flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

Commit

Agents and memory, checks and conflicts, profiles, slug renames, custom domains

Agents: every agent run is a record (kind, pull request, model, status, current step and a timeline of steps reported from the sandbox, cost), with Stop and Message. Projects get an Agents page (At work, Sessions, Memory), workspaces an Agent fleet and Workspace memory, and pull requests an Agent panel and a working badge in the list. Memory has two levels, project and workspace; agents write it with remember and read it with recall (MCP and REST), every g1t run starts with it, members curate it, and anything that looks like a secret is refused. Pull requests: the merge box lists every check and workflow job, failed ones open with their output (colours, line numbers, exit code), with re-run and history. Mergeability is worked out on every push: changed paths first, then a metered sandbox probe with git merge-tree when both sides touched a file. Conflicts are listed up front, block the merge, and are handed to the agent with the files named. Profiles at /u/<username>: name, picture, bio, location, website, joined; pull requests and issues with filters, only on repositories the viewer may read; workspaces shown only where membership is already shared or public. Authors link to their profiles; profiles get social cards. Workspace slugs can be renamed by owners. Every service moves its rows on workspace.renamed, idempotently and in any order; deployed apps are rebuilt under their new names with the old addresses redirecting; old links and git remotes redirect for 90 days. Repositories keep a store key that never changes, so a freed name can never adopt old git data. Custom domains for production: add a hostname (and its www or apex twin), get the DNS records to set, certificates by Cloudflare for SaaS, status kept current by the sweep; three included with Deployments, then $0.12 a month. The wildcard route waits for SaaS to be enabled on g1t.page. The sidebar no longer mistakes /u/<name> for a project.

