Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
589 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'1//! Coordinating work over REST and MCP: sessions of people's own harnesses
2//! (the MCP `session` tool), and claims and an issue's whole context
3//! (actions of the `issue` tool).
4//!
5//! The work service keeps them and decides who may see and change them
6//! (`g1t_contracts::work::coordination`): anyone who can read the
7//! repository reads them; taking part needs a confirmed email address and a
8//! role that may open issues and pull requests; only the person whose
9//! token started a session, or made a claim, changes it.
10
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.11use g1t_contracts::agent_sessions::{AgentMode, StartAgentSessionArgs};
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'12use g1t_contracts::work::*;
13use g1t_contracts::{FailureCode, Outcome, Viewer};
14use serde_json::{Value, json};
15use worker::Result;
16
17use crate::operations::{Services, repo_path};
18
19/// One operation on sessions, claims or an issue's context.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum CoordinationOp {
22 StartSession,
23 UpdateSession,
24 EndSession,
25 GetSession,
26 ListSessions,
27 RecordSessionEntries,
28 ClaimIssue,
29 ReleaseClaim,
30 HandOffIssue,
31 ListClaims,
32 GetWork,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.33 StartAgentSession,
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'34}
35
36impl CoordinationOp {
37 /// Every one: `Op::ALL` lists each as `Op::Coordination(…)`, which a
38 /// test checks against this.
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.39 pub const ALL: [CoordinationOp; 12] = [
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'40 CoordinationOp::StartSession,
41 CoordinationOp::UpdateSession,
42 CoordinationOp::EndSession,
43 CoordinationOp::GetSession,
44 CoordinationOp::ListSessions,
45 CoordinationOp::RecordSessionEntries,
46 CoordinationOp::ClaimIssue,
47 CoordinationOp::ReleaseClaim,
48 CoordinationOp::HandOffIssue,
49 CoordinationOp::ListClaims,
50 CoordinationOp::GetWork,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.51 CoordinationOp::StartAgentSession,
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'52 ];
53
54 pub fn name(self) -> &'static str {
55 match self {
56 CoordinationOp::StartSession => "start_session",
57 CoordinationOp::UpdateSession => "update_session",
58 CoordinationOp::EndSession => "end_session",
59 CoordinationOp::GetSession => "get_session",
60 CoordinationOp::ListSessions => "list_sessions",
61 CoordinationOp::RecordSessionEntries => "record_session_entries",
62 CoordinationOp::ClaimIssue => "claim_issue",
63 CoordinationOp::ReleaseClaim => "release_claim",
64 CoordinationOp::HandOffIssue => "hand_off_issue",
65 CoordinationOp::ListClaims => "list_claims",
66 CoordinationOp::GetWork => "get_work",
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.67 CoordinationOp::StartAgentSession => "start_agent_session",
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'68 }
69 }
70
71 /// For the API reference.
72 pub fn title(self) -> &'static str {
73 match self {
74 CoordinationOp::StartSession => "Start a session",
75 CoordinationOp::UpdateSession => "Update a session",
76 CoordinationOp::EndSession => "End a session",
77 CoordinationOp::GetSession => "Get a session",
78 CoordinationOp::ListSessions => "List sessions",
79 CoordinationOp::RecordSessionEntries => "Record entries in a session",
80 CoordinationOp::ClaimIssue => "Claim an issue",
81 CoordinationOp::ReleaseClaim => "Release a claim",
82 CoordinationOp::HandOffIssue => "Hand off an issue",
83 CoordinationOp::ListClaims => "List claims",
84 CoordinationOp::GetWork => "Get an issue's work context",
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.85 CoordinationOp::StartAgentSession => "Start an agent session on an issue",
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'86 }
87 }
88
89 /// Whether it only reads.
90 pub fn reads(self) -> bool {
91 matches!(
92 self,
93 CoordinationOp::GetSession | CoordinationOp::ListSessions | CoordinationOp::ListClaims | CoordinationOp::GetWork
94 )
95 }
96
97 pub fn description(self) -> &'static str {
98 match self {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.99 CoordinationOp::StartSession => "Start a session: one run of your own harness (a Claude Code session, a Codex run) on a repository, so others can see who is working on what. `harness` is claude_code, codex, cursor, copilot or other. Give `harness_session_id`, the harness's own id for the session, and starting again with it returns the session already started (refreshing the issue, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `issue` and `pull` attach it to an issue and a pull request of the repository; a pull request for an issue attaches that issue too. A subagent is a session of its own: give `parent_id` (one of your sessions here), its `agent_type`, the `task` and `prompt` its parent gave it, and as `harness_session_id` the parent's harness id, `/agent/` and the subagent's id; it starts on the parent's issue, pull request and branch unless it says otherwise, sees and is seen by its siblings in get_work and list_sessions, and once ended stays ended when started again. Returns the session (`id` `ses_…`, `state` active, idle or ended). A new session, or one started again after it ended, is a session.started event. Needs a confirmed email address and a role that may open issues.",
100 CoordinationOp::UpdateSession => "Update one of your sessions: attach it to an issue or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 2 hours; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. Returns the session; one ended with end_session is refused with `409`.",
101 CoordinationOp::EndSession => "End one of your sessions, with an optional `reason` and `result` (what it reported when it finished: a subagent's final message, up to 8,000 characters, never a tool's output). A session.ended event. Ending one already ended returns it as it is, taking `result` if it had none. Its subagents still going end with it (`parent ended`). Its claims are not released: release them, hand the issue off, or let them expire.",
102 CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and issues it and they touched; `links`, the issues and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. Anyone who can read the repository may.",
103 CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended; `issue` or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may.",
104 CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result, note, or skill for a skill it used, by name), `text` (up to 64,000 characters), and for tools `tool`. Give each a `key` and recording it again does nothing, so a retried hook records it once. `touched` says what it touched, up to 200: `{ kind, value }` with kind file (a path from the repository's root), commit, branch, pull (one it opened) or issue (one it named); each is kept once, ties the session to the pull requests and issues it names, and a file another active session touched too (outside its own parents and subagents, so siblings yes) is an overlap.detected event for both, once per pair. Entries of a session attached to a pull request show in that pull request's session too, where reviewers read why the change is what it is. Recording is a heartbeat. Returns how many were `recorded` and how many were `duplicates`.",
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'105 CoordinationOp::ClaimIssue => "Claim an open issue: say you are working on it. A claim is advisory, never a lock: anyone may claim an issue someone else has, and `others` lists the claims already on it, so you can coordinate or pick something else. It lasts `ttl_minutes` (30 unless you say, up to 240) and is renewed by claiming again or, when you give the `session_id` of one of your sessions, by that session's heartbeats. Returns your `claim`, the `others`, and `blocked_by`: each issue this one waits on, with its claims and pull requests. A new claim is a work.claimed event; one taking over a hand-off says which in `took_over`.",
106 CoordinationOp::ReleaseClaim => "Release your claim on an issue, with an optional `reason`: you stopped working on it. A work.released event. Claims also end when the issue closes, and expire when not renewed. Refused with `404` when you hold no active claim on it.",
107 CoordinationOp::HandOffIssue => "Hand an issue you claimed off: your claim ends as handed_off, with `note` saying where the work stands, for `to` (a username) or, without one, for anyone. A work.handoff_requested event, which whoever follows the issue hears; the next claim on it says it took the hand-off over. Refused with `404` when you hold no active claim on it.",
108 CoordinationOp::ListClaims => "A repository's claims, newest first, up to 100: active ones unless `state` says released, expired or handed_off, and `issue` for one issue's. Anyone who can read the repository may.",
109 CoordinationOp::GetWork => "Everything about an issue an agent needs before working on it, in one call: the `issue`, its `definition_of_done` (the items under its \"Definition of done\" heading) and the `required_checks` every pull request into the default branch must pass; active `claims`; `sessions` on it or its pull requests; its `pulls`, each with its status, required checks as they stand on its head, whether it merges cleanly, the other pull requests it overlaps and its place in the merge queue; `blocked_by` (each issue it waits on, with claims and pull requests) and `blocks` (the issues waiting on it); `decisions` recorded about it; `last_integration_failure`; and `recent`, its latest coordination events, newest first.",
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.110 CoordinationOp::StartAgentSession => "Start work on an issue in a session of its own, on the person's own Claude: g1t runs no model and bills nothing for it. Give `issue`, or `title` and `body` to open a new issue first; `instruction` adds to what the session is asked; `mode` (plan, implement or review) says what kind of work it is. With the workspace's Anthropic connection (an owner adds it under Integrations), g1t starts a Claude Managed Agents session with that key: g1t's MCP server attached with a credential for you and this repository only that lasts the session and is kept in a vault on the Anthropic account (never in the prompt), g1t's skills (work-on-issue, coordinate, fix-integration) next to the account's own, and the session, its subagents (threads), what they touched and what they reported recorded here as a g1t session (`harness` claude_managed_agents). Returns `session`, its `session_url` on g1t, `provider_session_url`, and `prompt`. With `handoff` true, or without an Anthropic connection when `handoff` is not false, nothing is started: `command` is a Claude Code command line, and `prompt` what to paste, for someone with g1t's Claude Code plugin, whose session then attaches to the issue as it starts.",
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'111 }
112 }
113
114 pub fn input(self) -> Value {
115 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
116 let id = json!({ "type": "string", "description": "The session's id (ses_…), from start_session or list_sessions." });
117 let number = json!({ "type": "integer", "description": "The issue's number." });
118 let issue = json!({ "type": "integer", "description": "The issue it works on." });
119 let pull = json!({ "type": "integer", "description": "The pull request it works on." });
120 let fields = |more: Value| -> Value {
121 let mut properties = json!({
122 "repo": repo,
123 "issue": issue,
124 "pull": pull,
125 "branch": { "type": "string", "description": "The branch it works on." },
126 "title": { "type": "string", "description": "What it is doing, in a few words. Up to 200 characters." },
127 "head_commit": { "type": "string", "description": "The commit it is at, such as after a push." },
128 });
129 if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) {
130 all.extend(more);
131 }
132 properties
133 };
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.134 let session_id = json!({ "type": "string", "description": "The session (ses_…) whose claim it is, when you have several on the issue, such as sibling subagents." });
135 let subagent = json!({
136 "agent_type": { "type": "string", "description": "For a subagent: its kind, as the harness names it (Explore, security-reviewer)." },
137 "task": { "type": "string", "description": "For a subagent: the task it was given, in a few words. Up to 200 characters." },
138 "prompt": { "type": "string", "description": "For a subagent: the prompt its parent gave it. Kept up to 4,000 characters." },
139 "model": { "type": "string", "description": "The model it runs on, when known." },
140 });
141 let with = |base: Value, more: Value| -> Value {
142 let mut base = base;
143 if let (Some(all), Value::Object(more)) = (base.as_object_mut(), more) {
144 all.extend(more);
145 }
146 base
147 };
148 let result = json!({ "type": "string", "description": "What it reported when it finished: a subagent's final message. Kept up to 8,000 characters. Never a tool's output." });
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'149 let (properties, required): (Value, &[&str]) = match self {
150 CoordinationOp::StartSession => (
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.151 with(
152 fields(json!({
153 "harness": {
154 "type": "string",
155 "enum": Harness::ALL.map(Harness::as_str),
156 "description": "The harness the session runs in.",
157 },
158 "harness_session_id": {
159 "type": "string",
160 "description": "The harness's own id for the session, such as Claude Code's session_id; for a subagent, the parent's, `/agent/` and the subagent's id. Starting again with it returns the same session.",
161 },
162 "parent_id": { "type": "string", "description": "For a subagent: the session that started it (ses_…), one of yours on the repository." },
163 })),
164 subagent.clone(),
165 ),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'166 &["repo", "harness"],
167 ),
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.168 CoordinationOp::UpdateSession => (with(fields(json!({ "id": id, "result": result })), subagent.clone()), &["repo", "id"]),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'169 CoordinationOp::EndSession => (
170 json!({
171 "repo": repo,
172 "id": id,
173 "reason": { "type": "string", "description": "Why it ended, in a few words. \"ended\" if left out." },
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.174 "result": result,
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'175 }),
176 &["repo", "id"],
177 ),
178 CoordinationOp::GetSession => (
179 json!({
180 "repo": repo,
181 "id": id,
182 "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page." },
183 }),
184 &["repo", "id"],
185 ),
186 CoordinationOp::ListSessions => (
187 json!({
188 "repo": repo,
189 "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." },
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.190 "issue": { "type": "integer", "description": "Only sessions on this issue: attached, or tied to it any other way." },
191 "pull": { "type": "integer", "description": "Only sessions on this pull request: attached, or tied to it any other way." },
192 "path": { "type": "string", "description": "Only sessions that touched this file, or any under it when it ends in `/`." },
193 "subagents": { "type": "boolean", "description": "List subagents too, each tree whole. Left out, only the sessions at the top." },
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'194 }),
195 &["repo"],
196 ),
197 CoordinationOp::RecordSessionEntries => (
198 json!({
199 "repo": repo,
200 "id": id,
201 "entries": {
202 "type": "array",
203 "description": "Up to 200 entries, oldest first.",
204 "items": {
205 "type": "object",
206 "properties": {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.207 "kind": { "type": "string", "enum": ["prompt", "message", "tool_call", "tool_result", "note", "skill"] },
208 "text": { "type": "string", "description": "For a skill, its name only." },
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'209 "tool": { "type": "string", "description": "For tool_call and tool_result: the tool's name." },
210 "commit": { "type": "string", "description": "The commit it was recorded at; the session's head if left out." },
211 "key": { "type": "string", "description": "An idempotency key, unique in the session: an entry with a key recorded before is skipped." },
212 },
213 "required": ["kind", "text"],
214 },
215 },
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.216 "touched": {
217 "type": "array",
218 "description": "What it touched, up to 200, each kept once.",
219 "items": {
220 "type": "object",
221 "properties": {
222 "kind": { "type": "string", "enum": ["file", "commit", "branch", "pull", "issue"] },
223 "value": { "type": "string", "description": "A path from the repository's root, a commit, a branch, or a number." },
224 },
225 "required": ["kind", "value"],
226 },
227 },
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'228 }),
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.229 &["repo", "id"],
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'230 ),
231 CoordinationOp::ClaimIssue => (
232 json!({
233 "repo": repo,
234 "number": number,
235 "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim." },
236 "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." },
237 "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." },
238 }),
239 &["repo", "number"],
240 ),
241 CoordinationOp::ReleaseClaim => (
242 json!({
243 "repo": repo,
244 "number": number,
245 "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." },
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.246 "session_id": session_id,
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'247 }),
248 &["repo", "number"],
249 ),
250 CoordinationOp::HandOffIssue => (
251 json!({
252 "repo": repo,
253 "number": number,
254 "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." },
255 "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." },
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.256 "session_id": session_id,
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'257 }),
258 &["repo", "number", "note"],
259 ),
260 CoordinationOp::ListClaims => (
261 json!({
262 "repo": repo,
263 "state": {
264 "type": "string",
265 "enum": ["active", "released", "expired", "handed_off"],
266 "description": "Claims in this state; active if left out.",
267 },
268 "issue": { "type": "integer", "description": "Only claims on this issue." },
269 }),
270 &["repo"],
271 ),
272 CoordinationOp::GetWork => (json!({ "repo": repo, "number": number }), &["repo", "number"]),
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.273 CoordinationOp::StartAgentSession => (
274 json!({
275 "repo": repo,
276 "issue": { "type": "integer", "description": "The issue to work on. Left out, give title (and body) to open one." },
277 "title": { "type": "string", "description": "Without issue: the title of a new issue to open and work on." },
278 "body": { "type": "string", "description": "Without issue: the new issue's description." },
279 "instruction": { "type": "string", "description": "What to do beyond what the issue says. Up to 4,000 characters." },
280 "mode": { "type": "string", "enum": ["plan", "implement", "review"], "description": "What kind of work: plan (say how, change nothing), implement (the default) or review (review its pull request)." },
281 "handoff": { "type": "boolean", "description": "true for a Claude Code command and prompt to run yourself instead of a session on the workspace's Anthropic account; false to refuse when there is no Anthropic connection. Left out, a session when the workspace can, else the hand-off." },
282 }),
283 &["repo"],
284 ),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'285 };
286 json!({ "type": "object", "properties": properties, "required": required })
287 }
288}
289
290fn text(input: &Value, key: &str) -> Option<String> {
291 input[key].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned)
292}
293
294/// A whole number given as a number, as digits, or as `#12`.
295fn number(input: &Value, key: &str) -> Option<u32> {
296 match &input[key] {
297 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
298 Value::String(digits) => digits.trim().trim_start_matches('#').parse().ok(),
299 _ => None,
300 }
301}
302
303/// An optional whole number: none when left out, refused when not one.
304fn optional_number(input: &Value, key: &str) -> std::result::Result<Option<u32>, String> {
305 match &input[key] {
306 Value::Null => Ok(None),
307 Value::String(text) if text.trim().is_empty() => Ok(None),
308 _ => number(input, key).map(Some).ok_or_else(|| format!("{key} is a number.")),
309 }
310}
311
312/// `blocked_by` for `update_issue`: issue numbers, as numbers, digits or
313/// `#12`. None when left out; refused when it holds anything else.
314pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> {
315 let items = match &input["blocked_by"] {
316 Value::Null => return Ok(None),
317 Value::Array(items) => items,
318 _ => return Err("blocked_by is a list of issue numbers.".to_owned()),
319 };
320 items
321 .iter()
322 .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not an issue number.")))
323 .collect::<std::result::Result<Vec<u32>, String>>()
324 .map(Some)
325}
326
327pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
328 let Some(repo) = repo_path(input) else {
329 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
330 };
331 let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message));
332 let (issue, pull) = match (optional_number(input, "issue"), optional_number(input, "pull")) {
333 (Ok(issue), Ok(pull)) => (issue, pull),
334 (Err(message), _) | (_, Err(message)) => return invalid(message),
335 };
336 let actor = || viewer.clone().unwrap_or_default();
337 let id = || text(input, "id").unwrap_or_default();
338 let issue_number = number(input, "number").unwrap_or_default();
339 let work = &services.work;
340 if matches!(
341 op,
342 CoordinationOp::UpdateSession | CoordinationOp::EndSession | CoordinationOp::GetSession | CoordinationOp::RecordSessionEntries
343 ) && id().is_empty()
344 {
345 return invalid("Name the session by its id (ses_…).".to_owned());
346 }
347 match op {
348 CoordinationOp::StartSession => {
349 let Some(harness) = input["harness"].as_str().and_then(Harness::parse) else {
350 let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect();
351 return invalid(format!("harness is one of {}.", names.join(", ")));
352 };
353 let args = StartSessionArgs {
354 actor: actor(),
355 repo,
356 harness,
357 harness_session_id: text(input, "harness_session_id"),
358 issue,
359 pull,
360 branch: text(input, "branch"),
361 title: text(input, "title"),
362 head_commit: text(input, "head_commit"),
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.363 parent_id: text(input, "parent_id"),
364 agent_type: text(input, "agent_type"),
365 task: text(input, "task"),
366 prompt: text(input, "prompt"),
367 model: text(input, "model"),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'368 };
369 g1t_kit::call(work, "start_session", &args).await
370 }
371 CoordinationOp::UpdateSession => {
372 let args = UpdateSessionArgs {
373 actor: actor(),
374 repo,
375 id: id(),
376 issue,
377 pull,
378 branch: text(input, "branch"),
379 head_commit: text(input, "head_commit"),
380 title: text(input, "title"),
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.381 agent_type: text(input, "agent_type"),
382 task: text(input, "task"),
383 prompt: text(input, "prompt"),
384 model: text(input, "model"),
385 result: text(input, "result"),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'386 };
387 g1t_kit::call(work, "update_session", &args).await
388 }
389 CoordinationOp::EndSession => {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.390 let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason"), result: text(input, "result") };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'391 g1t_kit::call(work, "end_session", &args).await
392 }
393 CoordinationOp::GetSession => {
394 let args = WorkSessionArgs { viewer: viewer.clone(), repo, id: id(), after_seq: number(input, "after").unwrap_or_default() };
395 g1t_kit::call(work, "get_work_session", &args).await
396 }
397 CoordinationOp::ListSessions => {
398 let state = match text(input, "state") {
399 None => None,
400 Some(state) => match SessionState::parse(&state) {
401 Some(state) => Some(state),
402 None => return invalid(format!("state is active, idle or ended, not {state}.")),
403 },
404 };
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.405 let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true");
406 let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, issue, pull, subagents, path: text(input, "path") };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'407 g1t_kit::call(work, "list_work_sessions", &args).await
408 }
409 CoordinationOp::RecordSessionEntries => {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.410 let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(match &input["entries"] {
411 Value::Null => json!([]),
412 entries => entries.clone(),
413 }) {
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'414 Ok(entries) => entries,
415 Err(_) => {
416 return invalid(
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.417 "entries is a list of { kind, text }, kind one of prompt, message, tool_call, tool_result, note or skill, with tool, commit and key as needed.".to_owned(),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'418 );
419 }
420 };
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.421 let touched: Vec<NewTouch> = match serde_json::from_value(match &input["touched"] {
422 Value::Null => json!([]),
423 touched => touched.clone(),
424 }) {
425 Ok(touched) => touched,
426 Err(_) => return invalid("touched is a list of { kind, value }, kind one of file, commit, branch, pull or issue.".to_owned()),
427 };
428 let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries, touched };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'429 g1t_kit::call(work, "record_session_entries", &args).await
430 }
431 CoordinationOp::ClaimIssue => {
432 let ttl_minutes = match optional_number(input, "ttl_minutes") {
433 Ok(minutes) => minutes,
434 Err(message) => return invalid(message),
435 };
436 let args = ClaimArgs {
437 actor: actor(),
438 repo,
439 number: issue_number,
440 session_id: text(input, "session_id"),
441 note: text(input, "note"),
442 ttl_minutes,
443 };
444 g1t_kit::call(work, "claim_issue", &args).await
445 }
446 CoordinationOp::ReleaseClaim => {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.447 let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason"), session_id: text(input, "session_id") };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'448 g1t_kit::call(work, "release_claim", &args).await
449 }
450 CoordinationOp::HandOffIssue => {
451 let Some(note) = text(input, "note") else {
452 return invalid("Say where the work stands in note, for whoever picks it up.".to_owned());
453 };
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.454 let args = HandOffArgs { actor: actor(), repo, number: issue_number, to: text(input, "to"), note, session_id: text(input, "session_id") };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'455 g1t_kit::call(work, "hand_off_issue", &args).await
456 }
457 CoordinationOp::ListClaims => {
458 let state = match text(input, "state") {
459 None => None,
460 Some(state) => match ClaimState::parse(&state) {
461 Some(state) => Some(state),
462 None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")),
463 },
464 };
465 let args = ListClaimsArgs { viewer: viewer.clone(), repo, state, issue };
466 g1t_kit::call(work, "list_claims", &args).await
467 }
468 CoordinationOp::GetWork => {
469 let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number };
470 g1t_kit::call(work, "get_work", &args).await
471 }
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.472 CoordinationOp::StartAgentSession => {
473 let mode = match text(input, "mode") {
474 None => None,
475 Some(mode) => match AgentMode::parse(&mode) {
476 Some(mode) => Some(mode),
477 None => return invalid(format!("mode is plan, implement or review, not {mode}.")),
478 },
479 };
480 if issue.is_none() && text(input, "title").is_none() {
481 return invalid("Name the issue, or give a title (and body) for a new one.".to_owned());
482 }
483 let handoff = match &input["handoff"] {
484 Value::Bool(handoff) => Some(*handoff),
485 Value::String(text) if text == "true" => Some(true),
486 Value::String(text) if text == "false" => Some(false),
487 _ => None,
488 };
489 let args = StartAgentSessionArgs {
490 actor: actor(),
491 repo,
492 issue,
493 title: text(input, "title"),
494 body: text(input, "body"),
495 instruction: text(input, "instruction"),
496 mode,
497 handoff,
498 };
499 g1t_kit::call(&services.integrations, "start_agent_session", &args).await
500 }
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::operations::Op;
508 use g1t_contracts::scopes::{Level, Scope, scope_for};
509
510 #[test]
511 fn each_operation_is_described_scoped_and_listed() {
512 for op in CoordinationOp::ALL {
513 assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name());
514 assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op)));
515 assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name());
516 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
517 let scope = scope_for(op.name()).unwrap();
518 assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name());
519 let sessions = op.name().contains("session");
520 let expected = match (sessions, op.reads()) {
521 (true, true) => Scope::PullRequestsRead,
522 (true, false) => Scope::PullRequestsWrite,
523 (false, true) => Scope::IssuesRead,
524 (false, false) => Scope::IssuesWrite,
525 };
526 assert_eq!(scope, expected, "{}", op.name());
527 }
528 }
529
530 #[test]
531 fn routes_reach_each_operation_with_its_input() {
532 let resolve = |method: &str, path: &str, query: &[(&str, &str)], body: Value| {
533 let query: Vec<(String, String)> = query.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
534 let (route, input) = crate::rest::resolve(method, path, &query, body).unwrap();
535 (route.op, input)
536 };
537 let coordination = |op| Op::Coordination(op);
538 let (op, input) = resolve("POST", "/repos/acme/web/sessions", &[], json!({ "harness": "claude_code" }));
539 assert_eq!(op, coordination(CoordinationOp::StartSession));
540 assert_eq!(input, json!({ "harness": "claude_code", "repo": "acme/web" }));
541 let (op, input) = resolve("PATCH", "/repos/acme/web/sessions/ses_1", &[], json!({}));
542 assert_eq!(op, coordination(CoordinationOp::UpdateSession));
543 assert_eq!(input, json!({ "id": "ses_1", "repo": "acme/web" }));
544 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/end", &[], json!({}));
545 assert_eq!(op, coordination(CoordinationOp::EndSession));
546 let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null);
547 assert_eq!(op, coordination(CoordinationOp::GetSession));
548 assert_eq!(input["after"], "40");
549 let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("issue", "4")], Value::Null);
550 assert_eq!(op, coordination(CoordinationOp::ListSessions));
551 assert_eq!(input, json!({ "state": "idle", "issue": "4", "repo": "acme/web" }));
552 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] }));
553 assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries));
554 let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" }));
555 assert_eq!(op, coordination(CoordinationOp::ClaimIssue));
556 assert_eq!(input, json!({ "note": "on it", "number": 4, "repo": "acme/web" }));
557 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({}));
558 assert_eq!(op, coordination(CoordinationOp::ReleaseClaim));
559 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" }));
560 assert_eq!(op, coordination(CoordinationOp::HandOffIssue));
561 let (op, _) = resolve("GET", "/repos/acme/web/claims", &[("state", "active")], Value::Null);
562 assert_eq!(op, coordination(CoordinationOp::ListClaims));
563 let (op, input) = resolve("GET", "/repos/acme/web/issues/4/work", &[], Value::Null);
564 assert_eq!(op, coordination(CoordinationOp::GetWork));
565 assert_eq!(input, json!({ "number": 4, "repo": "acme/web" }));
566 }
567
568 #[test]
569 fn mcp_actions_reach_each_operation() {
570 use crate::tools::{Tool, resolve};
571 let call = |tool: &str, action: &str| resolve(Tool::by_name(tool).unwrap(), &json!({ "action": action, "repo": "a/b", "id": "ses_1", "number": 4, "harness": "codex", "entries": [], "note": "x" }));
572 assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession)));
573 assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession)));
574 assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries)));
575 assert_eq!(call("issue", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimIssue)));
576 assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork)));
577 }
578
579 #[test]
580 fn numbers_come_as_numbers_digits_or_with_a_hash() {
581 assert_eq!(blocked_by(&json!({})), Ok(None));
582 assert_eq!(blocked_by(&json!({ "blocked_by": [3, "4", "#5"] })), Ok(Some(vec![3, 4, 5])));
583 assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![])));
584 assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err());
585 assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err());
586 assert_eq!(optional_number(&json!({ "issue": "" }), "issue"), Ok(None));
587 assert!(optional_number(&json!({ "issue": "soon" }), "issue").is_err());
588 }
589}

This file's history is long; its oldest lines are credited to the oldest commit read.