syntaqxcommitted Parent582c31cBrowse files
140 files+2960−850/140 viewed
+48−1
99 mod oauth;
1010 mod openapi;
1111 mod operations;
12+mod renamed;
1213 mod rest;
1314
1415 use g1t_contracts::billing::FinishRunArgs;
1617 DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs,
1718 };
1819 use g1t_contracts::work::{
19− CheckRun, QueueState, ReportChecksArgs, ReportPlanArgs, ReportQueueArgs, ReportReviewArgs,
20+ CheckRun, Mergeable, QueueState, ReportChecksArgs, ReportMergecheckArgs, ReportPlanArgs,
21+ ReportQueueArgs, ReportReviewArgs,
2022 };
2123 use g1t_contracts::identity::AgentScope;
2224 use g1t_contracts::{Failure, FailureCode, Outcome, PrincipalKind, Viewer};
257259 results: serde_json::from_value(body["results"].clone()).unwrap_or_default(),
258260 error: body["error"].as_str().map(str::to_owned),
259261 conflict_with: body["conflictWith"].as_u64().map(|n| n as u32),
262+ conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(),
260263 },
261264 )
262265 .await?;
266269 }
267270 }
268271
272+/// A sandbox reporting whether a pull request merges cleanly. As with
273+/// checks, the probe's own token is the credential.
274+async fn report_mergecheck(
275+ request: &mut Request,
276+ services: &Services,
277+ pull_id: &str,
278+) -> Result<Response> {
279+ let body = json_body(request).await;
280+ let reported: Outcome<Mergeable> = g1t_kit::call(
281+ &services.work,
282+ "report_mergecheck",
283+ &ReportMergecheckArgs {
284+ pull_id: pull_id.to_owned(),
285+ token: body["token"].as_str().unwrap_or_default().to_owned(),
286+ conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(),
287+ error: body["error"].as_str().map(str::to_owned),
288+ },
289+ )
290+ .await?;
291+ match reported {
292+ Outcome::Ok(state) => Response::from_json(&json!({ "mergeable": state })),
293+ Outcome::Fail(refused) => failure(&refused),
294+ }
295+}
296+
269297 /// A sandbox reporting the review its agent wrote. As with checks, the
270298 /// run's own token is the credential.
271299 async fn report_review(
485513 Outcome::Fail(refused) => failure(&refused),
486514 };
487515 }
516+ ("POST", path) if path.starts_with("/mergechecks/") => {
517+ let pull_id = path.trim_start_matches("/mergechecks/").to_owned();
518+ return report_mergecheck(&mut request, &services, &pull_id).await;
519+ }
488520 ("POST", path) if path.starts_with("/queue/") => {
489521 let entry_id = path.trim_start_matches("/queue/").to_owned();
490522 return report_queue(&mut request, &services, &entry_id).await;
509541 Outcome::Fail(refused) => failure(&refused),
510542 };
511543 }
544+ // A sandbox reporting its agent run's steps, cost and end. As with
545+ // checks, the run's own token, in the body, is the credential.
546+ ("POST", path) if path.starts_with("/agent-runs/") && path.ends_with("/report") => {
547+ let run_id = path.trim_start_matches("/agent-runs/").trim_end_matches("/report");
548+ let mut body = json_body(&mut request).await;
549+ if !body.is_object() {
550+ body = json!({});
551+ }
552+ body["runId"] = json!(run_id);
553+ let reported: Outcome<Value> = g1t_kit::call(&services.work, "report_run", &body).await?;
554+ return match reported {
555+ Outcome::Ok(status) => Response::from_json(&json!({ "status": status })),
556+ Outcome::Fail(refused) => failure(&refused),
557+ };
558+ }
512559 ("POST", path) if path.starts_with("/checks/") => {
513560 let run_id = path.trim_start_matches("/checks/").to_owned();
514561 return report_checks(&mut request, &services, &run_id).await;
+1−0
1313 const INSTRUCTIONS: &str = "g1t is a git forge with issues and pull requests, built so that many agents can work on the same issue at once.
1414 To work on an issue: get_issue to read it and see the pull requests already made for it, then create_pull_request with the issue's number. You get a draft pull request with its own fork to clone and push to. Call record_session as you work so people can see your reasoning, push your commits, and call mark_pull_request_ready with a summary.
1515 Before going far, read `overlaps` on get_pull_request: other pull requests in progress that change the same files. One for a different issue will conflict with yours, so narrow your change or say so. `behind` means main has moved; pull it into your fork and push. Once your pull request is ready, the issue's acceptance checks are run for you in a clean sandbox; read their output from get_pull_request and push a fix if they fail.
16+Before you start, recall what the project and its workspace remember; when you learn something the next agent would need, remember it (scope project for this codebase, workspace for what holds across projects). Never a secret.
1617 Issues and pull requests are named by repository (\"owner/name\") and number, and share one sequence of numbers.";
1718
1819 fn result(id: &Value, value: Value) -> Value {
+7−0
7979 &[Op::ReadSession, Op::RecordSession],
8080 ),
8181 (
82+ "Memory",
83+ "What agents and people learned that the next agent should know, for one project or across a workspace. Members and g1t's agents only; never a secret.",
84+ &[Op::Remember, Op::Recall],
85+ ),
86+ (
8287 "Actions",
8388 "GitHub Actions workflows in .g1t/workflows, their runs, and their jobs' logs.",
8489 &[
156161 Op::MessageAgent => "Message an agent",
157162 Op::AnswerMessage => "Answer a message",
158163 Op::TakeMessages => "Take new messages",
164+ Op::Remember => "Remember something",
165+ Op::Recall => "Recall memory",
159166 Op::ListIssues => "List issues",
160167 Op::GetIssue => "Get an issue",
161168 Op::CreateIssue => "Create an issue",
+99−1
6161 MessageAgent,
6262 AnswerMessage,
6363 TakeMessages,
64+ Remember,
65+ Recall,
6466 ListIssues,
6567 GetIssue,
6668 CreateIssue,
285287 }
286288
287289 impl Op {
288− pub const ALL: [Op; 64] = [
290+ pub const ALL: [Op; 66] = [
289291 Op::Whoami,
290292 Op::CreateWorkspace,
291293 Op::ListRepos,
298300 Op::MessageAgent,
299301 Op::AnswerMessage,
300302 Op::TakeMessages,
303+ Op::Remember,
304+ Op::Recall,
301305 Op::ListIssues,
302306 Op::GetIssue,
303307 Op::CreateIssue,
370374 Op::MessageAgent => "message_agent",
371375 Op::AnswerMessage => "answer_message",
372376 Op::TakeMessages => "take_messages",
377+ Op::Remember => "remember",
378+ Op::Recall => "recall",
373379 Op::UpdateRepoSettings => "update_repo_settings",
374380 Op::ListIssues => "list_issues",
375381 Op::GetIssue => "get_issue",
451457 Op::AnswerMessage => {
452458 "Answer a question or a handoff another agent sent you, by the message's id. For a handoff, set decline to say it is not yours to take. The answer reaches the asking agent at its next step."
453459 }
460+ Op::Remember => {
461+ "Save something to memory that the next agent working here should know: how to build or test, a convention, a decision and why, a trap. scope project is for this codebase; scope workspace is for what holds across all of the workspace's projects, such as \"we use pnpm everywhere\" or where staging lives. One short fact per memory. Every g1t agent run is given memory at its start, pinned first. Never save a secret, key, token or password: text that looks like one is refused. Members of the workspace and g1t's agents only."
462+ }
463+ Op::Recall => {
464+ "Search what the project and its workspace remember, by words in any order, or list it all without a query. Pinned memories come first, then the most recently used. Members of the workspace and g1t's agents only."
465+ }
454466 Op::TakeMessages => {
455467 "For a g1t agent at work: the messages sent to it that it has not seen yet, from people and from other agents. Each is returned once."
456468 }
656668 &["repo", "id", "body"],
657669 ),
658670 Op::TakeMessages => object(numbered(json!({})), &["repo", "number"]),
671+ Op::Remember => object(
672+ json!({
673+ "repo": repo_schema(),
674+ "text": { "type": "string", "description": "What to remember, in one or two sentences. At most 1000 characters." },
675+ "scope": {
676+ "type": "string",
677+ "enum": ["project", "workspace"],
678+ "description": "project: about this codebase. workspace: true across the workspace's projects. Defaults to project.",
679+ },
680+ "kind": {
681+ "type": "string",
682+ "enum": ["fact", "convention", "decision", "gotcha"],
683+ "description": "Defaults to fact.",
684+ },
685+ "from_number": {
686+ "type": "integer",
687+ "description": "For an agent: the pull request you are working on, recorded as where it was learned.",
688+ },
689+ }),
690+ &["repo", "text"],
691+ ),
692+ Op::Recall => object(
693+ json!({
694+ "repo": repo_schema(),
695+ "query": { "type": "string", "description": "Words to look for. Leave out for everything." },
696+ "limit": { "type": "integer", "description": "At most 100 of each level; 20 if not given." },
697+ }),
698+ &["repo"],
699+ ),
659700 Op::UpdateRepoSettings => object(
660701 json!({
661702 "repo": repo_schema(),
11441185 )
11451186 }
11461187
1188+ /// Runs the operation. One that found nothing, or was refused, under a
1189+ /// workspace slug that has since been renamed runs again under the
1190+ /// workspace's current slug; neither outcome changed anything.
11471191 pub async fn run(
11481192 self,
11491193 services: &Services,
11501194 viewer: &Viewer,
11511195 input: &Value,
11521196 ) -> Result<Outcome<Value>> {
1197+ let outcome = self.run_once(services, viewer, input).await?;
1198+ if let Outcome::Fail(failure) = &outcome
1199+ && matches!(failure.code, FailureCode::NotFound | FailureCode::Forbidden)
1200+ && let Some(retargeted) = crate::renamed::retarget(services, input).await?
1201+ {
1202+ return self.run_once(services, viewer, &retargeted).await;
1203+ }
1204+ Ok(outcome)
1205+ }
1206+
1207+ async fn run_once(
1208+ self,
1209+ services: &Services,
1210+ viewer: &Viewer,
1211+ input: &Value,
1212+ ) -> Result<Outcome<Value>> {
11531213 if self.needs_user() && viewer.is_none() {
11541214 return failed(
11551215 FailureCode::Unauthenticated,
13161376 )
13171377 .await
13181378 }
1379+ Op::Remember => {
1380+ let scope = match input["scope"].as_str() {
1381+ Some("workspace") => "workspace",
1382+ None | Some("project") => "project",
1383+ Some(_) => return failed(FailureCode::Invalid, "scope must be project or workspace."),
1384+ };
1385+ let kind = input["kind"].as_str().unwrap_or("fact");
1386+ if g1t_contracts::agents::MemoryKind::parse(kind).is_none() {
1387+ return failed(FailureCode::Invalid, "kind must be fact, convention, decision or gotcha.");
1388+ }
1389+ pass(
1390+ work,
1391+ "add_memory",
1392+ &json!({
1393+ "actor": actor(),
1394+ "workspace": repo.namespace.to_lowercase(),
1395+ "repo": repo,
1396+ "scope": scope,
1397+ "text": text(input, "text"),
1398+ "kind": kind,
1399+ "fromNumber": integer(input, "from_number"),
1400+ }),
1401+ )
1402+ .await
1403+ }
1404+ Op::Recall => {
1405+ pass(
1406+ work,
1407+ "recall",
1408+ &json!({
1409+ "viewer": viewer,
1410+ "repo": repo,
1411+ "query": optional_text(input, "query"),
1412+ "limit": integer(input, "limit"),
1413+ }),
1414+ )
1415+ .await
1416+ }
13191417 Op::TakeMessages => {
13201418 pass(
13211419 work,
+65−1
979979 ],
980980 "statuses": []
981981 },
982− "notes": "| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `checks` | The latest run of the acceptance checks against its head, with each command's output. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n| `statuses` | Commit statuses reported for its head, such as workflow runs. |\n\nChecks are started by g1t, not through the API. They run when a pull request becomes ready for review and again when its head moves."
982+ "notes": "| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `checks` | The latest run of the acceptance checks against its head, with each command's output. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n| `statuses` | Commit statuses reported for its head, such as workflow runs. |\n| `mergeable` | Whether it merges cleanly into its target: `clean`, `conflicting`, `checking` (being worked out) or `unknown`. Worked out ahead of time whenever it or its target moves. |\n| `conflicts` | When `mergeable` is `conflicting`, the files that conflict. Resolve them by merging the target in, or have the g1t agent do it. |\n| `earlierChecks` | Earlier runs of the acceptance checks, newest first, without their output. |\n\nChecks are started by g1t, not through the API. They run when a pull request becomes ready for review and again when its head moves."
983983 },
984984 "get_pull_request_changes": {
985985 "response": {
25602560 "created": true
25612561 },
25622562 "notes": "`created` is false when the ticket was imported before; `number` is then that issue."
2563+ },
2564+ "remember": {
2565+ "request": {
2566+ "text": "The tests need TZ=UTC or the date tests fail.",
2567+ "scope": "project",
2568+ "kind": "gotcha",
2569+ "from_number": 14
2570+ },
2571+ "response": {
2572+ "id": "mem_01m43v8q2w3e4r5t6y7u8i9o0p",
2573+ "scope": "project",
2574+ "workspace": "syntaqx",
2575+ "repo": {
2576+ "namespace": "syntaqx",
2577+ "name": "hello"
2578+ },
2579+ "text": "The tests need TZ=UTC or the date tests fail.",
2580+ "kind": "gotcha",
2581+ "source": {
2582+ "kind": "run",
2583+ "runId": "arn_01m43v2c1p9k8d7e6f5a4b3c2d",
2584+ "repo": {
2585+ "namespace": "syntaqx",
2586+ "name": "hello"
2587+ },
2588+ "number": 14
2589+ },
2590+ "createdBy": "g1t-agent",
2591+ "pinned": false,
2592+ "createdAt": "2026-10-01T18:40:12.000Z",
2593+ "updatedAt": "2026-10-01T18:40:12.000Z",
2594+ "lastUsedAt": null
2595+ },
2596+ "notes": "Text that looks like a key, a token or a password is refused with `invalid`: memory is read by every agent in the workspace. Saving the same text again in the same scope returns the memory already kept."
2597+ },
2598+ "recall": {
2599+ "query": {
2600+ "q": "pnpm"
2601+ },
2602+ "response": {
2603+ "project": [],
2604+ "workspace": [
2605+ {
2606+ "id": "mem_01m43t0a1s2d3f4g5h6j7k8l9z",
2607+ "scope": "workspace",
2608+ "workspace": "syntaqx",
2609+ "repo": null,
2610+ "text": "We use pnpm everywhere, never npm or yarn.",
2611+ "kind": "convention",
2612+ "source": {
2613+ "kind": "person",
2614+ "runId": null,
2615+ "repo": null,
2616+ "number": null
2617+ },
2618+ "createdBy": "syntaqx",
2619+ "pinned": true,
2620+ "createdAt": "2026-10-01T18:40:12.000Z",
2621+ "updatedAt": "2026-10-01T18:40:12.000Z",
2622+ "lastUsedAt": null
2623+ }
2624+ ]
2625+ },
2626+ "notes": "Members of the workspace only. Each memory returned is marked used, which keeps it near the front of what agents are given."
25632627 }
25642628 }
+80−0
1+//! Old workspace slugs, after a rename. An operation that names a
2+//! repository (`owner/name`) or a workspace by a slug the workspace has
3+//! since been renamed from is run again under the slug it has now, so
4+//! scripts and agents written against the old address keep working while
5+//! it redirects.
6+
7+use g1t_contracts::identity::SlugArgs;
8+use serde_json::Value;
9+use worker::Result;
10+
11+use crate::operations::Services;
12+
13+/// The workspace slug an operation's input names: the owner in `repo`, or
14+/// `workspace`.
15+pub fn named_slug(input: &Value) -> Option<&str> {
16+ input["repo"]
17+ .as_str()
18+ .and_then(|repo| repo.split_once('/'))
19+ .map(|(owner, _)| owner)
20+ .or_else(|| input["workspace"].as_str())
21+ .filter(|slug| !slug.is_empty())
22+}
23+
24+/// `input` with `old` replaced by `new` wherever it names the workspace.
25+pub fn rewrite(input: &Value, old: &str, new: &str) -> Value {
26+ let mut input = input.clone();
27+ if let Some(repo) = input["repo"].as_str()
28+ && let Some((owner, name)) = repo.split_once('/')
29+ && owner.eq_ignore_ascii_case(old)
30+ {
31+ input["repo"] = Value::String(format!("{new}/{name}"));
32+ }
33+ if input["workspace"]
34+ .as_str()
35+ .is_some_and(|workspace| workspace.eq_ignore_ascii_case(old))
36+ {
37+ input["workspace"] = Value::String(new.to_owned());
38+ }
39+ input
40+}
41+
42+/// The input under the workspace's current slug, if it names an old one.
43+pub async fn retarget(services: &Services, input: &Value) -> Result<Option<Value>> {
44+ let Some(old) = named_slug(input) else {
45+ return Ok(None);
46+ };
47+ let current: Option<String> = g1t_kit::call(
48+ &services.identity,
49+ "resolve_slug",
50+ &SlugArgs {
51+ slug: old.to_owned(),
52+ },
53+ )
54+ .await?;
55+ Ok(current.map(|new| rewrite(input, old, &new)))
56+}
57+
58+#[cfg(test)]
59+mod tests {
60+ use super::*;
61+ use serde_json::json;
62+
63+ #[test]
64+ fn finds_the_slug_an_operation_names() {
65+ assert_eq!(named_slug(&json!({ "repo": "acme/rocket" })), Some("acme"));
66+ assert_eq!(named_slug(&json!({ "workspace": "acme" })), Some("acme"));
67+ assert_eq!(named_slug(&json!({ "id": "x" })), None);
68+ }
69+
70+ #[test]
71+ fn rewrites_only_the_old_slug() {
72+ let input = json!({ "repo": "Acme/rocket", "workspace": "acme", "title": "acme/x" });
73+ assert_eq!(
74+ rewrite(&input, "acme", "acme-inc"),
75+ json!({ "repo": "acme-inc/rocket", "workspace": "acme-inc", "title": "acme/x" })
76+ );
77+ let other = json!({ "repo": "globex/rocket" });
78+ assert_eq!(rewrite(&other, "acme", "acme-inc"), other);
79+ }
80+}
+7−0
6565 Op::AnswerMessage,
6666 &[],
6767 ),
68+ route("POST", "/repos/:owner/:name/memory", Op::Remember, &[]),
69+ route(
70+ "GET",
71+ "/repos/:owner/:name/memory",
72+ Op::Recall,
73+ &[("q", "query"), ("limit", "limit")],
74+ ),
6875 route(
6976 "GET",
7077 "/repos/:owner/:name/events",
+2−0
9090 { label: 'g1t agents', slug: 'guides/g1t-agents' },
9191 { label: 'Outcomes and plans', slug: 'guides/outcomes' },
9292 { label: 'Talking to agents', slug: 'guides/talking-to-agents' },
93+ { label: 'Agents, sessions and memory', slug: 'guides/agents-and-memory' },
9394 { label: 'Bring your own agent', slug: 'guides/bring-your-own-agent' },
9495 ],
9596 },
105106 {
106107 label: 'Landing changes',
107108 items: [
109+ { label: 'Pull requests and checks', slug: 'guides/pull-requests' },
108110 { label: 'The merge queue', slug: 'guides/merge-queue' },
109111 { label: 'Sessions and why-blame', slug: 'guides/why-blame' },
110112 { label: 'Forks and branches', slug: 'concepts/forks' },
+193−0
1+---
2+title: Agents, sessions and memory
3+description: Watch every agent at work, stop or steer a run, read past sessions, and curate what agents remember about a project and across a workspace.
4+---
5+
6+Every project has an **Agents** section, beside its pull requests: who is
7+working on it right now, what each agent did before, and what agents have
8+learned about the project. The workspace has the same across all of its
9+projects: the **Agent fleet** and **Workspace memory**.
10+
11+| You want to | Go to |
12+| --- | --- |
13+| See what agents are doing on a project now | **Agents → At work** |
14+| Stop a run, or tell it something | **Stop** or **Message** on the run |
15+| See how a pull request was made | **Agents → Sessions**, or the pull request's Agent panel |
16+| Teach every agent something about this code | **Agents → Memory** |
17+| Teach every agent something true in every project | **Workspace memory**, under Across projects |
18+| See every agent across the workspace, and its cost | **Agent fleet**, under Across projects |
19+
20+## Runs
21+
22+A **run** is one sandbox g1t starts. Each g1t agent run does one kind of
23+work on one pull request:
24+
25+| Kind | What the agent does |
26+| --- | --- |
27+| `implement` | Makes the change an issue asks for, in a new pull request. |
28+| `revise` | Addresses failed checks or a review on its own pull request. |
29+| `review` | Reviews a pull request and gives a verdict. |
30+| `answer` | Answers another agent's question or handoff. |
31+| `update` | Merges in the branch its pull request will land on. |
32+| `plan` | Turns an outcome into a plan of issues. |
33+
34+Sandboxes that run commands rather than a model show too, as `checks` and
35+`queue`, so the list is everything g1t is running for the project.
36+
37+Each run records:
38+
39+- the agent (`g1t-agent`) and, for members, the model it runs on;
40+- its pull request, or for a plan, the outcome;
41+- its status: starting, running, done, failed or stopped;
42+- its **current step**, one line such as `Edited src/auth.ts` or
43+ `Ran npm test`, and the latest 200 steps;
44+- how long it has run, and for members, what it has cost so far.
45+
46+The cost is what the agent harness reports as the run goes. What the
47+workspace is charged is on its [Usage](/guides/usage-and-billing/) page.
48+
49+### At work
50+
51+**Agents → At work** lists the project's runs: the ones under way first,
52+updating every few seconds while any is running, then the ones that
53+recently finished. Choose a run's step count for its step-by-step page,
54+or **Session** for the full record of its pull request.
55+
56+The pull request page shows the same for its own agent, near the top: who
57+is working on it, its stage, what it is doing this minute, for how long,
58+and what it has cost. The pull request list marks each pull request an
59+agent is working on with what it is doing, such as **Revising**. An issue
60+shows which agent picked it up and its current step.
61+
62+### Stop a run
63+
64+Members of the workspace can stop a run with **Stop**:
65+
66+- its sandbox shuts down at once, and nothing more is pushed;
67+- what it already pushed stays on the pull request;
68+- g1t stops seeing that pull request through, and marks it **Needs you**,
69+ so it does not start the same work again on its own.
70+
71+To start again, ask for what you want on the pull request: a review, a
72+catch-up, or changes in a review, which sends the agent back to revise.
73+
74+### Message a run
75+
76+**Message** sends the agent on the run's pull request a message, the same
77+as **Message the agent** on the pull request. When it arrives depends on
78+the run:
79+
80+- **Implement, revise and answer runs** read messages while they work, at
81+ their next step between tool calls.
82+- **Review, update and plan runs** do not. The message waits on the pull
83+ request and is given to the agent's next run there. g1t says so when you
84+ send it.
85+
86+See [talk to agents](/guides/talking-to-agents/) for what an agent does
87+with a message.
88+
89+## Sessions
90+
91+**Agents → Sessions** lists every pull request with a recorded session,
92+most recently active first, with its first prompt, how many entries and
93+tool calls it has, the kinds of runs g1t made for it, and their cost.
94+Filter by kind of run, by outcome (in progress, ready for review, merged
95+or closed), or by pull request number.
96+
97+A session's page shows its runs, each with its steps, then the session
98+itself: prompts, what the agent said, the tools it ran and, with **Show
99+tool results**, what they returned. Sessions from your own agents, recorded
100+with `record_session`, are listed the same way. See
101+[sessions and why-blame](/guides/why-blame/) for what a session records.
102+
103+A session is as visible as its project. The model and the cost of a run
104+are shown only to members of the workspace.
105+
106+## Memory
107+
108+Memory is what agents and people have learned that the next agent should
109+know. It has two levels.
110+
111+**Project memory** is about one project's code:
112+
113+- how to build and test it: "Run `npm run db:reset` before the integration
114+ tests";
115+- its conventions: "Components live in `src/components/ui`; import from
116+ there, not from radix-ui";
117+- decisions and why: "We keep the v1 webhook payload; two customers still
118+ parse it";
119+- its traps: "The date tests fail unless `TZ=UTC`".
120+
121+**Workspace memory** is true across all of the workspace's projects:
122+
123+- "We use pnpm everywhere, never npm or yarn";
124+- "Staging lives at staging.example.com and deploys from main";
125+- "Every service logs JSON to stdout";
126+- "Ask Ana before changing anything under billing".
127+
128+Put something in workspace memory only when it holds in every project. When
129+it is true of one codebase, it belongs to that project.
130+
131+### How agents use it
132+
133+Every g1t agent run is given memory when it starts: the workspace's and
134+the project's, each labelled, pinned memories first, then the ones used
135+most recently, up to about 6,000 characters. Agents are told to treat it as
136+notes from colleagues: usually right, sometimes out of date, and where it
137+disagrees with the code, the code wins.
138+
139+Agents add to it as they work with the `remember` tool, choosing the scope
140+themselves: `project` for this codebase, `workspace` for what holds across
141+projects. Each memory records where it came from: the person who wrote it,
142+or the agent's run and the pull request it was working on, linked from the
143+memory.
144+
145+Your own agents can use memory too, through the [MCP tools](/reference/mcp/#memory)
146+`remember` and `recall` or the API:
147+
148+```sh
149+curl -X POST https://api.g1t.sh/repos/acme/web/memory \
150+ -H "Authorization: Bearer $G1T_TOKEN" \
151+ -H "Content-Type: application/json" \
152+ -d '{"text": "The date tests fail unless TZ=UTC.", "kind": "gotcha"}'
153+
154+curl "https://api.g1t.sh/repos/acme/web/memory?q=tests" \
155+ -H "Authorization: Bearer $G1T_TOKEN"
156+```
157+
158+Each memory `recall` returns counts as used, which keeps it near the front
159+of what agents are given.
160+
161+### Curate it
162+
163+Memory is only as good as it is true. On **Agents → Memory** and on
164+**Workspace memory**, members can:
165+
166+- **add** a memory, with its kind: fact, convention, decision or gotcha;
167+- **pin** one, so every agent gets it first, whatever the budget;
168+- **edit** one that has drifted, or change its kind;
169+- **forget** one that no longer holds.
170+
171+Each shows who added it, when, and when it was last given to an agent. A
172+memory that has not been given to an agent for a long time is a good one to
173+check. Saving the same text twice keeps one memory. A project or a
174+workspace keeps up to 500.
175+
176+The project's Memory page also lists the workspace's memory, read-only,
177+since agents there get both; manage it from **Workspace memory**.
178+
179+### Who can see it
180+
181+Memory is for members of the workspace and their agents. It is never shown
182+on a public project's pages, since it can hold what the workspace keeps to
183+itself.
184+
185+### Never a secret
186+
187+Every agent in the workspace reads memory, so it never holds a secret. g1t
188+refuses text that looks like one: a key or token with a known prefix (such
189+as `sk-`, `ghp_`, `AKIA` or `g1t_`), a private key, a URL with a password
190+in it, `password=` or `token:` followed by a value, or a long random string.
191+Say where the secret lives instead: "The deploy key is the `DEPLOY_KEY`
192+secret". Agents read secrets from [secrets and variables](/guides/secrets-and-variables/),
193+never from memory.
+93−1
2121 `pr-<number>` in place of the branch. A name too long for an address, or
2222 one another app already has, is shortened or given a short suffix.
2323
24+When a workspace is [renamed](/guides/workspaces/#rename-a-workspace), its
25+apps move to addresses with the new name, and the old addresses redirect to
26+them for 90 days.
27+
2428 An app runs only while it answers a request. One nobody visits runs
2529 nothing and costs nothing, and the next visit wakes it in milliseconds.
2630
133137 branch, builds and replaces production. The **Deployments** page shows the
134138 live address, the commit it runs and when it went up.
135139
140+## Custom domains
141+
142+Production can be served at a domain of your own, such as `example.com` or
143+`www.example.com`, as well as at its address on g1t.page. A domain belongs
144+to the project, not to a build: every redeploy is served on it with nothing
145+to change.
146+
147+### Add a domain
148+
149+1. Open the project's **Settings → Domains**,
150+ `g1t.sh/<workspace>/<repo>/settings/domains`.
151+2. Enter the domain and choose **Add domain**. Leave **Also add the www
152+ (or apex) twin** checked to add both `example.com` and
153+ `www.example.com` at once: the one you typed serves the app, and the
154+ other redirects to it with a `308`, path and query kept.
155+3. Add the DNS records the page lists, at whoever manages the domain's
156+ DNS. Each name and value has a copy button.
157+4. Wait. The page follows the domain from **Waiting for DNS** to
158+ **Issuing certificate** to **Active** by itself; **Check now** asks
159+ again at once.
160+
161+Custom domains need the workspace's Deployments plan. Only members of the
162+workspace can add or remove them.
163+
164+### A subdomain, such as www
165+
166+Add one record:
167+
168+| Type | Name | Target |
169+| --- | --- | --- |
170+| `CNAME` | `www` | `domains.g1t.page` |
171+
172+The same goes for any subdomain, such as `app.example.com` (name `app`).
173+
174+### The apex, such as example.com
175+
176+DNS does not allow a plain `CNAME` at the apex of a domain, so use your
177+provider's flattened form of one, pointed at `domains.g1t.page`:
178+
179+| DNS provider | Record |
180+| --- | --- |
181+| Cloudflare DNS | `CNAME` at `@` (Cloudflare flattens it) |
182+| Amazon Route 53 | Not supported by alias records to other zones; use the www pairing below |
183+| DNSimple, NS1, Namecheap, Porkbun, Gandi | `ALIAS` at `@` |
184+| DNS Made Easy, Constellix | `ANAME` at `@` |
185+
186+If your provider has no flattened `CNAME`, `ALIAS` or `ANAME`, add
187+`www.example.com` on g1t instead, and set up your provider's forwarding (or
188+any redirect) from `example.com` to `www.example.com`.
189+
190+### Verification and certificates
191+
192+Pointing the domain at `domains.g1t.page` is what proves it is yours: once
193+the record is seen, the certificate authority checks the domain over HTTP
194+and issues a certificate, renewed by itself before it expires. Visitors are
195+always served over HTTPS, TLS 1.2 or newer.
196+
197+The page may also list a `TXT` record named `_cf-custom-hostname.<domain>`.
198+It proves ownership before traffic moves, which is useful when the domain
199+is serving a site elsewhere today: add the `TXT` first, wait for
200+**Issuing certificate** or **Active**, then change the `CNAME`. Any other
201+`TXT` records listed are for the certificate and are needed as shown.
202+
203+DNS changes can take from a few minutes to an hour to be seen. A domain
204+that stays at **Waiting for DNS** usually has a record with a typo, a
205+leftover `A` or `AAAA` record beside the new one, or (on Cloudflare DNS) a
206+`CAA` record that does not allow the certificate's authority.
207+
208+### Remove a domain
209+
210+Choose **Remove** beside it. It stops serving the project at once, and any
211+domain redirecting to it goes too. Your DNS records are left as they are.
212+
136213 ## When apps come down
137214
138215 Nothing keeps running unasked. An app comes down, and stops costing
195272 | 10 apps | The most apps up at once: production and previews together, across the workspace's projects. |
196273 | 1 million requests | To all of the workspace's apps. |
197274 | 3 million CPU milliseconds | Time your code spends computing. Waiting on the network is not counted. |
275+| 3 custom domains | Across the workspace's projects, with their certificates. A www/apex pair is two. |
198276
199277 **Usage past that,** and **every build**, come out of the workspace's
200278 [credit](/guides/usage-and-billing/#add-credit) at Cloudflare's price plus
206284 | Each app past 10 | $0.024 a month |
207285 | Each million requests past 1 million | $0.36 |
208286 | Each million CPU milliseconds past 3 million | $0.024 |
287+| Each custom domain past 3 | $0.12 a month, by the most the workspace had at once that month |
209288
210289 Builds are charged when they finish. Usage past the allowance is charged
211290 once, on the first sweep after the month ends, as one line: *Deployments in
251330 g1t's [Workers for Platforms](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/)
252331 namespace, under the name in its address.
253332 5. A request to `*.g1t.page` reaches g1t's dispatcher, which runs the app
254− by the name in the hostname. Nothing else is looked up.
333+ by the name in the hostname. Nothing else is looked up. A request to a
334+ custom domain reaches the same dispatcher through Cloudflare for SaaS;
335+ it looks the hostname up once, in a key-value store kept at the edge.
255336
256337 Your code never holds a Cloudflare credential, and apps are served from
257338 `g1t.page`, not `g1t.sh`, so they share no cookies or origin with the site
266347 | "the output directory `x` does not exist after the build" | The build wrote elsewhere: check its log, then fix **Output directory**. |
267348 | "`d1_databases` is not provisioned on g1t.page yet" | The app deployed without that binding. See [Workers projects](#workers-projects). |
268349 | A preview page says "This preview is not up" | It came down (see [When apps come down](#when-apps-come-down)). Push, or choose **Redeploy**. |
350+| "Custom domains are being switched on" | Custom domains are not on for g1t.page yet. Domains you add are kept, and set up by themselves once they are. |
351+| A domain stays at **Waiting for DNS** | Check the record against the one listed, remove other `A`/`AAAA` records for the same name, then choose **Check now**. |
352+| A domain says "This domain is not set up" | It points at g1t, but no project has added it. Add it under **Settings → Domains**. |
269353 | "The build did not finish in 45 minutes" | Builds are stopped after 45 minutes. Make the build faster, or build less for previews with **Build command**. |
270354
271355 ## Running your own g1t
280364 `npx wrangler secret put CLOUDFLARE_API_TOKEN` in `services/deployments`.
281365 4. Deploy `services/deployments`, `services/pages`, and the runner.
282366
367+Custom domains also need Cloudflare for SaaS on the apps' zone. Turn it on
368+under the zone's **SSL/TLS → Custom Hostnames**, give the token **SSL and
369+Certificates: Edit** on the zone, and run `scripts/setup-custom-domains.sh`:
370+it creates the `g1t-domains` KV namespace, the fallback origin's DNS record
371+(`domains`, `AAAA`, `100::`, proxied) and sets it as the fallback origin.
372+The dispatcher's `*/*` route on the zone, in `services/pages/wrangler.jsonc`,
373+is what brings custom domains' traffic to it.
374+
283375 Without a card processor configured, every feature is on and nothing is
284376 charged.
+7−0
9292 pull requests to be up to date before merging** in its settings; catching
9393 up is then a step of its own, before "ready".
9494
95+g1t also works out ahead of time whether each pull request still merges
96+cleanly, every time it or `main` moves
97+([how](/guides/pull-requests/#conflicts)). When one of an agent's pull
98+requests is found to conflict, g1t does not wait for a merge to trip over
99+it: the agent is sent to merge `main` in and resolve the conflicts, told
100+which files conflict, and the checks run again on the result.
101+
95102 The pull request's page shows which step it is at. If g1t cannot finish,
96103 because the checks still fail after two revisions, a review could not be
97104 written, or a conflict could not be resolved while bringing it up to date,
+8−0
1717 git clone https://g1t.sh/syntaqx/g1t.git
1818 ```
1919
20+If the workspace is [renamed](/guides/workspaces/#rename-a-workspace), the
21+old remote redirects to the new one for 90 days. Git follows the redirect
22+and warns about it; point the remote at the new address:
23+
24+```sh
25+git remote set-url origin https://g1t.sh/<new-workspace>/<repo>.git
26+```
27+
2028 ## Authentication
2129
2230 Pushing, and reading private repositories, needs credentials. Use your
+8−2
106106 - If `main` moved outside the queue, every entry is tested again on the new
107107 `main`.
108108
109+A pull request that is already known to conflict with `main` is not added
110+to the queue: [its merge box](/guides/pull-requests/#conflicts) says which
111+files conflict and how to resolve them first.
112+
109113 ## When an entry fails
110114
111115 An entry fails when its checks or its `merge_group` workflows fail in the
116120 1. Its pull request gets a failed check run. Each command is named with the
117121 state it ran in, such as `cargo test (merge queue, on the default branch
118122 with #41 merged in first)`, and the run says why it failed. A conflict
119− names the pull request ahead it collided with.
120−2. Its conversation records that it was taken out of the queue, and why.
123+ names the pull request ahead it collided with, and the files.
124+2. Its conversation records that it was taken out of the queue, and why: a
125+ conflict links the pull request it collided with and each conflicting
126+ file, which opens in the pull request's changes.
121127 3. The entries that were tested on top of it are tested again without it.
122128
123129 A g1t agent's pull request is then sent back to revise, like any failed
+128−0
1+---
2+title: Pull requests and checks
3+description: What the merge box shows before a pull request can merge, every check and what a failed one printed, and conflicts found before anyone tries to merge.
4+---
5+
6+At the foot of an open pull request's conversation, the **merge box** says
7+what stands between it and its target branch: its checks, its reviews,
8+whether it merges cleanly, and the button that merges it. Everything in it
9+updates by itself while something is still running.
10+
11+## Checks
12+
13+A pull request has two kinds of checks, and the merge box lists both in
14+one place:
15+
16+- **Acceptance checks**: the commands written on the issue it is for, run
17+ against its head in a clean sandbox that holds nothing but that commit.
18+ No agent runs there, so a pass says something about the code. See
19+ [acceptance checks](/concepts/overview/#acceptance-checks).
20+- **Workflows**: the repository's [GitHub Actions workflows](/guides/actions/)
21+ run on its head, listed job by job.
22+
23+The first line sums them up: **All checks have passed**, **2 of 5 checks
24+failed**, or how many are still running. Below it, each check has a row with
25+its state, its command or job name, and how long it took.
26+
27+### What a failed check printed
28+
29+A failed acceptance check opens by itself and shows:
30+
31+- the exit code, or that it was stopped for taking too long (ten minutes);
32+- what it printed, standard output and error together, as a log with line
33+ numbers and its colours, and a button to copy it;
34+- a note when the output was long and only its end was kept, which is where
35+ failures are.
36+
37+A workflow job's **Details** opens its log on the run's page.
38+
39+Above the acceptance checks is the commit they ran on, linked. If the pull
40+request has been pushed to since, the box says so: those results are for
41+an older commit, and the latest push has not been checked yet.
42+
43+When the checks could not be run at all, for example because the sandbox
44+stopped, the box says why instead of listing results.
45+
46+### Running them again
47+
48+**Re-run checks** runs the acceptance checks again on the current head.
49+Whoever opened the pull request and members of the workspace can. Members
50+can also **Re-run failed jobs** of a workflow that failed.
51+
52+**Earlier runs of the acceptance checks** lists the runs before the latest,
53+with the commit each ran on and how it went.
54+
55+The acceptance checks also run again by themselves on every push.
56+
57+## Conflicts
58+
59+g1t works out whether a pull request merges cleanly into its target before
60+anyone tries to merge it, and again whenever either side moves: a push to
61+the pull request, or anything landing on the target branch.
62+
63+1. **Without a sandbox.** g1t compares the files the pull request changed
64+ since it and the target last agreed with the files the target changed
65+ since then. If they share none, the merge cannot conflict, and that is
66+ the answer.
67+2. **With a short probe.** If they share files, a sandbox merges the two
68+ commits without an agent and pushes nothing, and reports the files that
69+ conflict. Meanwhile the box says **Checking whether this merges cleanly**,
70+ and the merge button waits. Probes are metered as sandbox time, like
71+ checks; each pair of commits is probed once, and a repository runs at most
72+ three at a time, the rest following in turn.
73+
74+A pull request that conflicts shows **This branch has conflicts that must
75+be resolved**, the conflicting files, each linked to its diff, and three
76+ways to resolve them:
77+
78+- **Resolve with g1t agent.** An agent merges the target branch in,
79+ resolves the conflicts keeping what both sides meant, and pushes the
80+ result. It is told which files conflict. Available to whoever can push to
81+ the pull request: for a pull request's fork, whoever opened it; for a
82+ branch, any member.
83+- **Resolve in the browser.** Coming soon.
84+- **On the command line.** The box lists the commands, each with a copy
85+ button. For a pull request from a branch:
86+
87+ ```sh
88+ git fetch origin
89+ git checkout my-branch
90+ git merge origin/main
91+ # fix each conflicting file, then
92+ git add -A && git commit --no-edit
93+ git push origin my-branch
94+ ```
95+
96+ For a pull request in its own fork, clone the fork and pull `main` into
97+ it instead:
98+
99+ ```sh
100+ git clone https://g1t.sh/pulls/<id>.git && cd <id>
101+ git pull --no-rebase https://g1t.sh/<owner>/<repo>.git main
102+ ```
103+
104+While it conflicts, it cannot be merged or added to the
105+[merge queue](/guides/merge-queue/), and the merge button says so. Once the
106+fix is pushed, g1t works it out again.
107+
108+A pull request that only has fallen behind its target, without conflicts,
109+still merges: merging brings it up to date first, unless the repository
110+requires pull requests to be up to date.
111+
112+A [g1t agent's](/guides/g1t-agents/) pull request that is found to conflict
113+is sent back to resolve it by itself, before it is ready.
114+
115+## From the API
116+
117+`GET /repos/{owner}/{name}/pulls/{number}` returns, besides the pull request:
118+
119+| Field | What it is |
120+| --- | --- |
121+| `checks` | The latest run of the acceptance checks: `status`, `headCommit`, `error`, and `results`, each with `command`, `passed`, `exitCode`, `output` and `durationMs`. |
122+| `earlierChecks` | The runs before it, newest first, without their output. |
123+| `statuses` | What each workflow run said about its head. |
124+| `mergeable` | `clean`, `conflicting`, `checking` or `unknown`. |
125+| `conflicts` | When conflicting, the files that conflict. |
126+| `behind` | Whether its target has moved on without it. |
127+
128+An agent sees the same through the `get_pull_request` tool.
+106−9
11 ---
22 title: Workspaces
3−description: Workspaces, their names and icons, members and roles, and access tokens that belong to a workspace.
3+description: Workspaces, their names and icons, renaming one, members and roles, and access tokens that belong to a workspace.
44 ---
55
66 A workspace owns repositories and is the first part of their address:
1515
1616 1. Open [g1t.sh/workspaces/new](https://g1t.sh/workspaces/new).
1717 2. Choose its name in URLs: lowercase letters, digits and single hyphens.
18− It cannot be changed later, because repository addresses and clones
19− depend on it.
18+ An owner can [change it later](#rename-a-workspace), and old addresses
19+ redirect for 90 days.
2020 3. Optionally give it a display name.
2121
2222 From the API, `POST /workspaces` with `slug` and `name`, or the
4444 | | Example | Where it appears | Changes |
4545 | --- | --- | --- | --- |
4646 | **Display name** | `Flagon Industries` | The sidebar, the top of its page, mission control and link previews | Any time, up to 80 characters; spaces and capitals are fine |
47−| **Slug** | `flagon` | Every address: `g1t.sh/flagon/<repo>` and clone URLs | Never, so links and clones keep working |
47+| **Slug** | `flagon` | Every address: `g1t.sh/flagon/<repo>`, clone URLs, API paths and `g1t.page` app addresses | By an owner, once a day at most; the old one redirects for 90 days. See [rename a workspace](#rename-a-workspace) |
4848
4949 Without a display name, the slug is shown. Where an address is shown, the
5050 slug is in monospace beside the name. Owners change the display name and
5151 description on **Settings → General**; from the API, `update_workspace`.
5252
53−A workspace also has an icon, as an organization does on GitHub. Without
53+A workspace also has an icon. Without
5454 one, g1t draws its first letter in a colour of its own. To upload one, an
5555 owner opens **Settings → General** and picks an image:
5656
6767 You can upload a picture of yourself the same way, under
6868 [Settings → Picture](https://g1t.sh/settings#picture).
6969
70+## Rename a workspace
71+
72+Renaming changes the slug, the first part of every address under the
73+workspace. The display name is separate; change it on its own under
74+**Settings → General → Workspace details**. Only owners can rename a
75+workspace.
76+
77+1. Open the workspace's **Settings → General** and go to **Address**.
78+2. Type the new slug. The field shows the new address, `g1t.sh/<new>`, and
79+ whether the name is available.
80+3. Choose **Change address**, read what changes, type the new slug to
81+ confirm, and choose **Change address** again.
82+
83+You land on the workspace's settings at its new address. Repositories,
84+issues and the rest move with it within a few seconds.
85+
86+### What changes
87+
88+| | Before | After |
89+| --- | --- | --- |
90+| Pages | `g1t.sh/old/<repo>` | `g1t.sh/new/<repo>` |
91+| Git remotes | `https://g1t.sh/old/<repo>.git` | `https://g1t.sh/new/<repo>.git` |
92+| API paths | `https://api.g1t.sh/repos/old/<repo>` | `https://api.g1t.sh/repos/new/<repo>` |
93+| MCP tool arguments | `"owner": "old"` | `"owner": "new"` |
94+| Production apps | `https://<project>-old.g1t.page` | `https://<project>-new.g1t.page` |
95+| Previews | `https://<project>-git-<branch>-old.g1t.page` | `https://<project>-git-<branch>-new.g1t.page` |
96+
97+These stay the same: the display name, description and icon, members and
98+roles, access tokens, secrets and variables, integrations, webhooks,
99+issues and pull requests, and billing, plans and credit.
100+
101+### What redirects, and for how long
102+
103+For 90 days after a rename, the old name keeps working:
104+
105+| | Behaviour |
106+| --- | --- |
107+| Web pages | Answer with a permanent redirect (301) to the same page under the new name, query string included. |
108+| `git clone`, `fetch`, `pull` and `push` | Redirected to the new remote. Git follows it, but prints a warning each time until you update the remote. |
109+| API | Calls to the old paths redirect to the new ones. |
110+| `g1t.page` apps | Production and preview addresses under the old name redirect to the new ones. |
111+
112+Update your remotes now rather than relying on the redirect:
113+
114+```sh
115+git remote set-url origin https://g1t.sh/<new>/<repo>.git
116+```
117+
118+Update anything else that has the old name written into it, too: links in
119+READMEs and docs, CI configuration, API clients and MCP clients.
120+
121+### Limits
122+
123+- A workspace can be renamed once every 24 hours.
124+- For 90 days the old name is held for the workspace. Nobody else can take
125+ it, and you can rename back to it.
126+- After 90 days the redirects stop, and anyone can create a workspace or
127+ register a username with the old name. Links and remotes that still use
128+ it then reach whatever has the name, or nothing.
129+- The new name follows the same rules as a new workspace: lowercase letters,
130+ digits and single hyphens, up to 39 characters, not a reserved word, and
131+ not another workspace's slug or someone else's username.
132+
70133 ## Members and roles
71134
72135 | Role | Can |
88151
89152 | Settings | Who | |
90153 | --- | --- | --- |
91−| **General** | Owners | The icon, the display name and a one-line description. The slug is shown, and cannot be changed. |
154+| **General** | Owners | The icon, the display name, a one-line description and the address (the slug). |
92155 | **Members** | Members | Who belongs, and their roles. Owners add and remove people. |
93156 | **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. |
94157 | **Billing and plans** | Members | [Plans](/guides/usage-and-billing/#plans), the balance and the statement. Owners turn plans on and add credit. |
105168 ## Workspace access tokens
106169
107170 A workspace has access tokens of its own, for CI, integrations and agents
108−that work for a team. They replace the shared "service account" other
109−forges need: there is no extra account to create, pay for or lose the
110−password to.
171+that work for a team. There is no shared service account to create, pay
172+for or lose the password to.
111173
112174 | | Personal token | Workspace token |
113175 | --- | --- | --- |
125187
126188 Every member can see a workspace's tokens: the name, who created each and
127189 when it was last used. Only owners can create or delete them.
190+
191+## Profiles
192+
193+Every person has a profile at `g1t.sh/u/<username>`, apart from the
194+workspaces at `g1t.sh/<workspace>`. Author names on issues and pull
195+requests link to it.
196+
197+**What it shows.** Your picture, name, username, pronouns, bio, location,
198+website and when you joined; then your work in three tabs:
199+
200+- **Overview:** pull requests merged, open pull requests and issues
201+ opened, and your most recent activity.
202+- **Pull requests** and **Issues:** everything you opened, newest first,
203+ with filters beside the list for state (open, closed, merged), type,
204+ repository and sort order. Add `?tab=pulls&state=merged` and the like to
205+ link to a filtered list.
206+
207+**Edit it** under **Profile** in [Settings](https://g1t.sh/settings). Every
208+field is optional. The bio takes up to 160 characters and is also what a
209+link to your profile says. The website must be an `https://` address;
210+`example.com` is saved as `https://example.com`. Your email address is
211+never shown.
212+
213+**Who sees what.** A profile is public, but the work and workspaces on it
214+are filtered for whoever is looking:
215+
216+| On the profile | Shown to a visitor when |
217+| --- | --- |
218+| An issue or pull request, and its title | They can read its repository: it is public, or they are a member of its workspace |
219+| The counts | Only what they could see is counted |
220+| A workspace | They are a member of it too, or you made a public project in it, whose page shows that already |
221+
222+Someone signed out sees your public work and the workspaces where you made
223+a public project; nothing else. The link preview for a profile uses only
224+public work.
+11−1
8383
8484 See [sessions and why-blame](/guides/why-blame/).
8585
86+## Memory
87+
88+| Tool | Required | What it does | Route |
89+| --- | --- | --- | --- |
90+| `remember` | `repo`, `text` | Save one fact, convention, decision or gotcha for the next agent. `scope` is `project` (this codebase, the default) or `workspace` (true across its projects); `kind` is `fact`, `convention`, `decision` or `gotcha`. Text that looks like a secret is refused. Members and g1t's agents only. | [`POST /repos/{owner}/{name}/memory`](/reference/api/memory/remember/) |
91+| `recall` | `repo` | What the project and its workspace remember, pinned first. `query` matches every word; `limit` caps each level. | [`GET /repos/{owner}/{name}/memory`](/reference/api/memory/recall/) |
92+
93+See [agents, sessions and memory](/guides/agents-and-memory/).
94+
8695 ## Plans
8796
8897 | Tool | Required | What it does | Route |
169178 tools: `get_repo`, `list_issues`, `get_issue`, `list_labels`,
170179 `create_issue`, `add_comment`, `list_pull_requests`, `get_pull_request`,
171180 `get_pull_request_changes`, `read_session`, `get_merge_queue`,
172−`list_events`, `take_messages`, `message_agent`, `answer_message` and `get_context`.
181+`list_events`, `take_messages`, `message_agent`, `answer_message`, `remember`,
182+`recall` and `get_context`.
173183 `tools/list` shows such a token only the tools it may use.
+306−1
9999 ]
100100 },
101101 {
102+ "name": "Memory",
103+ "description": "What agents and people learned that the next agent should know, for one project or across a workspace. Members and g1t's agents only; never a secret.",
104+ "x-tools": [
105+ "remember",
106+ "recall"
107+ ]
108+ },
109+ {
102110 "name": "Actions",
103111 "description": "GitHub Actions workflows in .g1t/workflows, their runs, and their jobs' logs.",
104112 "x-tools": [
17211729 }
17221730 }
17231731 },
1732+ "/repos/{owner}/{name}/memory": {
1733+ "post": {
1734+ "operationId": "remember",
1735+ "tags": [
1736+ "Memory"
1737+ ],
1738+ "summary": "Remember something",
1739+ "description": "Save something to memory that the next agent working here should know: how to build or test, a convention, a decision and why, a trap. scope project is for this codebase; scope workspace is for what holds across all of the workspace's projects, such as \"we use pnpm everywhere\" or where staging lives. One short fact per memory. Every g1t agent run is given memory at its start, pinned first. Never save a secret, key, token or password: text that looks like one is refused. Members of the workspace and g1t's agents only.\n\nText that looks like a key, a token or a password is refused with `invalid`: memory is read by every agent in the workspace. Saving the same text again in the same scope returns the memory already kept.",
1740+ "x-mcp-tool": "remember",
1741+ "security": [
1742+ {
1743+ "token": []
1744+ }
1745+ ],
1746+ "parameters": [
1747+ {
1748+ "name": "owner",
1749+ "in": "path",
1750+ "required": true,
1751+ "schema": {
1752+ "type": "string"
1753+ },
1754+ "description": "The workspace that owns the repository."
1755+ },
1756+ {
1757+ "name": "name",
1758+ "in": "path",
1759+ "required": true,
1760+ "schema": {
1761+ "type": "string"
1762+ },
1763+ "description": "The repository's name."
1764+ }
1765+ ],
1766+ "responses": {
1767+ "200": {
1768+ "description": "Success.",
1769+ "content": {
1770+ "application/json": {
1771+ "schema": {},
1772+ "example": {
1773+ "id": "mem_01m43v8q2w3e4r5t6y7u8i9o0p",
1774+ "scope": "project",
1775+ "workspace": "syntaqx",
1776+ "repo": {
1777+ "namespace": "syntaqx",
1778+ "name": "hello"
1779+ },
1780+ "text": "The tests need TZ=UTC or the date tests fail.",
1781+ "kind": "gotcha",
1782+ "source": {
1783+ "kind": "run",
1784+ "runId": "arn_01m43v2c1p9k8d7e6f5a4b3c2d",
1785+ "repo": {
1786+ "namespace": "syntaqx",
1787+ "name": "hello"
1788+ },
1789+ "number": 14
1790+ },
1791+ "createdBy": "g1t-agent",
1792+ "pinned": false,
1793+ "createdAt": "2026-10-01T18:40:12.000Z",
1794+ "updatedAt": "2026-10-01T18:40:12.000Z",
1795+ "lastUsedAt": null
1796+ }
1797+ }
1798+ }
1799+ },
1800+ "401": {
1801+ "description": "A token is required, or the one sent is not valid.",
1802+ "content": {
1803+ "application/json": {
1804+ "schema": {
1805+ "$ref": "#/components/schemas/Error"
1806+ }
1807+ }
1808+ }
1809+ },
1810+ "403": {
1811+ "description": "Signed in, but not allowed to do this.",
1812+ "content": {
1813+ "application/json": {
1814+ "schema": {
1815+ "$ref": "#/components/schemas/Error"
1816+ }
1817+ }
1818+ }
1819+ },
1820+ "404": {
1821+ "description": "It does not exist, or you cannot see it.",
1822+ "content": {
1823+ "application/json": {
1824+ "schema": {
1825+ "$ref": "#/components/schemas/Error"
1826+ }
1827+ }
1828+ }
1829+ },
1830+ "409": {
1831+ "description": "The request conflicts with the current state.",
1832+ "content": {
1833+ "application/json": {
1834+ "schema": {
1835+ "$ref": "#/components/schemas/Error"
1836+ }
1837+ }
1838+ }
1839+ },
1840+ "422": {
1841+ "description": "The input is not valid.",
1842+ "content": {
1843+ "application/json": {
1844+ "schema": {
1845+ "$ref": "#/components/schemas/Error"
1846+ }
1847+ }
1848+ }
1849+ }
1850+ },
1851+ "requestBody": {
1852+ "required": true,
1853+ "content": {
1854+ "application/json": {
1855+ "schema": {
1856+ "type": "object",
1857+ "properties": {
1858+ "text": {
1859+ "type": "string",
1860+ "description": "What to remember, in one or two sentences. At most 1000 characters."
1861+ },
1862+ "scope": {
1863+ "type": "string",
1864+ "enum": [
1865+ "project",
1866+ "workspace"
1867+ ],
1868+ "description": "project: about this codebase. workspace: true across the workspace's projects. Defaults to project."
1869+ },
1870+ "kind": {
1871+ "type": "string",
1872+ "enum": [
1873+ "fact",
1874+ "convention",
1875+ "decision",
1876+ "gotcha"
1877+ ],
1878+ "description": "Defaults to fact."
1879+ },
1880+ "from_number": {
1881+ "type": "integer",
1882+ "description": "For an agent: the pull request you are working on, recorded as where it was learned."
1883+ }
1884+ },
1885+ "required": [
1886+ "text"
1887+ ]
1888+ },
1889+ "example": {
1890+ "text": "The tests need TZ=UTC or the date tests fail.",
1891+ "scope": "project",
1892+ "kind": "gotcha",
1893+ "from_number": 14
1894+ }
1895+ }
1896+ }
1897+ }
1898+ },
1899+ "get": {
1900+ "operationId": "recall",
1901+ "tags": [
1902+ "Memory"
1903+ ],
1904+ "summary": "Recall memory",
1905+ "description": "Search what the project and its workspace remember, by words in any order, or list it all without a query. Pinned memories come first, then the most recently used. Members of the workspace and g1t's agents only.\n\nMembers of the workspace only. Each memory returned is marked used, which keeps it near the front of what agents are given.",
1906+ "x-mcp-tool": "recall",
1907+ "security": [
1908+ {
1909+ "token": []
1910+ }
1911+ ],
1912+ "parameters": [
1913+ {
1914+ "name": "owner",
1915+ "in": "path",
1916+ "required": true,
1917+ "schema": {
1918+ "type": "string"
1919+ },
1920+ "description": "The workspace that owns the repository."
1921+ },
1922+ {
1923+ "name": "name",
1924+ "in": "path",
1925+ "required": true,
1926+ "schema": {
1927+ "type": "string"
1928+ },
1929+ "description": "The repository's name."
1930+ },
1931+ {
1932+ "name": "q",
1933+ "in": "query",
1934+ "required": false,
1935+ "schema": {
1936+ "type": "string"
1937+ },
1938+ "description": "Words to look for. Leave out for everything."
1939+ },
1940+ {
1941+ "name": "limit",
1942+ "in": "query",
1943+ "required": false,
1944+ "schema": {
1945+ "type": "integer"
1946+ },
1947+ "description": "At most 100 of each level; 20 if not given."
1948+ }
1949+ ],
1950+ "responses": {
1951+ "200": {
1952+ "description": "Success.",
1953+ "content": {
1954+ "application/json": {
1955+ "schema": {},
1956+ "example": {
1957+ "project": [],
1958+ "workspace": [
1959+ {
1960+ "id": "mem_01m43t0a1s2d3f4g5h6j7k8l9z",
1961+ "scope": "workspace",
1962+ "workspace": "syntaqx",
1963+ "repo": null,
1964+ "text": "We use pnpm everywhere, never npm or yarn.",
1965+ "kind": "convention",
1966+ "source": {
1967+ "kind": "person",
1968+ "runId": null,
1969+ "repo": null,
1970+ "number": null
1971+ },
1972+ "createdBy": "syntaqx",
1973+ "pinned": true,
1974+ "createdAt": "2026-10-01T18:40:12.000Z",
1975+ "updatedAt": "2026-10-01T18:40:12.000Z",
1976+ "lastUsedAt": null
1977+ }
1978+ ]
1979+ }
1980+ }
1981+ }
1982+ },
1983+ "401": {
1984+ "description": "A token is required, or the one sent is not valid.",
1985+ "content": {
1986+ "application/json": {
1987+ "schema": {
1988+ "$ref": "#/components/schemas/Error"
1989+ }
1990+ }
1991+ }
1992+ },
1993+ "403": {
1994+ "description": "Signed in, but not allowed to do this.",
1995+ "content": {
1996+ "application/json": {
1997+ "schema": {
1998+ "$ref": "#/components/schemas/Error"
1999+ }
2000+ }
2001+ }
2002+ },
2003+ "404": {
2004+ "description": "It does not exist, or you cannot see it.",
2005+ "content": {
2006+ "application/json": {
2007+ "schema": {
2008+ "$ref": "#/components/schemas/Error"
2009+ }
2010+ }
2011+ }
2012+ },
2013+ "422": {
2014+ "description": "The input is not valid.",
2015+ "content": {
2016+ "application/json": {
2017+ "schema": {
2018+ "$ref": "#/components/schemas/Error"
2019+ }
2020+ }
2021+ }
2022+ }
2023+ },
2024+ "x-example-query": {
2025+ "q": "pnpm"
2026+ }
2027+ }
2028+ },
17242029 "/repos/{owner}/{name}/events": {
17252030 "get": {
17262031 "operationId": "list_events",
1071611021 "Pull requests"
1071711022 ],
1071811023 "summary": "Get a pull request",
10719− "description": "A pull request's status, head commit, comments and reviews, the issue it is for, the latest run of that issue's acceptance checks with each command's output, whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files.\n\n| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `checks` | The latest run of the acceptance checks against its head, with each command's output. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n| `statuses` | Commit statuses reported for its head, such as workflow runs. |\n\nChecks are started by g1t, not through the API. They run when a pull request becomes ready for review and again when its head moves.",
11024+ "description": "A pull request's status, head commit, comments and reviews, the issue it is for, the latest run of that issue's acceptance checks with each command's output, whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files.\n\n| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `checks` | The latest run of the acceptance checks against its head, with each command's output. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n| `statuses` | Commit statuses reported for its head, such as workflow runs. |\n| `mergeable` | Whether it merges cleanly into its target: `clean`, `conflicting`, `checking` (being worked out) or `unknown`. Worked out ahead of time whenever it or its target moves. |\n| `conflicts` | When `mergeable` is `conflicting`, the files that conflict. Resolve them by merging the target in, or have the g1t agent do it. |\n| `earlierChecks` | Earlier runs of the acceptance checks, newest first, without their output. |\n\nChecks are started by g1t, not through the API. They run when a pull request becomes ready for review and again when its head moves.",
1072011025 "x-mcp-tool": "get_pull_request",
1072111026 "security": [
1072211027 {
+462−0
1+/**
2+ * Agents at work, wherever they show: the project's Agents page, the
3+ * workspace's fleet, a pull request's Agent panel and the pull request
4+ * list. Stopping and messaging a run go through the project's
5+ * `agents.json` resource route, so every place behaves the same.
6+ */
7+import { Bot, CircleSlash, Clock, Coins, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react";
8+import { type ReactNode, useEffect, useMemo, useState } from "react";
9+import { Link, useFetcher, useRevalidator } from "react-router";
10+
11+import {
12+ type AgentRun,
13+ type AgentRunStatus,
14+ type RunKind,
15+ RUN_KIND_LABEL,
16+ type Stage,
17+ isActiveRun,
18+ isAgentKind,
19+ takesMessages,
20+} from "@g1t/contracts";
21+
22+import { STAGE_LABEL, StageDots } from "./lifecycle";
23+import { Avatar, TimeAgo } from "./ui";
24+import {
25+ AlertDialog,
26+ AlertDialogAction,
27+ AlertDialogCancel,
28+ AlertDialogContent,
29+ AlertDialogDescription,
30+ AlertDialogFooter,
31+ AlertDialogHeader,
32+ AlertDialogTitle,
33+ AlertDialogTrigger,
34+} from "./ui/alert-dialog";
35+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
36+
37+/** How often a page with something running asks again. */
38+export const LIVE_MS = 4000;
39+
40+/** Revalidates the page every few seconds while `live`, as the merge queue does. */
41+export function useLiveRefresh(live: boolean) {
42+ const revalidator = useRevalidator();
43+ useEffect(() => {
44+ if (!live) return;
45+ const timer = setInterval(() => {
46+ if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate();
47+ }, LIVE_MS);
48+ return () => clearInterval(timer);
49+ }, [live, revalidator]);
50+}
51+
52+export function formatCost(usd: number | null | undefined): string | null {
53+ if (usd == null) return null;
54+ if (usd > 0 && usd < 0.01) return "<$0.01";
55+ return `$${usd.toFixed(2)}`;
56+}
57+
58+function span(ms: number): string {
59+ const seconds = Math.max(0, Math.floor(ms / 1000));
60+ const hours = Math.floor(seconds / 3600);
61+ const minutes = Math.floor((seconds % 3600) / 60);
62+ if (hours > 0) return `${hours}h ${minutes}m`;
63+ if (minutes > 0) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
64+ return `${seconds}s`;
65+}
66+
67+/** How long a run has gone on, ticking while it runs. */
68+export function Elapsed({ from, to }: { from: string; to?: string | null }) {
69+ const [now, setNow] = useState(() => Date.now());
70+ useEffect(() => {
71+ if (to) return;
72+ const timer = setInterval(() => setNow(Date.now()), 1000);
73+ return () => clearInterval(timer);
74+ }, [to]);
75+ const end = to ? new Date(to).getTime() : now;
76+ return (
77+ <span suppressHydrationWarning className="tabular-nums">
78+ {span(end - new Date(from).getTime())}
79+ </span>
80+ );
81+}
82+
83+const STATUS: Record<AgentRunStatus, { label: string; tone: string; icon: ReactNode }> = {
84+ queued: { label: "Starting", tone: "text-muted border-line", icon: <Clock size={12} /> },
85+ running: {
86+ label: "Running",
87+ tone: "text-merged border-merged/40 bg-merged/10",
88+ icon: <Loader2 size={12} className="animate-spin" />,
89+ },
90+ succeeded: { label: "Done", tone: "text-accent border-accent/40 bg-accent/10", icon: <Bot size={12} /> },
91+ failed: { label: "Failed", tone: "text-danger border-danger/40 bg-danger/10", icon: <TriangleAlert size={12} /> },
92+ stopped: { label: "Stopped", tone: "text-warn border-warn/40 bg-warn/10", icon: <OctagonX size={12} /> },
93+};
94+
95+export function RunStatusBadge({ status }: { status: AgentRunStatus }) {
96+ const { label, tone, icon } = STATUS[status];
97+ return (
98+ <span className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${tone}`}>
99+ {icon}
100+ {label}
101+ </span>
102+ );
103+}
104+
105+export function KindLabel({ kind }: { kind: RunKind }) {
106+ return (
107+ <span className="rounded border border-line px-1.5 py-px font-mono text-[0.6875rem] text-muted">
108+ {RUN_KIND_LABEL[kind].toLowerCase()}
109+ </span>
110+ );
111+}
112+
113+/** Where a project's agent actions are posted. */
114+function actionUrl(run: AgentRun): string {
115+ return `/${run.repo.namespace}/${run.repo.name}/agents.json`;
116+}
117+
118+type ActionResult = { ok: boolean; notice?: string; error?: string } | undefined;
119+
120+/** Stop a run, after saying what that does. */
121+export function StopRun({ run, compact }: { run: AgentRun; compact?: boolean }) {
122+ const fetcher = useFetcher<ActionResult>();
123+ const busy = fetcher.state !== "idle";
124+ const onPull = run.number != null && isAgentKind(run.kind);
125+ return (
126+ <>
127+ <AlertDialog>
128+ <AlertDialogTrigger
129+ disabled={busy}
130+ className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-danger/50 hover:text-danger disabled:opacity-50"
131+ >
132+ <Square size={11} />
133+ {busy ? "Stopping…" : compact ? "Stop" : "Stop run"}
134+ </AlertDialogTrigger>
135+ <AlertDialogContent>
136+ <AlertDialogHeader>
137+ <AlertDialogTitle>Stop this {RUN_KIND_LABEL[run.kind].toLowerCase()} run?</AlertDialogTitle>
138+ <AlertDialogDescription>
139+ Its sandbox is shut down at once and nothing more is pushed.
140+ {onPull &&
141+ ` g1t stops seeing #${run.number} through and leaves it for a person; what the agent already pushed stays on it.`}
142+ </AlertDialogDescription>
143+ </AlertDialogHeader>
144+ <AlertDialogFooter>
145+ <AlertDialogCancel>Keep it running</AlertDialogCancel>
146+ <AlertDialogAction
147+ onClick={() => fetcher.submit({ intent: "stop", run: run.id }, { method: "post", action: actionUrl(run) })}
148+ >
149+ Stop
150+ </AlertDialogAction>
151+ </AlertDialogFooter>
152+ </AlertDialogContent>
153+ </AlertDialog>
154+ {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
155+ </>
156+ );
157+}
158+
159+/** Send the agent on a run's pull request a message. */
160+export function MessageRun({ run }: { run: AgentRun }) {
161+ const fetcher = useFetcher<ActionResult>();
162+ const [open, setOpen] = useState(false);
163+ const live = takesMessages(run.kind) && isActiveRun(run.status);
164+ if (run.number == null) return null;
165+ return (
166+ <Dialog open={open} onOpenChange={setOpen}>
167+ <DialogTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg">
168+ <MessageSquare size={11} />
169+ Message
170+ </DialogTrigger>
171+ <DialogContent>
172+ <DialogHeader>
173+ <DialogTitle>Message the agent on #{run.number}</DialogTitle>
174+ <DialogDescription>
175+ {live
176+ ? "It reads this at its next step, between tool calls, without starting over."
177+ : "This run does not read messages while it works. Your message waits on the pull request and is given to the agent's next run there."}
178+ </DialogDescription>
179+ </DialogHeader>
180+ <fetcher.Form method="post" action={actionUrl(run)} className="space-y-3">
181+ <input type="hidden" name="intent" value="message" />
182+ <input type="hidden" name="number" value={run.number} />
183+ <textarea
184+ name="body"
185+ required
186+ rows={4}
187+ autoComplete="off"
188+ data-1p-ignore
189+ placeholder="Keep the old flag working too…"
190+ className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
191+ />
192+ {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
193+ {fetcher.data?.ok && fetcher.data.notice && <p className="text-sm text-accent">{fetcher.data.notice}</p>}
194+ <div className="flex justify-end">
195+ <button
196+ type="submit"
197+ disabled={fetcher.state !== "idle"}
198+ className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
199+ >
200+ {fetcher.state !== "idle" ? "Sending…" : "Send"}
201+ </button>
202+ </div>
203+ </fetcher.Form>
204+ </DialogContent>
205+ </Dialog>
206+ );
207+}
208+
209+/** What a run is on, as a link: its pull request, or the plan it writes. */
210+function RunTarget({ run, showRepo }: { run: AgentRun; showRepo?: boolean }) {
211+ const base = `/${run.repo.namespace}/${run.repo.name}`;
212+ const repo = showRepo ? (
213+ <Link to={base} className="font-mono text-xs text-faint hover:text-muted">
214+ {run.repo.namespace}/{run.repo.name}
215+ </Link>
216+ ) : null;
217+ if (run.number != null) {
218+ return (
219+ <span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
220+ {repo}
221+ <Link to={`${base}/pull/${run.number}`} prefetch="intent" className="min-w-0 truncate font-medium hover:text-accent">
222+ {run.title ?? "Pull request"} <span className="font-normal text-faint">#{run.number}</span>
223+ </Link>
224+ </span>
225+ );
226+ }
227+ return (
228+ <span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
229+ {repo}
230+ <Link to={`${base}/plans`} className="min-w-0 truncate font-medium hover:text-accent">
231+ {run.title ?? "A plan"}
232+ </Link>
233+ </span>
234+ );
235+}
236+
237+/** One run, live or finished, with what can be done about it. */
238+export function RunCard({ run, member, showRepo }: { run: AgentRun; member: boolean; showRepo?: boolean }) {
239+ const base = `/${run.repo.namespace}/${run.repo.name}`;
240+ const active = isActiveRun(run.status);
241+ const cost = formatCost(run.costUsd);
242+ return (
243+ <li
244+ className={`rounded-xl border bg-surface p-4 transition-colors hover:border-line-strong ${
245+ active ? "border-merged/30" : "border-line"
246+ }`}
247+ >
248+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
249+ <Avatar name={run.agent} size={22} />
250+ <span className="text-sm font-medium">{run.agent}</span>
251+ <KindLabel kind={run.kind} />
252+ <span className="min-w-0 grow">
253+ <RunTarget run={run} showRepo={showRepo} />
254+ </span>
255+ <RunStatusBadge status={run.status} />
256+ </div>
257+ {run.step && (
258+ <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`} title={run.step}>
259+ {active && <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
260+ {run.step}
261+ </p>
262+ )}
263+ {run.status === "failed" && run.error && <p className="mt-1.5 text-xs text-danger">{run.error}</p>}
264+ <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
265+ <span className="flex items-center gap-1">
266+ <Clock size={12} />
267+ {run.startedAt || active ? <Elapsed from={run.startedAt ?? run.createdAt} to={run.finishedAt} /> : "—"}
268+ </span>
269+ {cost && (
270+ <span className="flex items-center gap-1" title={active ? "So far, as the harness reports it" : "As the harness reported it"}>
271+ <Coins size={12} />
272+ {cost}
273+ {active && " so far"}
274+ </span>
275+ )}
276+ {run.model && <span className="font-mono">{run.model}</span>}
277+ {run.startedBy && <span>started by {run.startedBy}</span>}
278+ <span>
279+ <TimeAgo at={run.createdAt} />
280+ </span>
281+ <span className="grow" />
282+ <Link to={`${base}/agents/runs/${run.id}`} className="hover:text-fg">
283+ {run.stepCount} {run.stepCount === 1 ? "step" : "steps"}
284+ </Link>
285+ {run.number != null && (
286+ <Link to={`${base}/sessions/${run.number}`} className="hover:text-fg">
287+ Session
288+ </Link>
289+ )}
290+ {member && active && <MessageRun run={run} />}
291+ {member && active && <StopRun run={run} compact />}
292+ </div>
293+ </li>
294+ );
295+}
296+
297+/** The runs that are on now, newest first, then the rest. */
298+export function splitRuns(runs: AgentRun[]): { live: AgentRun[]; done: AgentRun[] } {
299+ return {
300+ live: runs.filter((run) => isActiveRun(run.status)),
301+ done: runs.filter((run) => !isActiveRun(run.status)),
302+ };
303+}
304+
305+type Live = { runs: AgentRun[]; member: boolean };
306+
307+/**
308+ * The runs of a project, fetched from its `agents.json`, every few seconds
309+ * while any is running. For pages whose own loader does not have them.
310+ */
311+export function useRuns(owner: string, repo: string, query: Record<string, string>): Live | null {
312+ const fetcher = useFetcher<Live>();
313+ const search = new URLSearchParams(query).toString();
314+ const url = `/${owner}/${repo}/agents.json?${search}`;
315+ const { load } = fetcher;
316+ useEffect(() => {
317+ load(url);
318+ }, [load, url]);
319+ const live = fetcher.data?.runs.some((run) => isActiveRun(run.status)) ?? false;
320+ useEffect(() => {
321+ if (!live) return;
322+ const timer = setInterval(() => {
323+ if (document.visibilityState === "visible") load(url);
324+ }, LIVE_MS);
325+ return () => clearInterval(timer);
326+ }, [live, load, url]);
327+ return fetcher.data ?? null;
328+}
329+
330+/**
331+ * The agent on a pull request, near the top of its page: who is working
332+ * on it, at what stage, what it is doing this minute, for how long and at
333+ * what cost, with its session, and Stop and Message for members.
334+ */
335+export function AgentPanel({
336+ owner,
337+ repo,
338+ number,
339+ stage,
340+}: {
341+ owner: string;
342+ repo: string;
343+ number: number;
344+ stage?: Stage | null;
345+}) {
346+ const data = useRuns(owner, repo, { number: String(number), limit: "5" });
347+ const runs = data?.runs ?? [];
348+ const member = data?.member ?? false;
349+ const current = runs.find((run) => isActiveRun(run.status)) ?? runs[0];
350+ if (!current) return null;
351+ const active = isActiveRun(current.status);
352+ const base = `/${owner}/${repo}`;
353+ const cost = formatCost(current.costUsd);
354+ const spent = formatCost(runs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0) || null);
355+ return (
356+ <section
357+ aria-label="Agent"
358+ className={`mt-4 rounded-2xl bg-surface p-4 ring-1 ${active ? "ring-merged/40" : "ring-line"}`}
359+ >
360+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
361+ <Avatar name={current.agent} size={24} />
362+ <p className="text-sm">
363+ <span className="font-medium">{current.agent}</span>{" "}
364+ <span className="text-muted">
365+ {active
366+ ? `is ${RUN_KIND_LABEL[current.kind].toLowerCase()}`
367+ : `${current.status === "succeeded" ? "finished" : current.status} its ${RUN_KIND_LABEL[current.kind].toLowerCase()} run`}
368+ </span>
369+ </p>
370+ {stage && (
371+ <span className="flex items-center gap-2 text-xs text-muted">
372+ <StageDots stage={stage} />
373+ {STAGE_LABEL[stage]}
374+ </span>
375+ )}
376+ <span className="grow" />
377+ <RunStatusBadge status={current.status} />
378+ </div>
379+ {current.step && (
380+ <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" title={current.step}>
381+ {active && <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
382+ {current.step}
383+ </p>
384+ )}
385+ <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
386+ <span className="flex items-center gap-1">
387+ <Clock size={12} />
388+ <Elapsed from={current.startedAt ?? current.createdAt} to={current.finishedAt} />
389+ </span>
390+ {cost && (
391+ <span className="flex items-center gap-1">
392+ <Coins size={12} />
393+ {cost}
394+ {active ? " so far" : ""}
395+ {runs.length > 1 && spent && ` · ${spent} over ${runs.length} runs`}
396+ </span>
397+ )}
398+ {current.model && <span className="font-mono">{current.model}</span>}
399+ <span className="grow" />
400+ <Link to={`${base}/agents/runs/${current.id}`} className="hover:text-fg">
401+ Run
402+ </Link>
403+ <Link to={`${base}/sessions/${number}`} className="hover:text-fg">
404+ Session
405+ </Link>
406+ {member && active && <MessageRun run={current} />}
407+ {member && active && <StopRun run={current} />}
408+ </div>
409+ </section>
410+ );
411+}
412+
413+/** A small "agent working" mark for a row of the pull request list. */
414+export function AgentBadge({ run }: { run: AgentRun | undefined }) {
415+ if (!run) return null;
416+ return (
417+ <span
418+ title={run.step ?? undefined}
419+ className="inline-flex shrink-0 items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-xs text-merged"
420+ >
421+ <Loader2 size={11} className="animate-spin" />
422+ {RUN_KIND_LABEL[run.kind]}
423+ </span>
424+ );
425+}
426+
427+/** The active run on each pull request of a project, by number. */
428+export function useActiveRuns(owner: string, repo: string): Map<number, AgentRun> {
429+ const data = useRuns(owner, repo, { active: "1", limit: "100" });
430+ return useMemo(() => {
431+ const byNumber = new Map<number, AgentRun>();
432+ for (const run of data?.runs ?? []) {
433+ if (run.number != null && !byNumber.has(run.number)) byNumber.set(run.number, run);
434+ }
435+ return byNumber;
436+ }, [data]);
437+}
438+
439+/** Nothing running, said plainly. */
440+export function Idle({ children }: { children: ReactNode }) {
441+ return (
442+ <p className="flex items-center gap-2 rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">
443+ <CircleSlash size={15} className="text-faint" />
444+ {children}
445+ </p>
446+ );
447+}
448+
449+/**
450+ * What the agent on a pull request is doing this minute, as one line, for
451+ * an issue's sidebar: which agent picked the issue up, and where it is.
452+ */
453+export function AgentStepLine({ owner, repo, number }: { owner: string; repo: string; number: number }) {
454+ const data = useRuns(owner, repo, { number: String(number), active: "1", limit: "1" });
455+ const run = data?.runs[0];
456+ if (!run?.step) return null;
457+ return (
458+ <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={run.step}>
459+ {RUN_KIND_LABEL[run.kind]}: {run.step}
460+ </span>
461+ );
462+}
+315−0
1+/**
2+ * Memory, as a project's and a workspace's pages show it: what agents and
3+ * people learned, each with where it came from, to pin, edit or forget.
4+ * Both pages post to their own action with the intents in `memoryAction`.
5+ */
6+import { Bot, Brain, Pencil, Pin, PinOff, Trash2, User } from "lucide-react";
7+import { useEffect, useRef, useState } from "react";
8+import { Link, useFetcher } from "react-router";
9+
10+import { MEMORY_KINDS, type Memory, type MemoryKind, type MemoryScope, type Result, type User as Actor } from "@g1t/contracts";
11+
12+import { TimeAgo } from "./ui";
13+import {
14+ AlertDialog,
15+ AlertDialogAction,
16+ AlertDialogCancel,
17+ AlertDialogContent,
18+ AlertDialogDescription,
19+ AlertDialogFooter,
20+ AlertDialogHeader,
21+ AlertDialogTitle,
22+ AlertDialogTrigger,
23+} from "./ui/alert-dialog";
24+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
25+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
26+import { Switch } from "./ui/switch";
27+
28+const KIND: Record<MemoryKind, { label: string; about: string; tone: string }> = {
29+ fact: { label: "Fact", about: "How something is", tone: "text-info border-info/40" },
30+ convention: { label: "Convention", about: "How things are done here", tone: "text-accent border-accent/40" },
31+ decision: { label: "Decision", about: "Something chosen, and why", tone: "text-merged border-merged/40" },
32+ gotcha: { label: "Gotcha", about: "A trap to avoid", tone: "text-warn border-warn/40" },
33+};
34+
35+const TEXTAREA =
36+ "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
37+
38+type Done = { ok: boolean; error?: string } | undefined;
39+
40+function KindBadge({ kind }: { kind: MemoryKind }) {
41+ return (
42+ <span className={`inline-flex shrink-0 items-center rounded-full border px-2 py-px text-[0.6875rem] ${KIND[kind].tone}`}>
43+ {KIND[kind].label}
44+ </span>
45+ );
46+}
47+
48+function KindSelect({ value }: { value?: MemoryKind }) {
49+ return (
50+ <Select name="kind" defaultValue={value ?? "fact"}>
51+ <SelectTrigger size="sm" className="w-40">
52+ <SelectValue />
53+ </SelectTrigger>
54+ <SelectContent>
55+ {MEMORY_KINDS.map((kind) => (
56+ <SelectItem key={kind} value={kind} description={KIND[kind].about}>
57+ {KIND[kind].label}
58+ </SelectItem>
59+ ))}
60+ </SelectContent>
61+ </Select>
62+ );
63+}
64+
65+/** Where a memory came from, linked where it can be. */
66+function Source({ memory }: { memory: Memory }) {
67+ const { source } = memory;
68+ const repo = source.repo;
69+ const where =
70+ repo && source.number != null ? (
71+ <>
72+ {" "}
73+ on{" "}
74+ <Link to={`/${repo.namespace}/${repo.name}/pull/${source.number}`} className="hover:text-fg">
75+ {memory.scope === "workspace" && `${repo.name}`}#{source.number}
76+ </Link>
77+ </>
78+ ) : repo && memory.scope === "workspace" ? (
79+ <>
80+ {" "}
81+ in <Link to={`/${repo.namespace}/${repo.name}`} className="hover:text-fg">{repo.name}</Link>
82+ </>
83+ ) : null;
84+ if (source.kind === "person") {
85+ return (
86+ <span className="inline-flex items-center gap-1">
87+ <User size={11} />
88+ {memory.createdBy}
89+ {where}
90+ </span>
91+ );
92+ }
93+ return (
94+ <span className="inline-flex items-center gap-1">
95+ <Bot size={11} />
96+ {source.runId && repo ? (
97+ <Link to={`/${repo.namespace}/${repo.name}/agents/runs/${source.runId}`} className="hover:text-fg">
98+ {memory.createdBy}'s run
99+ </Link>
100+ ) : (
101+ memory.createdBy
102+ )}
103+ {where}
104+ </span>
105+ );
106+}
107+
108+function EditMemory({ memory, action }: { memory: Memory; action: string }) {
109+ const fetcher = useFetcher<Done>();
110+ const [open, setOpen] = useState(false);
111+ useEffect(() => {
112+ if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false);
113+ }, [fetcher.state, fetcher.data]);
114+ return (
115+ <Dialog open={open} onOpenChange={setOpen}>
116+ <DialogTrigger aria-label="Edit" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg">
117+ <Pencil size={13} />
118+ </DialogTrigger>
119+ <DialogContent>
120+ <DialogHeader>
121+ <DialogTitle>Edit memory</DialogTitle>
122+ <DialogDescription>Agents get the new wording from their next run on.</DialogDescription>
123+ </DialogHeader>
124+ <fetcher.Form method="post" action={action} className="space-y-3">
125+ <input type="hidden" name="intent" value="update" />
126+ <input type="hidden" name="id" value={memory.id} />
127+ <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} />
128+ <div className="flex items-center justify-between gap-3">
129+ <KindSelect value={memory.kind} />
130+ <button
131+ type="submit"
132+ disabled={fetcher.state !== "idle"}
133+ className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
134+ >
135+ Save
136+ </button>
137+ </div>
138+ {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
139+ </fetcher.Form>
140+ </DialogContent>
141+ </Dialog>
142+ );
143+}
144+
145+function MemoryItem({ memory, action, editable }: { memory: Memory; action: string; editable: boolean }) {
146+ const fetcher = useFetcher<Done>();
147+ const pinned = fetcher.formData ? fetcher.formData.get("pinned") === "true" : memory.pinned;
148+ const gone = fetcher.formData?.get("intent") === "delete";
149+ if (gone) return null;
150+ return (
151+ <li className="group flex gap-3 px-4 py-3">
152+ <div className="min-w-0 grow">
153+ <div className="flex flex-wrap items-center gap-2">
154+ <KindBadge kind={memory.kind} />
155+ {pinned && (
156+ <span className="inline-flex items-center gap-1 text-[0.6875rem] text-accent">
157+ <Pin size={11} />
158+ pinned
159+ </span>
160+ )}
161+ </div>
162+ <p className="mt-1.5 text-sm whitespace-pre-wrap">{memory.text}</p>
163+ <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
164+ <Source memory={memory} />
165+ <span>
166+ added <TimeAgo at={memory.createdAt} />
167+ </span>
168+ <span>{memory.lastUsedAt ? <>given to an agent <TimeAgo at={memory.lastUsedAt} /></> : "not given to an agent yet"}</span>
169+ </p>
170+ {fetcher.data?.error && <p className="mt-1.5 text-xs text-danger">{fetcher.data.error}</p>}
171+ </div>
172+ {editable && (
173+ <div className="flex shrink-0 items-start gap-0.5">
174+ <button
175+ type="button"
176+ aria-label={pinned ? "Unpin" : "Pin"}
177+ title={pinned ? "Unpin" : "Pin: given to every agent first"}
178+ onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })}
179+ className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
180+ >
181+ {pinned ? <PinOff size={13} /> : <Pin size={13} />}
182+ </button>
183+ <EditMemory memory={memory} action={action} />
184+ <AlertDialog>
185+ <AlertDialogTrigger aria-label="Forget" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-danger">
186+ <Trash2 size={13} />
187+ </AlertDialogTrigger>
188+ <AlertDialogContent>
189+ <AlertDialogHeader>
190+ <AlertDialogTitle>Forget this?</AlertDialogTitle>
191+ <AlertDialogDescription>
192+ No agent is given it again. This cannot be undone.
193+ </AlertDialogDescription>
194+ </AlertDialogHeader>
195+ <AlertDialogFooter>
196+ <AlertDialogCancel>Keep it</AlertDialogCancel>
197+ <AlertDialogAction onClick={() => fetcher.submit({ intent: "delete", id: memory.id }, { method: "post", action })}>
198+ Forget
199+ </AlertDialogAction>
200+ </AlertDialogFooter>
201+ </AlertDialogContent>
202+ </AlertDialog>
203+ </div>
204+ )}
205+ </li>
206+ );
207+}
208+
209+export function MemoryList({
210+ memories,
211+ action,
212+ editable,
213+ empty,
214+}: {
215+ memories: Memory[];
216+ action: string;
217+ editable: boolean;
218+ empty: string;
219+}) {
220+ if (memories.length === 0) {
221+ return <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">{empty}</p>;
222+ }
223+ return (
224+ <ul className="divide-y divide-line rounded-xl border border-line bg-surface">
225+ {memories.map((memory) => (
226+ <MemoryItem key={memory.id} memory={memory} action={action} editable={editable} />
227+ ))}
228+ </ul>
229+ );
230+}
231+
232+/** A form to add a memory at one level. */
233+export function AddMemory({ scope, action, placeholder }: { scope: MemoryScope; action: string; placeholder: string }) {
234+ const fetcher = useFetcher<Done>();
235+ const form = useRef<HTMLFormElement>(null);
236+ useEffect(() => {
237+ if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset();
238+ }, [fetcher.state, fetcher.data]);
239+ return (
240+ <fetcher.Form ref={form} method="post" action={action} className="rounded-xl border border-line bg-surface p-4">
241+ <input type="hidden" name="intent" value="add" />
242+ <input type="hidden" name="scope" value={scope} />
243+ <label className="flex items-center gap-2 text-sm font-medium">
244+ <Brain size={15} className="text-merged" />
245+ Add to {scope === "workspace" ? "the workspace's" : "this project's"} memory
246+ </label>
247+ <textarea name="text" required rows={2} maxLength={1000} placeholder={placeholder} className={`${TEXTAREA} mt-3`} />
248+ <div className="mt-3 flex flex-wrap items-center gap-3">
249+ <KindSelect />
250+ <label className="flex items-center gap-2 text-xs text-muted">
251+ <Switch name="pinned" value="true" size="sm" />
252+ Pin it
253+ </label>
254+ <span className="grow" />
255+ <button
256+ type="submit"
257+ disabled={fetcher.state !== "idle"}
258+ className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
259+ >
260+ {fetcher.state !== "idle" ? "Adding…" : "Add"}
261+ </button>
262+ </div>
263+ {fetcher.data?.error && <p className="mt-2 text-sm text-danger">{fetcher.data.error}</p>}
264+ <p className="mt-2 text-xs text-faint">
265+ One fact, convention, decision or gotcha each. Never a secret: say where it lives instead, and
266+ g1t refuses anything that looks like a key or a token.
267+ </p>
268+ </fetcher.Form>
269+ );
270+}
271+
272+type MemoryApi = {
273+ addMemory(actor: Actor, workspace: string, memory: { scope: MemoryScope; repo?: { namespace: string; name: string } | null; text: string; kind?: MemoryKind; pinned?: boolean }): Promise<Result<Memory>>;
274+ updateMemory(actor: Actor, workspace: string, id: string, change: { text?: string; kind?: MemoryKind; pinned?: boolean }): Promise<Result<Memory>>;
275+ deleteMemory(actor: Actor, workspace: string, id: string): Promise<Result<boolean>>;
276+};
277+
278+/** The action both memory pages share: add, update and delete. */
279+export async function memoryAction(
280+ api: MemoryApi,
281+ actor: Actor,
282+ workspace: string,
283+ repo: { namespace: string; name: string } | null,
284+ form: FormData,
285+): Promise<Done> {
286+ const intent = String(form.get("intent") ?? "");
287+ const kindValue = String(form.get("kind") ?? "");
288+ const kind = MEMORY_KINDS.includes(kindValue as MemoryKind) ? (kindValue as MemoryKind) : undefined;
289+ if (intent === "add") {
290+ const scope: MemoryScope = form.get("scope") === "workspace" ? "workspace" : "project";
291+ const added = await api.addMemory(actor, workspace, {
292+ scope,
293+ repo,
294+ text: String(form.get("text") ?? ""),
295+ kind,
296+ pinned: form.get("pinned") === "true",
297+ });
298+ return added.ok ? { ok: true } : { ok: false, error: added.error.message };
299+ }
300+ if (intent === "update") {
301+ const pinned = form.get("pinned");
302+ const text = form.get("text");
303+ const changed = await api.updateMemory(actor, workspace, String(form.get("id") ?? ""), {
304+ text: text == null ? undefined : String(text),
305+ kind,
306+ pinned: pinned == null ? undefined : pinned === "true",
307+ });
308+ return changed.ok ? { ok: true } : { ok: false, error: changed.error.message };
309+ }
310+ if (intent === "delete") {
311+ const gone = await api.deleteMemory(actor, workspace, String(form.get("id") ?? ""));
312+ return gone.ok ? { ok: true } : { ok: false, error: gone.error.message };
313+ }
314+ return { ok: false, error: "Unknown action." };
315+}
+699−0
1+/**
2+ * The parts of a pull request's merge box that say what stands between it
3+ * and a merge: every check, with what a failed one printed, and whether
4+ * the change merges cleanly into its target, with what to do when not.
5+ */
6+import {
7+ Check,
8+ ChevronRight,
9+ CircleAlert,
10+ CircleCheck,
11+ CircleX,
12+ Copy,
13+ GitCommitHorizontal,
14+ History,
15+ LoaderCircle,
16+ RotateCw,
17+ Sparkles,
18+ Terminal,
19+ TriangleAlert,
20+} from "lucide-react";
21+import { useMemo, useState } from "react";
22+import { Form, Link } from "react-router";
23+
24+import type { CheckResult, CheckRun, CommitStatus, Job, Mergeable, Pull } from "@g1t/contracts";
25+
26+import { duration } from "./actions";
27+import { Button, CopyLine, ErrorText, TimeAgo } from "./ui";
28+import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
29+
30+// --- Output ---------------------------------------------------------------
31+
32+/** What the sandbox puts first when it kept only the end of the output. */
33+const CUT_SHORT = "… (earlier output not shown)";
34+
35+/** One run of text in one style, from a line with ANSI colour codes. */
36+type Segment = { text: string; className: string };
37+
38+const ANSI_COLOURS: Record<number, string> = {
39+ 30: "text-faint",
40+ 31: "text-danger",
41+ 32: "text-accent",
42+ 33: "text-warn",
43+ 34: "text-info",
44+ 35: "text-merged",
45+ 36: "text-info",
46+ 37: "text-fg",
47+ 90: "text-faint",
48+ 91: "text-danger",
49+ 92: "text-accent",
50+ 93: "text-warn",
51+ 94: "text-info",
52+ 95: "text-merged",
53+ 96: "text-info",
54+ 97: "text-fg",
55+};
56+
57+/**
58+ * A line's text split where its colour changes. Colours and bold are kept;
59+ * every other escape sequence (cursor movement, erasing, links) is dropped.
60+ */
61+export function ansiSegments(line: string): Segment[] {
62+ const segments: Segment[] = [];
63+ let colour = "";
64+ let bold = false;
65+ // SGR sequences are kept apart; anything else that starts with ESC goes.
66+ const pattern = /\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g;
67+ let at = 0;
68+ const push = (text: string) => {
69+ if (text) segments.push({ text, className: [colour, bold ? "font-semibold" : ""].filter(Boolean).join(" ") });
70+ };
71+ for (const match of line.matchAll(pattern)) {
72+ push(line.slice(at, match.index));
73+ at = match.index + match[0].length;
74+ if (match[1] === undefined) continue;
75+ const codes = match[1] === "" ? [0] : match[1].split(";").map(Number);
76+ for (const code of codes) {
77+ if (code === 0) {
78+ colour = "";
79+ bold = false;
80+ } else if (code === 1) bold = true;
81+ else if (code === 22) bold = false;
82+ else if (code === 39) colour = "";
83+ else if (ANSI_COLOURS[code]) colour = ANSI_COLOURS[code];
84+ }
85+ }
86+ push(line.slice(at));
87+ return segments;
88+}
89+
90+/** Text without any ANSI escape sequences, for copying. */
91+export function stripAnsi(text: string): string {
92+ return ansiSegments(text.replace(/\r\n/g, "\n"))
93+ .map((segment) => segment.text)
94+ .join("");
95+}
96+
97+/** Lines as a terminal would show them: a carriage return starts the line over. */
98+function terminalLines(text: string): string[] {
99+ return text
100+ .replace(/\r\n/g, "\n")
101+ .split("\n")
102+ .map((line) => {
103+ const parts = line.split("\r");
104+ return parts[parts.length - 1] || parts.filter(Boolean).pop() || "";
105+ });
106+}
107+
108+/**
109+ * A command's output as a log: numbered lines, colours rendered, a copy
110+ * button, and a note when only the end of it was kept.
111+ */
112+export function LogViewer({ text }: { text: string }) {
113+ const cut = text.startsWith(CUT_SHORT);
114+ const body = cut ? text.slice(CUT_SHORT.length).replace(/^\n/, "") : text;
115+ const lines = useMemo(() => {
116+ const all = terminalLines(body);
117+ while (all.length > 0 && all[all.length - 1].trim() === "") all.pop();
118+ return all;
119+ }, [body]);
120+ const [copied, setCopied] = useState(false);
121+ if (lines.length === 0) {
122+ return <p className="px-3 py-2 font-mono text-xs text-faint">No output.</p>;
123+ }
124+ return (
125+ <div className="relative">
126+ <button
127+ type="button"
128+ aria-label="Copy the output"
129+ title="Copy the output"
130+ className="absolute top-1.5 right-3 z-10 rounded-md border border-line bg-surface p-1.5 text-faint transition-colors hover:text-fg"
131+ onClick={() => {
132+ void navigator.clipboard.writeText(stripAnsi(body));
133+ setCopied(true);
134+ setTimeout(() => setCopied(false), 1500);
135+ }}
136+ >
137+ {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />}
138+ </button>
139+ {cut && (
140+ <p className="border-b border-line px-3 py-1.5 text-xs text-faint">
141+ Showing the end of the output. Earlier lines were not kept.
142+ </p>
143+ )}
144+ <div className="max-h-96 overflow-auto py-1.5 font-mono text-xs leading-5">
145+ {lines.map((line, index) => (
146+ <div key={index} className="flex gap-3 pr-12 pl-2 hover:bg-raised/40">
147+ <span className="w-9 shrink-0 text-right text-faint/70 select-none">{index + 1}</span>
148+ <span className="min-w-0 break-all whitespace-pre-wrap text-fg/85">
149+ {ansiSegments(line).map((segment, at) =>
150+ segment.className ? (
151+ <span key={at} className={segment.className}>
152+ {segment.text}
153+ </span>
154+ ) : (
155+ segment.text
156+ ),
157+ )}
158+ </span>
159+ </div>
160+ ))}
161+ </div>
162+ </div>
163+ );
164+}
165+
166+// --- Checks ---------------------------------------------------------------
167+
168+function seconds(ms: number): string {
169+ if (ms < 1000) return `${ms}ms`;
170+ const s = Math.round(ms / 1000);
171+ return s < 60 ? `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
172+}
173+
174+type Standing = "passed" | "failed" | "running" | "queued" | "skipped";
175+
176+function StandingIcon({ standing, size = 15 }: { standing: Standing; size?: number }) {
177+ if (standing === "passed") return <CircleCheck size={size} className="shrink-0 text-accent" aria-label="Passed" />;
178+ if (standing === "failed") return <CircleX size={size} className="shrink-0 text-danger" aria-label="Failed" />;
179+ if (standing === "running")
180+ return <LoaderCircle size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />;
181+ if (standing === "skipped") return <CircleAlert size={size} className="shrink-0 text-faint" aria-label="Skipped" />;
182+ return <LoaderCircle size={size} className="shrink-0 text-faint" aria-label="Queued" />;
183+}
184+
185+/** The id of the workflow run a status links to, if it links to one. */
186+export function runIdOf(status: CommitStatus): string | null {
187+ return /\/actions\/runs\/([^/?#]+)/.exec(status.targetUrl ?? "")?.[1] ?? null;
188+}
189+
190+/** Where a job stands, in the box's terms. */
191+function jobStanding(job: Job): Standing {
192+ if (job.status !== "completed") return job.status === "in_progress" || job.status === "calling" ? "running" : "queued";
193+ if (job.conclusion === "success") return "passed";
194+ if (job.conclusion === "skipped" || job.conclusion === "cancelled") return "skipped";
195+ return "failed";
196+}
197+
198+function statusStanding(status: CommitStatus): Standing {
199+ if (status.state === "pending") return "running";
200+ return status.state === "success" ? "passed" : "failed";
201+}
202+
203+/** One row of the checks list: an icon, a name, how long, and a way in. */
204+function CheckLine({
205+ standing,
206+ name,
207+ detail,
208+ time,
209+ to,
210+}: {
211+ standing: Standing;
212+ name: React.ReactNode;
213+ detail?: React.ReactNode;
214+ time?: string;
215+ to?: string;
216+}) {
217+ return (
218+ <div className="flex items-center gap-2.5 px-4 py-2 text-sm">
219+ <StandingIcon standing={standing} />
220+ <span className="min-w-0 grow truncate">
221+ {name}
222+ {detail && <span className="text-muted"> — {detail}</span>}
223+ </span>
224+ {time && <span className="shrink-0 font-mono text-xs text-faint">{time}</span>}
225+ {to && (
226+ <Link to={to} className="shrink-0 text-xs text-muted hover:text-fg hover:underline">
227+ Details
228+ </Link>
229+ )}
230+ </div>
231+ );
232+}
233+
234+/** An acceptance check: its command, and, open when it failed, what it printed. */
235+function AcceptanceCheck({ result }: { result: CheckResult }) {
236+ const timedOut = result.exitCode == null && !result.passed;
237+ return (
238+ <details className="group" open={!result.passed}>
239+ <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
240+ <StandingIcon standing={result.passed ? "passed" : "failed"} />
241+ <span className="min-w-0 grow truncate">
242+ <code className="font-mono text-xs">{result.command}</code>
243+ {!result.passed && (
244+ <span className="text-muted">
245+ {" — "}
246+ {timedOut ? "stopped for taking too long" : `exited with code ${result.exitCode}`}
247+ </span>
248+ )}
249+ </span>
250+ <span className="shrink-0 font-mono text-xs text-faint">{seconds(result.durationMs)}</span>
251+ <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
252+ </summary>
253+ <div className="mx-4 mb-3 overflow-hidden rounded-lg border border-line bg-bg">
254+ <LogViewer text={result.output} />
255+ </div>
256+ </details>
257+ );
258+}
259+
260+/**
261+ * Every check on a pull request, at a glance and in detail: its issue's
262+ * acceptance checks, run in a clean sandbox, and the workflows run on its
263+ * head, job by job.
264+ */
265+export function ChecksSection({
266+ run,
267+ commands,
268+ statuses,
269+ jobs,
270+ pull,
271+ base,
272+ earlier,
273+ canRerun,
274+ canRerunWorkflows,
275+ error,
276+}: {
277+ run: CheckRun | null;
278+ /** The issue's acceptance checks, shown before a run has results. */
279+ commands: string[];
280+ statuses: CommitStatus[];
281+ /** Each workflow run's jobs, by run id, where they could be read. */
282+ jobs: Record<string, Job[]>;
283+ pull: Pull;
284+ /** The repository's path, `/<owner>/<repo>`. */
285+ base: string;
286+ earlier: CheckRun[];
287+ canRerun: boolean;
288+ canRerunWorkflows: boolean;
289+ error?: string | null;
290+}) {
291+ if (!run && statuses.length === 0) return null;
292+
293+ // Every row, so they can be counted the same way they are shown.
294+ const acceptance: Standing[] = run
295+ ? run.results.length > 0
296+ ? run.results.map((result) => (result.passed ? "passed" : "failed"))
297+ : run.status === "queued" || run.status === "running"
298+ ? commands.map(() => (run.status === "running" ? "running" : "queued"))
299+ : run.status === "errored"
300+ ? ["failed"]
301+ : []
302+ : [];
303+ const workflow: Standing[] = statuses.flatMap((status) => {
304+ const id = runIdOf(status);
305+ const theirs = id ? jobs[id] : undefined;
306+ return theirs && theirs.length > 0 ? theirs.map(jobStanding) : [statusStanding(status)];
307+ });
308+ const all = [...acceptance, ...workflow];
309+ const failed = all.filter((standing) => standing === "failed").length;
310+ const pending = all.filter((standing) => standing === "running" || standing === "queued").length;
311+ const passed = all.filter((standing) => standing === "passed").length;
312+ const total = all.length;
313+
314+ const headline =
315+ failed > 0
316+ ? `${failed} of ${total} ${total === 1 ? "check" : "checks"} failed`
317+ : pending > 0
318+ ? `${pending} of ${total} ${total === 1 ? "check is" : "checks are"} still running`
319+ : total > 0
320+ ? "All checks have passed"
321+ : "Checks";
322+ const sub = [
323+ failed > 0 && pending > 0 && `${pending} still running`,
324+ passed > 0 && `${passed} passed`,
325+ all.filter((standing) => standing === "skipped").length > 0 &&
326+ `${all.filter((standing) => standing === "skipped").length} skipped or cancelled`,
327+ ]
328+ .filter(Boolean)
329+ .join(", ");
330+
331+ const pending_ = run?.status === "queued" || run?.status === "running";
332+ const commitPath = (sha: string) =>
333+ pull.fork ? `/${pull.fork.namespace}/${pull.fork.name}/commit/${sha}` : `${base}/commit/${sha}`;
334+ const older = run != null && pull.headCommit != null && run.headCommit !== pull.headCommit;
335+ const failedRuns = statuses.filter((status) => statusStanding(status) === "failed" && runIdOf(status));
336+
337+ return (
338+ <div>
339+ <div className="flex items-start gap-3 px-4 py-3">
340+ <span className="mt-0.5 shrink-0">
341+ {failed > 0 ? (
342+ <CircleX size={16} className="text-danger" />
343+ ) : pending > 0 ? (
344+ <LoaderCircle size={16} className="animate-spin text-warn" />
345+ ) : (
346+ <CircleCheck size={16} className="text-accent" />
347+ )}
348+ </span>
349+ <div className="min-w-0 grow">
350+ <p className="text-sm font-medium">{headline}</p>
351+ {sub && <p className="mt-0.5 text-xs text-muted">{sub}</p>}
352+ </div>
353+ <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
354+ {canRerun && run && !pending_ && (
355+ <Form method="post">
356+ <input type="hidden" name="action" value="recheck" />
357+ <Button variant="quiet" type="submit">
358+ <RotateCw size={13} />
359+ Re-run checks
360+ </Button>
361+ </Form>
362+ )}
363+ {canRerunWorkflows &&
364+ failedRuns.map((status) => (
365+ <Form method="post" key={status.context}>
366+ <input type="hidden" name="action" value="rerun-workflow" />
367+ <input type="hidden" name="run" value={runIdOf(status) ?? ""} />
368+ <Button variant="quiet" type="submit" title={`Re-run the failed jobs of ${status.context}`}>
369+ <RotateCw size={13} />
370+ Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""}
371+ </Button>
372+ </Form>
373+ ))}
374+ </div>
375+ </div>
376+ {error && (
377+ <div className="px-4 pb-2">
378+ <ErrorText>{error}</ErrorText>
379+ </div>
380+ )}
381+
382+ <div className="divide-y divide-line border-t border-line">
383+ {run && (
384+ <section aria-label="Acceptance checks" className="py-1">
385+ <p className="flex flex-wrap items-center gap-x-1.5 px-4 pt-1.5 pb-1 text-xs text-faint">
386+ <span className="font-medium text-muted">Acceptance checks</span>
387+ <span>· in a clean sandbox, on</span>
388+ <Link to={commitPath(run.headCommit)} className="inline-flex items-center gap-1 font-mono hover:text-fg">
389+ <GitCommitHorizontal size={12} />
390+ {run.headCommit.slice(0, 7)}
391+ </Link>
392+ {run.finishedAt ? (
393+ <span>
394+ · finished <TimeAgo at={run.finishedAt} />
395+ </span>
396+ ) : (
397+ <span>
398+ · started <TimeAgo at={run.createdAt} />
399+ </span>
400+ )}
401+ </p>
402+ {older && (
403+ <p className="mx-4 my-1.5 flex items-start gap-2 rounded-md bg-warn/10 px-2.5 py-1.5 text-xs text-warn">
404+ <TriangleAlert size={13} className="mt-0.5 shrink-0" />
405+ These checks ran on an older commit. The latest push,{" "}
406+ <span className="font-mono">{pull.headCommit?.slice(0, 7)}</span>, has not been checked yet.
407+ </p>
408+ )}
409+ {run.status === "errored" && (
410+ <div className="mx-4 my-1.5 rounded-lg border border-danger/40 bg-danger/5 px-3 py-2 text-sm">
411+ <p className="flex items-center gap-2 font-medium text-danger">
412+ <TriangleAlert size={14} />
413+ The checks could not be run
414+ </p>
415+ {run.error && <p className="mt-1 text-muted">{run.error}</p>}
416+ </div>
417+ )}
418+ {run.status === "failed" && run.error && (
419+ <p className="mx-4 my-1.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm text-muted">{run.error}</p>
420+ )}
421+ {run.results.length > 0
422+ ? run.results.map((result, index) => <AcceptanceCheck key={`${result.command}-${index}`} result={result} />)
423+ : pending_ &&
424+ commands.map((command) => (
425+ <CheckLine
426+ key={command}
427+ standing={run.status === "running" ? "running" : "queued"}
428+ name={<code className="font-mono text-xs">{command}</code>}
429+ detail={run.status === "running" ? "running" : "waiting for a sandbox"}
430+ />
431+ ))}
432+ </section>
433+ )}
434+
435+ {statuses.length > 0 && (
436+ <section aria-label="Workflows" className="py-1">
437+ <p className="px-4 pt-1.5 pb-1 text-xs font-medium text-muted">Workflows</p>
438+ {statuses.map((status) => {
439+ const id = runIdOf(status);
440+ const runPath = id ? `${base}/actions/runs/${id}` : undefined;
441+ const theirs = id ? jobs[id] : undefined;
442+ if (!theirs || theirs.length === 0) {
443+ return (
444+ <CheckLine
445+ key={status.context}
446+ standing={statusStanding(status)}
447+ name={status.context}
448+ detail={status.description ?? undefined}
449+ to={runPath}
450+ />
451+ );
452+ }
453+ return theirs.map((job) => (
454+ <CheckLine
455+ key={`${status.context}-${job.id}`}
456+ standing={jobStanding(job)}
457+ name={
458+ <>
459+ <span className="text-muted">{status.context} / </span>
460+ {job.name}
461+ </>
462+ }
463+ detail={job.reason ?? (jobStanding(job) === "failed" ? "failed" : undefined)}
464+ time={duration(job.startedAt, job.finishedAt) || undefined}
465+ to={`${runPath}?job=${job.id}`}
466+ />
467+ ));
468+ })}
469+ </section>
470+ )}
471+
472+ {earlier.length > 0 && (
473+ <details className="group">
474+ <summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-2 text-xs text-muted hover:text-fg">
475+ <History size={13} />
476+ Earlier runs of the acceptance checks ({earlier.length})
477+ <ChevronRight size={13} className="transition-transform group-open:rotate-90" />
478+ </summary>
479+ <ul className="space-y-1 px-4 pb-3 text-xs">
480+ {earlier.map((before) => (
481+ <li key={before.id} className="flex items-center gap-2 text-muted">
482+ <StandingIcon
483+ size={13}
484+ standing={
485+ before.status === "passed"
486+ ? "passed"
487+ : before.status === "queued" || before.status === "running"
488+ ? "queued"
489+ : "failed"
490+ }
491+ />
492+ <Link to={commitPath(before.headCommit)} className="font-mono hover:text-fg">
493+ {before.headCommit.slice(0, 7)}
494+ </Link>
495+ <span className="min-w-0 truncate">
496+ {before.status === "errored"
497+ ? `could not run${before.error ? `: ${before.error}` : ""}`
498+ : before.results.length > 0
499+ ? `${before.results.filter((result) => result.passed).length} of ${before.results.length} passed`
500+ : before.status}
501+ </span>
502+ <span className="ml-auto shrink-0 text-faint">
503+ <TimeAgo at={before.createdAt} />
504+ </span>
505+ </li>
506+ ))}
507+ </ul>
508+ </details>
509+ )}
510+ </div>
511+ </div>
512+ );
513+}
514+
515+// --- Mergeability ---------------------------------------------------------
516+
517+/** How to resolve the conflicts by hand, step by step, for a branch or a fork. */
518+export function commandLineSteps(pull: Pull, owner: string, repo: string, defaultBranch: string, files: string[]): string[] {
519+ const upstream = `https://g1t.sh/${owner}/${repo}.git`;
520+ const add = files.length > 0 && files.length <= 6 ? `git add ${files.join(" ")}` : "git add -A";
521+ if (pull.branch) {
522+ return [
523+ `git fetch origin`,
524+ `git checkout ${pull.branch}`,
525+ `git merge origin/${defaultBranch}`,
526+ `${add} && git commit --no-edit`,
527+ `git push origin ${pull.branch}`,
528+ ];
529+ }
530+ const fork = pull.fork!;
531+ return [
532+ `git clone https://g1t.sh/${fork.namespace}/${fork.name}.git && cd ${fork.name}`,
533+ `git pull --no-rebase ${upstream} ${defaultBranch}`,
534+ `${add} && git commit --no-edit`,
535+ `git push`,
536+ ];
537+}
538+
539+/**
540+ * The branch conflicts with its target: which files, and three ways out —
541+ * the g1t agent, the browser (soon), or the command line.
542+ */
543+export function ConflictsSection({
544+ conflicts,
545+ pull,
546+ owner,
547+ repo,
548+ defaultBranch,
549+ changesUrl,
550+ canResolve,
551+ resolving,
552+ onResolve,
553+ error,
554+}: {
555+ conflicts: string[];
556+ pull: Pull;
557+ owner: string;
558+ repo: string;
559+ defaultBranch: string;
560+ /** Where the pull request's changes are shown, to link each file. */
561+ changesUrl: string;
562+ /** Whether the viewer may have the g1t agent resolve them. */
563+ canResolve: boolean;
564+ resolving: boolean;
565+ onResolve: () => void;
566+ error?: string | null;
567+}) {
568+ const steps = commandLineSteps(pull, owner, repo, defaultBranch, conflicts);
569+ return (
570+ <div className="bg-danger/5">
571+ <div className="flex gap-3 px-4 py-3">
572+ <CircleAlert size={16} className="mt-0.5 shrink-0 text-danger" />
573+ <div className="min-w-0 grow">
574+ <p className="text-sm font-medium">This branch has conflicts that must be resolved</p>
575+ <p className="mt-0.5 text-sm text-muted">
576+ Merging <span className="font-mono">{defaultBranch}</span> into it stops on{" "}
577+ {conflicts.length === 1 ? "one file" : `${conflicts.length} files`}. It cannot merge until they are resolved.
578+ </p>
579+ {conflicts.length > 0 && (
580+ <>
581+ <p className="mt-3 text-xs font-medium text-muted">Conflicting files</p>
582+ <ul className="mt-1.5 space-y-1">
583+ {conflicts.map((path) => (
584+ <li key={path}>
585+ <Link
586+ to={`${changesUrl}#file-${path}`}
587+ className="font-mono text-xs text-fg hover:text-danger hover:underline"
588+ >
589+ {path}
590+ </Link>
591+ </li>
592+ ))}
593+ </ul>
594+ </>
595+ )}
596+ <div className="mt-3 flex flex-wrap items-center gap-2">
597+ {canResolve && (
598+ <Form method="post" onSubmit={onResolve}>
599+ <input type="hidden" name="action" value="update" />
600+ <Button variant="primary" type="submit" disabled={resolving}>
601+ <Sparkles size={14} />
602+ {resolving ? "The g1t agent is resolving them…" : "Resolve with g1t agent"}
603+ </Button>
604+ </Form>
605+ )}
606+ <Tooltip>
607+ <TooltipTrigger asChild>
608+ {/* A disabled button gets no pointer events; the span carries the tooltip. */}
609+ <span tabIndex={0} className="inline-flex">
610+ <Button variant="quiet" type="button" disabled aria-disabled="true">
611+ Resolve in the browser
612+ <span className="rounded-full border border-line px-1.5 text-[0.625rem] text-faint">Soon</span>
613+ </Button>
614+ </span>
615+ </TooltipTrigger>
616+ <TooltipContent>
617+ An editor for conflicts in the browser is coming. Until then, have the g1t agent resolve them, or use the
618+ command line.
619+ </TooltipContent>
620+ </Tooltip>
621+ </div>
622+ {error && (
623+ <div className="mt-2">
624+ <ErrorText>{error}</ErrorText>
625+ </div>
626+ )}
627+ <details className="group mt-3">
628+ <summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-muted hover:text-fg">
629+ <Terminal size={13} />
630+ Resolve on the command line
631+ <ChevronRight size={13} className="transition-transform group-open:rotate-90" />
632+ </summary>
633+ <ol className="mt-2 space-y-2 text-xs text-muted">
634+ {steps.map((step, index) => (
635+ <li key={step}>
636+ <p className="mb-1">
637+ {index + 1}.{" "}
638+ {step.startsWith("git add")
639+ ? "Fix the conflicts in each file, removing the conflict markers, then commit the merge:"
640+ : step.startsWith("git push")
641+ ? "Push it. The pull request updates, and its checks run again:"
642+ : step.includes("merge") || step.includes("pull")
643+ ? `Merge ${defaultBranch} in. Git stops on the conflicting files:`
644+ : step.startsWith("git clone")
645+ ? "Clone the pull request's fork:"
646+ : step.startsWith("git checkout")
647+ ? "Check out the pull request's branch:"
648+ : "Get the latest of both:"}
649+ </p>
650+ <CopyLine text={step} prompt />
651+ </li>
652+ ))}
653+ </ol>
654+ {pull.fork && (
655+ <p className="mt-2 text-xs text-faint">
656+ A pull request's fork takes pushes from whoever opened it.
657+ </p>
658+ )}
659+ </details>
660+ </div>
661+ </div>
662+ </div>
663+ );
664+}
665+
666+/** Whether the change merges cleanly, when that is not a conflict. */
667+export function MergeabilityRow({
668+ mergeable,
669+ defaultBranch,
670+}: {
671+ mergeable: Mergeable;
672+ defaultBranch: string;
673+}) {
674+ if (mergeable === "checking") {
675+ return (
676+ <div className="flex gap-3 px-4 py-3 text-sm">
677+ <LoaderCircle size={16} className="mt-0.5 shrink-0 animate-spin text-faint" />
678+ <div>
679+ <p className="font-medium">Checking whether this merges cleanly…</p>
680+ <p className="mt-0.5 text-muted">
681+ It and <span className="font-mono">{defaultBranch}</span> changed some of the same files, so g1t is merging
682+ the two in a sandbox to see.
683+ </p>
684+ </div>
685+ </div>
686+ );
687+ }
688+ if (mergeable === "clean") {
689+ return (
690+ <div className="flex gap-3 px-4 py-3 text-sm">
691+ <CircleCheck size={16} className="mt-0.5 shrink-0 text-accent" />
692+ <p className="font-medium">
693+ This branch has no conflicts with <span className="font-mono">{defaultBranch}</span>
694+ </p>
695+ </div>
696+ );
697+ }
698+ return null;
699+}
+116−0
1+import { Check } from "lucide-react";
2+import { useState } from "react";
3+import { Form, Link, useNavigation } from "react-router";
4+
5+import { PROFILE_LIMITS, type Profile } from "@g1t/contracts";
6+
7+import { Button } from "./ui";
8+import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
9+import { Input } from "./ui/input";
10+import { Textarea } from "./ui/textarea";
11+
12+/**
13+ * The Profile section of a person's settings: what everyone sees at
14+ * `/u/<username>`. Posts `intent=profile`; identity checks every field.
15+ */
16+export function ProfileSection({
17+ username,
18+ profile,
19+ error,
20+ saved,
21+}: {
22+ username: string;
23+ profile: Profile | null;
24+ error?: string;
25+ saved: boolean;
26+}) {
27+ const navigation = useNavigation();
28+ const busy = navigation.state === "submitting" && navigation.formData?.get("intent") === "profile";
29+ const [bio, setBio] = useState(profile?.bio ?? "");
30+ return (
31+ <section id="profile" className="scroll-mt-20">
32+ <div className="flex flex-wrap items-baseline justify-between gap-2">
33+ <h2 className="font-medium">Profile</h2>
34+ <Link to={`/u/${username}`} className="text-sm text-muted underline-offset-4 hover:text-fg hover:underline">
35+ View your profile
36+ </Link>
37+ </div>
38+ <p className="mt-1 text-sm text-muted">
39+ Shown to everyone at <span className="font-mono text-fg">g1t.sh/u/{username}</span>. All of it is
40+ optional; your email address is never shown.
41+ </p>
42+ <Form method="post" className="mt-4 grid gap-4 sm:grid-cols-2">
43+ <input type="hidden" name="intent" value="profile" />
44+ <Field>
45+ <FieldLabel htmlFor="profile-name">Name</FieldLabel>
46+ <Input
47+ id="profile-name"
48+ name="name"
49+ defaultValue={profile?.name ?? ""}
50+ maxLength={PROFILE_LIMITS.name}
51+ placeholder={username}
52+ />
53+ </Field>
54+ <Field>
55+ <FieldLabel htmlFor="profile-pronouns">Pronouns</FieldLabel>
56+ <Input
57+ id="profile-pronouns"
58+ name="pronouns"
59+ defaultValue={profile?.pronouns ?? ""}
60+ maxLength={PROFILE_LIMITS.pronouns}
61+ placeholder="they/them"
62+ />
63+ </Field>
64+ <Field className="sm:col-span-2">
65+ <FieldLabel htmlFor="profile-bio">Bio</FieldLabel>
66+ <Textarea
67+ id="profile-bio"
68+ name="bio"
69+ rows={2}
70+ value={bio}
71+ onChange={(event) => setBio(event.currentTarget.value)}
72+ maxLength={PROFILE_LIMITS.bio}
73+ placeholder="What you work on, in a line or two."
74+ />
75+ <FieldDescription>
76+ {bio.length}/{PROFILE_LIMITS.bio} characters. Also what a link to your profile says.
77+ </FieldDescription>
78+ </Field>
79+ <Field>
80+ <FieldLabel htmlFor="profile-location">Location</FieldLabel>
81+ <Input
82+ id="profile-location"
83+ name="location"
84+ defaultValue={profile?.location ?? ""}
85+ maxLength={PROFILE_LIMITS.location}
86+ placeholder="Portland, Oregon"
87+ />
88+ </Field>
89+ <Field>
90+ <FieldLabel htmlFor="profile-website">Website</FieldLabel>
91+ <Input
92+ id="profile-website"
93+ name="website"
94+ inputMode="url"
95+ defaultValue={profile?.website ?? ""}
96+ maxLength={PROFILE_LIMITS.website}
97+ placeholder="https://example.com"
98+ />
99+ <FieldDescription>An https:// address.</FieldDescription>
100+ </Field>
101+ <div className="flex flex-wrap items-center gap-3 sm:col-span-2">
102+ <Button type="submit" disabled={busy}>
103+ {busy ? "Saving…" : "Save profile"}
104+ </Button>
105+ {saved && !busy && !error && (
106+ <span className="inline-flex items-center gap-1.5 text-sm text-muted">
107+ <Check size={14} className="text-accent" />
108+ Saved
109+ </span>
110+ )}
111+ <FieldError>{error}</FieldError>
112+ </div>
113+ </Form>
114+ </section>
115+ );
116+}
+4−1
1−import { GitBranch, Lock, Network, Rocket, Settings, Webhook } from "lucide-react";
1+import { GitBranch, Globe, Lock, Network, Rocket, Settings, Webhook } from "lucide-react";
22
33 import { TabLink } from "./ui";
44
1515 <TabLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}>
1616 Deployments
1717 </TabLink>
18+ <TabLink to={`${base}/settings/domains`} icon={<Globe size={15} />}>
19+ Domains
20+ </TabLink>
1821 <TabLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}>
1922 Dependencies
2023 </TabLink>
+10−3
3535 Webhook,
3636 PlayCircle,
3737 Bot,
38+ Brain,
3839 ShieldCheck,
3940 Rocket,
4041 X,
486487 <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
487488 Pull requests
488489 </SidebarLink>
489− <SidebarSoonLink to={`${base}/soon/agents`} also={soonPaths(base, "Agents")} icon={<Bot size={15} />} about="Every agent at work on this project, its sessions, playbooks and memory.">
490+ <SidebarLink to={`${base}/agents`} also={[`${base}/sessions`, `${base}/memory`, ...soonPaths(base, "Agents")]} icon={<Bot size={15} />}>
490491 Agents
491− </SidebarSoonLink>
492+ </SidebarLink>
492493 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
493494 Workflows
494495 </SidebarLink>
564565 // In a repository, or on the way into one, its own menu takes the sidebar.
565566 const target = going ?? pathname;
566567 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
567− const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
568+ const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
568569 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
569570 const away = inSettings || inAccount || inRepo;
570571 // The repository the menu is for: the one loaded if it is the one being
675676 </SidebarGroup>
676677 {ws && (
677678 <SidebarGroup title="Across projects">
679+ <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}>
680+ Agent fleet
681+ </SidebarLink>
682+ <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />}>
683+ Memory
684+ </SidebarLink>
678685 {roadmapIn("Workspace").map((item) => (
679686 <SidebarSoonLink
680687 key={item.key}
+58−10
197197 request_changes: { label: "requested changes", style: "text-danger" },
198198 } as const;
199199
200+/** How g1t's own agents are named; they have no profile to link to. */
201+const AGENTS = new Set(["g1t-agent", "g1t agent", "g1t"]);
202+
203+/**
204+ * A person's name (or `children`) linking to their profile at `/u/<name>`.
205+ * g1t's agents, and names that are not usernames, stay plain text.
206+ */
207+export function PersonLink({
208+ name,
209+ className,
210+ label,
211+ children,
212+}: {
213+ name: string;
214+ className?: string;
215+ label?: string;
216+ children?: ReactNode;
217+}) {
218+ if (AGENTS.has(name) || !/^[a-z0-9-]{1,39}$/i.test(name)) {
219+ return <span className={className}>{children ?? name}</span>;
220+ }
221+ return (
222+ <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}>
223+ {children ?? name}
224+ </Link>
225+ );
226+}
227+
200228 /**
201229 * Comments in order, then the box to add one. On a pull request, `review`
202230 * says where its changes are shown and whether the viewer may give a
224252 return (
225253 <div className="flex gap-3">
226254 <span className="mt-1 hidden shrink-0 sm:block">
227− <Avatar name={author} size={32} />
255+ <PersonLink name={author} label={`${author}'s profile`}>
256+ <Avatar name={author} size={32} />
257+ </PersonLink>
228258 </span>
229259 <article className="min-w-0 grow overflow-hidden rounded-xl border border-line bg-surface">
230260 <header className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-line bg-raised/40 px-4 py-2 text-sm text-muted">
231261 <span className="sm:hidden">
232262 <Avatar name={author} size={18} />
233263 </span>
234− <span className="font-medium text-fg">{author}</span>
264+ <PersonLink name={author} className="font-medium text-fg hover:underline" />
235265 {action}
236266 {at && <TimeAgo at={at} />}
237267 {aside && <span className="ml-auto min-w-0">{aside}</span>}
244274
245275 type Review = { changesUrl: string; canJudge: boolean };
246276
247−/** Text with each `#12` linked to the issue or pull request of that number. */
248−function WithReferences({ text, base }: { text: string; base?: string }) {
277+/**
278+ * Text with each `#12` linked to the issue or pull request of that number,
279+ * and each file named in backticks shown as code, linked to its diff when
280+ * `filesUrl` says where the changes are.
281+ */
282+function WithReferences({ text, base, filesUrl }: { text: string; base?: string; filesUrl?: string }) {
249283 if (!base) return <>{text}</>;
250284 return (
251285 <>
252− {text.split(/(#\d+)/).map((part, index) =>
253− /^#\d+$/.test(part) ? (
286+ {text.split(/(#\d+|`[^`\n]+`)/).map((part, index) =>
287+ /^`[^`]+`$/.test(part) ? (
288+ filesUrl ? (
289+ <Link
290+ key={index}
291+ to={`${filesUrl}#file-${part.slice(1, -1)}`}
292+ className="font-mono text-xs text-fg hover:underline"
293+ >
294+ {part.slice(1, -1)}
295+ </Link>
296+ ) : (
297+ <code key={index} className="font-mono text-xs text-fg">
298+ {part.slice(1, -1)}
299+ </code>
300+ )
301+ ) : /^#\d+$/.test(part) ? (
254302 // Issues and pull requests share numbers; the issue page forwards.
255303 <Link
256304 key={index}
268316 }
269317
270318 /** Something that happened, as one line on the conversation's rail. */
271−function TimelineEvent({ comment, base }: { comment: Comment; base?: string }) {
319+function TimelineEvent({ comment, base, filesUrl }: { comment: Comment; base?: string; filesUrl?: string }) {
272320 return (
273321 <div className="flex items-center gap-3 text-sm text-muted">
274322 <span className="hidden w-8 shrink-0 justify-center sm:flex">
276324 </span>
277325 <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
278326 <Avatar name={comment.author.username} size={16} />
279− <span className="font-medium text-fg">{comment.author.username}</span>
327+ <PersonLink name={comment.author.username} className="font-medium text-fg hover:underline" />
280328 <span>
281− <WithReferences text={comment.body} base={base} />
329+ <WithReferences text={comment.body} base={base} filesUrl={filesUrl} />
282330 </span>
283331 <span className="text-faint">
284332 · <TimeAgo at={comment.createdAt} />
306354 <>
307355 {comments.map((comment) => {
308356 if (comment.kind === "event") {
309− return <TimelineEvent key={comment.id} comment={comment} base={base} />;
357+ return <TimelineEvent key={comment.id} comment={comment} base={base} filesUrl={review?.changesUrl} />;
310358 }
311359 const verdict = comment.verdict && VERDICTS[comment.verdict];
312360 return (
+6−1
4646 },
4747 {
4848 key: "Agents",
49− tabs: soon("Agents"),
49+ tabs: [
50+ { label: "At work", path: "agents" },
51+ { label: "Sessions", path: "sessions" },
52+ { label: "Memory", path: "memory", members: true },
53+ ...soon("Agents"),
54+ ],
5055 },
5156 {
5257 key: "Deployments",
+31−0
1+import { redirect } from "react-router";
2+
3+import { isValidNamespace } from "@g1t/contracts";
4+
5+import { identity } from "./services.server";
6+
7+/**
8+ * A workspace that was renamed keeps its old address as a redirect for
9+ * `SLUG_HOLD_DAYS`. Call this where a page is about to 404 on its first
10+ * path segment: if `slug` is an old name, it throws a 301 to the same
11+ * address (path and query) under the current one. Otherwise it returns, and
12+ * the caller 404s as before. Only the not-found path pays for the lookup.
13+ */
14+export async function redirectIfRenamed(request: Request, slug: string): Promise<void> {
15+ const old = slug.toLowerCase();
16+ // Nothing that could never have been a workspace's name is looked up.
17+ if (!isValidNamespace(old)) return;
18+ let current: string | null = null;
19+ try {
20+ current = await identity.resolveSlug(old);
21+ } catch {
22+ // A lookup that fails leaves the page a 404, never a 500.
23+ return;
24+ }
25+ if (!current || current === old) return;
26+ const url = new URL(request.url);
27+ const segments = url.pathname.split("/");
28+ // segments[0] is the empty string before the leading slash.
29+ segments[1] = current;
30+ throw redirect(segments.join("/") + url.search, 301);
31+}
+3−51
120120 },
121121
122122 // --- Agents -------------------------------------------------------------
123− {
124− key: "agents",
125− title: "At work",
126− section: "Agents",
127− summary: "Every agent at work on this project now, and a way to steer it.",
128− why: "Agents are the project's busiest contributors. See each one live: what issue it holds, what it is doing this minute, what it has spent, and send it a word mid-run.",
129− plans: [
130− "Live view of each running agent, with its current step",
131− "Steer, pause or stop a run, and hand it to a person",
132− "What each run has cost so far",
133− "Your own agents and g1t's side by side",
134− ],
135− today: { label: "Pull requests", path: "pulls" },
136− },
137− {
138− key: "sessions",
139− title: "Sessions",
140− section: "Agents",
141− summary: "Every agent session that changed this project, searchable.",
142− why: "When an agent writes a line, its reasoning is worth keeping. Every session is kept, searchable, and linked from the lines it wrote, so why-blame answers why the code is the way it is.",
143− plans: [
144− "Every session, with its prompt, steps, tools and result",
145− "Search by file, issue, agent or words",
146− "Why-blame: from any line to the session that wrote it",
147− "Sessions as context for the next agent on the same code",
148− ],
149− today: { label: "Commits", path: "commits" },
150− },
123+ // At work, Sessions and Memory are built (see project-nav.ts), and so is
124+ // the workspace's Agent fleet.
151125 {
152126 key: "playbooks",
153127 title: "Playbooks",
160134 "Paths agents may not touch without a person",
161135 "Learned from reviews: what people corrected becomes a rule",
162136 ],
163− today: { label: "Settings", path: "settings" },
164− },
165− {
166− key: "memory",
167− title: "Memory",
168− section: "Agents",
169− summary: "What agents have learned about this project, kept and curated.",
170− why: "Agents that forget repeat mistakes. g1t keeps what they learn about the code, its quirks and its people, and lets you see, edit and delete it.",
171− plans: ["Facts agents learned, with where they learned them", "Edit or remove anything", "Shared across every agent on the project"],
172− },
173−
174− {
175− key: "fleet",
176− title: "Agent fleet",
177− section: "Workspace",
178− summary: "Every agent at work across the workspace, and what each is costing.",
179− why: "Agents work on many projects at once. The fleet shows them all: what each holds, how far along it is, and where people are needed.",
180− plans: [
181− "Every running agent across projects, live",
182− "Queue of work waiting for an agent, by priority",
183− "Spend by agent, project and kind of work",
184− "Pause or redirect agents across the workspace",
185− ],
137+ today: { label: "Memory", path: "memory" },
186138 },
187139
188140 // --- Deployments ----------------------------------------------------------
+3−0
22
33 import {
44 actionsClient,
5+ agentsClient,
56 billingClient,
67 deploymentsClient,
78 projectsClient,
2324 export const actions = actionsClient(env.ACTIONS);
2425 export const deployments = deploymentsClient(env.DEPLOYMENTS);
2526 export const projects = projectsClient(env.PROJECTS);
27+/** Agent runs, sessions and memory: methods of the work service. */
28+export const agents = agentsClient(env.WORK);
+12−0
1212 route("oauth/authorize", "routes/oauth-authorize.tsx"),
1313 route("new", "routes/new.tsx"),
1414 route("settings", "routes/settings.tsx"),
15+ // People, apart from workspaces: `u` is a reserved name.
16+ route("u/:username", "routes/user.tsx"),
1517 route("explore", "routes/explore.tsx", { id: "explore" }),
1618 route("pricing", "routes/pricing.tsx"),
1719 route("search", "routes/explore.tsx", { id: "search" }),
2729 route("-/webhooks", "routes/workspace/webhooks.tsx"),
2830 route("-/secrets", "routes/workspace/secrets.tsx"),
2931 route("-/settings", "routes/workspace/settings.tsx"),
32+ route("-/agents", "routes/workspace/agents.tsx"),
33+ route("-/memory", "routes/workspace/memory.tsx"),
3034 // What the workspace will have across its projects.
3135 route("-/soon/:feature", "routes/workspace/soon.tsx"),
3236 ]),
3337 // Why a line is the way it is, fetched by the blame view.
3438 route(":owner/:repo/why/:hash", "routes/repo/why.ts"),
39+ // A project's agent runs as JSON, and stopping or messaging one.
40+ route(":owner/:repo/agents.json", "routes/repo/agents-live.ts"),
3541 // A project: its overview first, its repository's code under Code. The
3642 // 1:1 project of a repository has the repository's name, so every
3743 // repository address below keeps working.
4955 route("pulls/new", "routes/repo/pull-new.tsx"),
5056 route("pull/:number", "routes/repo/pull.tsx"),
5157 route("queue", "routes/repo/queue.tsx"),
58+ route("agents", "routes/repo/agents.tsx"),
59+ route("agents/runs/:id", "routes/repo/agents-run.tsx"),
60+ route("sessions", "routes/repo/sessions.tsx"),
61+ route("sessions/:number", "routes/repo/session.tsx"),
62+ route("memory", "routes/repo/memory.tsx"),
5263 route("actions", "routes/repo/actions.tsx"),
5364 route("actions/runs/:id", "routes/repo/actions-run.tsx"),
5465 route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"),
6273 route("settings/webhooks", "routes/repo/webhooks.tsx"),
6374 route("settings/secrets", "routes/repo/secrets.tsx"),
6475 route("settings/deployments", "routes/repo/settings-deployments.tsx"),
76+ route("settings/domains", "routes/repo/settings-domains.tsx"),
6577 route("settings/dependencies", "routes/repo/settings-dependencies.tsx"),
6678 // What the project will have: one page for each Soon in its menu.
6779 route("soon/:feature", "routes/repo/soon.tsx"),
+9−1
11 import { data } from "react-router";
22
3+import type { Route } from "./+types/not-found";
4+import { redirectIfRenamed } from "../lib/renamed.server";
5+
36 /**
47 * Any address no other route matches. Throwing the 404 from a route, rather
58 * than leaving the router to, means the root loader still runs, so someone
69 * signed in sees the page in their own sidebar and not the public frame.
10+ *
11+ * An address under a renamed workspace's old name is sent to the new one
12+ * first, so deep links keep working the same as its pages do.
713 */
8−export function loader() {
14+export async function loader({ request }: Route.LoaderArgs) {
15+ const first = new URL(request.url).pathname.split("/")[1];
16+ if (first) await redirectIfRenamed(request, first);
917 throw data(null, { status: 404 });
1018 }
1119
+2−1
147147 </div>
148148 <p className="mt-1 text-sm text-muted">
149149 Includes {DEPLOYMENTS_ALLOWANCE.apps} apps up at once, {(DEPLOYMENTS_ALLOWANCE.requests / 1e6).toLocaleString()}{" "}
150− million requests and {(DEPLOYMENTS_ALLOWANCE.cpuMs / 1e6).toLocaleString()} million CPU milliseconds a month;
150+ million requests, {(DEPLOYMENTS_ALLOWANCE.cpuMs / 1e6).toLocaleString()} million CPU milliseconds and{" "}
151+ {DEPLOYMENTS_ALLOWANCE.customDomains} custom domains a month;
151152 builds, and usage past that, at the prices above.
152153 </p>
153154 </section>
+61−0
1+/**
2+ * A project's agent runs as JSON, for the parts of other pages that show
3+ * them live (a pull request's Agent panel, the pull request list), and
4+ * the one place runs are stopped and messaged from.
5+ */
6+import { env } from "cloudflare:workers";
7+import { data } from "react-router";
8+
9+import { RUN_KINDS, type RunKind, takesMessages } from "@g1t/contracts";
10+
11+import type { Route } from "./+types/agents-live";
12+import { agents, work } from "../../lib/services.server";
13+import { assertSameOrigin, getViewer, roleIn } from "../../lib/session.server";
14+
15+export async function loader({ params, context, request }: Route.LoaderArgs) {
16+ const viewer = getViewer(context);
17+ const query = new URL(request.url).searchParams;
18+ const number = Number(query.get("number"));
19+ const kind = query.get("kind");
20+ const found = await agents.listRuns(viewer, {
21+ repo: { namespace: params.owner, name: params.repo },
22+ active: query.get("active") === "1",
23+ number: number > 0 ? number : undefined,
24+ kind: kind && RUN_KINDS.includes(kind as RunKind) ? (kind as RunKind) : undefined,
25+ limit: Math.min(Number(query.get("limit")) || 50, 200),
26+ });
27+ if (!found.ok) throw data({ error: found.error.message }, { status: 404 });
28+ return Response.json(
29+ { runs: found.value, member: roleIn(viewer, params.owner) != null },
30+ { headers: { "cache-control": "no-store" } },
31+ );
32+}
33+
34+export async function action({ params, context, request }: Route.ActionArgs) {
35+ assertSameOrigin(request);
36+ const viewer = getViewer(context);
37+ if (!viewer) return { ok: false, error: "Sign in first." };
38+ const path = { namespace: params.owner, name: params.repo };
39+ const form = await request.formData();
40+ const intent = String(form.get("intent") ?? "");
41+ if (intent === "stop") {
42+ const stopped = await env.RUNNER.stopRun(viewer, path, String(form.get("run") ?? ""));
43+ return stopped.ok ? { ok: true, notice: "Stopped." } : { ok: false, error: stopped.error.message };
44+ }
45+ if (intent === "message") {
46+ const number = Number(form.get("number"));
47+ const sent = await work.messageAgent(viewer, path, number, String(form.get("body") ?? ""));
48+ if (!sent.ok) return { ok: false, error: sent.error.message };
49+ // Whether the agent reads it now, or its next run on the pull request does.
50+ const active = await agents.listRuns(viewer, { repo: path, number, active: true, limit: 1 });
51+ const run = active.ok ? active.value[0] : undefined;
52+ return {
53+ ok: true,
54+ notice:
55+ run && takesMessages(run.kind)
56+ ? "Sent. The agent reads it at its next step."
57+ : "Sent. This run does not read messages while it works, so the agent's next run on this pull request gets it.",
58+ };
59+ }
60+ return { ok: false, error: "Unknown action." };
61+}
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.