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
606 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-aef0bb4ceda89e542'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`. For work that has no issue yet, leave out `number` and give `title` (and `body`): the issue is opened first (an issue.opened event), then claimed, the session named by `session_id` is attached to it, and `issue` is the issue opened. The session is checked first, so a claim it would refuse opens nothing.",
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'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,
Merge branch 'worktree-agent-aef0bb4ceda89e542'234 "number": { "type": "integer", "description": "The issue's number. Left out, give title (and body) to open one and claim it." },
235 "title": { "type": "string", "description": "Without number: the title of a new issue to open and claim." },
236 "body": { "type": "string", "description": "Without number: the new issue's description." },
237 "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim. With title, it is attached to the new issue." },
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'238 "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." },
239 "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." },
240 }),
Merge branch 'worktree-agent-aef0bb4ceda89e542'241 &["repo"],
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'242 ),
243 CoordinationOp::ReleaseClaim => (
244 json!({
245 "repo": repo,
246 "number": number,
247 "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.248 "session_id": session_id,
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'249 }),
250 &["repo", "number"],
251 ),
252 CoordinationOp::HandOffIssue => (
253 json!({
254 "repo": repo,
255 "number": number,
256 "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." },
257 "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.258 "session_id": session_id,
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'259 }),
260 &["repo", "number", "note"],
261 ),
262 CoordinationOp::ListClaims => (
263 json!({
264 "repo": repo,
265 "state": {
266 "type": "string",
267 "enum": ["active", "released", "expired", "handed_off"],
268 "description": "Claims in this state; active if left out.",
269 },
270 "issue": { "type": "integer", "description": "Only claims on this issue." },
271 }),
272 &["repo"],
273 ),
274 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.275 CoordinationOp::StartAgentSession => (
276 json!({
277 "repo": repo,
278 "issue": { "type": "integer", "description": "The issue to work on. Left out, give title (and body) to open one." },
279 "title": { "type": "string", "description": "Without issue: the title of a new issue to open and work on." },
280 "body": { "type": "string", "description": "Without issue: the new issue's description." },
281 "instruction": { "type": "string", "description": "What to do beyond what the issue says. Up to 4,000 characters." },
282 "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)." },
283 "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." },
284 }),
285 &["repo"],
286 ),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'287 };
288 json!({ "type": "object", "properties": properties, "required": required })
289 }
290}
291
292fn text(input: &Value, key: &str) -> Option<String> {
293 input[key].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned)
294}
295
296/// A whole number given as a number, as digits, or as `#12`.
297fn number(input: &Value, key: &str) -> Option<u32> {
298 match &input[key] {
299 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
300 Value::String(digits) => digits.trim().trim_start_matches('#').parse().ok(),
301 _ => None,
302 }
303}
304
305/// An optional whole number: none when left out, refused when not one.
306fn optional_number(input: &Value, key: &str) -> std::result::Result<Option<u32>, String> {
307 match &input[key] {
308 Value::Null => Ok(None),
309 Value::String(text) if text.trim().is_empty() => Ok(None),
310 _ => number(input, key).map(Some).ok_or_else(|| format!("{key} is a number.")),
311 }
312}
313
314/// `blocked_by` for `update_issue`: issue numbers, as numbers, digits or
315/// `#12`. None when left out; refused when it holds anything else.
316pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> {
317 let items = match &input["blocked_by"] {
318 Value::Null => return Ok(None),
319 Value::Array(items) => items,
320 _ => return Err("blocked_by is a list of issue numbers.".to_owned()),
321 };
322 items
323 .iter()
324 .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not an issue number.")))
325 .collect::<std::result::Result<Vec<u32>, String>>()
326 .map(Some)
327}
328
329pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
330 let Some(repo) = repo_path(input) else {
331 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
332 };
333 let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message));
334 let (issue, pull) = match (optional_number(input, "issue"), optional_number(input, "pull")) {
335 (Ok(issue), Ok(pull)) => (issue, pull),
336 (Err(message), _) | (_, Err(message)) => return invalid(message),
337 };
338 let actor = || viewer.clone().unwrap_or_default();
339 let id = || text(input, "id").unwrap_or_default();
340 let issue_number = number(input, "number").unwrap_or_default();
341 let work = &services.work;
342 if matches!(
343 op,
344 CoordinationOp::UpdateSession | CoordinationOp::EndSession | CoordinationOp::GetSession | CoordinationOp::RecordSessionEntries
345 ) && id().is_empty()
346 {
347 return invalid("Name the session by its id (ses_…).".to_owned());
348 }
349 match op {
350 CoordinationOp::StartSession => {
351 let Some(harness) = input["harness"].as_str().and_then(Harness::parse) else {
352 let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect();
353 return invalid(format!("harness is one of {}.", names.join(", ")));
354 };
355 let args = StartSessionArgs {
356 actor: actor(),
357 repo,
358 harness,
359 harness_session_id: text(input, "harness_session_id"),
360 issue,
361 pull,
362 branch: text(input, "branch"),
363 title: text(input, "title"),
364 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.365 parent_id: text(input, "parent_id"),
366 agent_type: text(input, "agent_type"),
367 task: text(input, "task"),
368 prompt: text(input, "prompt"),
369 model: text(input, "model"),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'370 };
371 g1t_kit::call(work, "start_session", &args).await
372 }
373 CoordinationOp::UpdateSession => {
374 let args = UpdateSessionArgs {
375 actor: actor(),
376 repo,
377 id: id(),
378 issue,
379 pull,
380 branch: text(input, "branch"),
381 head_commit: text(input, "head_commit"),
382 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.383 agent_type: text(input, "agent_type"),
384 task: text(input, "task"),
385 prompt: text(input, "prompt"),
386 model: text(input, "model"),
387 result: text(input, "result"),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'388 };
389 g1t_kit::call(work, "update_session", &args).await
390 }
391 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.392 let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason"), result: text(input, "result") };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'393 g1t_kit::call(work, "end_session", &args).await
394 }
395 CoordinationOp::GetSession => {
396 let args = WorkSessionArgs { viewer: viewer.clone(), repo, id: id(), after_seq: number(input, "after").unwrap_or_default() };
397 g1t_kit::call(work, "get_work_session", &args).await
398 }
399 CoordinationOp::ListSessions => {
400 let state = match text(input, "state") {
401 None => None,
402 Some(state) => match SessionState::parse(&state) {
403 Some(state) => Some(state),
404 None => return invalid(format!("state is active, idle or ended, not {state}.")),
405 },
406 };
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.407 let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true");
408 let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, issue, pull, subagents, path: text(input, "path") };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'409 g1t_kit::call(work, "list_work_sessions", &args).await
410 }
411 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.412 let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(match &input["entries"] {
413 Value::Null => json!([]),
414 entries => entries.clone(),
415 }) {
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'416 Ok(entries) => entries,
417 Err(_) => {
418 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.419 "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'420 );
421 }
422 };
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.423 let touched: Vec<NewTouch> = match serde_json::from_value(match &input["touched"] {
424 Value::Null => json!([]),
425 touched => touched.clone(),
426 }) {
427 Ok(touched) => touched,
428 Err(_) => return invalid("touched is a list of { kind, value }, kind one of file, commit, branch, pull or issue.".to_owned()),
429 };
430 let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries, touched };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'431 g1t_kit::call(work, "record_session_entries", &args).await
432 }
433 CoordinationOp::ClaimIssue => {
434 let ttl_minutes = match optional_number(input, "ttl_minutes") {
435 Ok(minutes) => minutes,
436 Err(message) => return invalid(message),
437 };
Merge branch 'worktree-agent-aef0bb4ceda89e542'438 let title = text(input, "title");
439 if issue_number == 0 && title.is_none() {
440 return invalid("Name the issue by its number, or give a title (and body) to open one.".to_owned());
441 }
442 if issue_number != 0 && title.is_some() {
443 return invalid("Give the issue's number or a title for a new one, not both.".to_owned());
444 }
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'445 let args = ClaimArgs {
446 actor: actor(),
447 repo,
448 number: issue_number,
Merge branch 'worktree-agent-aef0bb4ceda89e542'449 title,
450 body: text(input, "body"),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'451 session_id: text(input, "session_id"),
452 note: text(input, "note"),
453 ttl_minutes,
454 };
455 g1t_kit::call(work, "claim_issue", &args).await
456 }
457 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.458 let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason"), session_id: text(input, "session_id") };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'459 g1t_kit::call(work, "release_claim", &args).await
460 }
461 CoordinationOp::HandOffIssue => {
462 let Some(note) = text(input, "note") else {
463 return invalid("Say where the work stands in note, for whoever picks it up.".to_owned());
464 };
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.465 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'466 g1t_kit::call(work, "hand_off_issue", &args).await
467 }
468 CoordinationOp::ListClaims => {
469 let state = match text(input, "state") {
470 None => None,
471 Some(state) => match ClaimState::parse(&state) {
472 Some(state) => Some(state),
473 None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")),
474 },
475 };
476 let args = ListClaimsArgs { viewer: viewer.clone(), repo, state, issue };
477 g1t_kit::call(work, "list_claims", &args).await
478 }
479 CoordinationOp::GetWork => {
480 let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number };
481 g1t_kit::call(work, "get_work", &args).await
482 }
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.483 CoordinationOp::StartAgentSession => {
484 let mode = match text(input, "mode") {
485 None => None,
486 Some(mode) => match AgentMode::parse(&mode) {
487 Some(mode) => Some(mode),
488 None => return invalid(format!("mode is plan, implement or review, not {mode}.")),
489 },
490 };
491 if issue.is_none() && text(input, "title").is_none() {
492 return invalid("Name the issue, or give a title (and body) for a new one.".to_owned());
493 }
494 let handoff = match &input["handoff"] {
495 Value::Bool(handoff) => Some(*handoff),
496 Value::String(text) if text == "true" => Some(true),
497 Value::String(text) if text == "false" => Some(false),
498 _ => None,
499 };
500 let args = StartAgentSessionArgs {
501 actor: actor(),
502 repo,
503 issue,
504 title: text(input, "title"),
505 body: text(input, "body"),
506 instruction: text(input, "instruction"),
507 mode,
508 handoff,
509 };
510 g1t_kit::call(&services.integrations, "start_agent_session", &args).await
511 }
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::operations::Op;
519 use g1t_contracts::scopes::{Level, Scope, scope_for};
520
521 #[test]
522 fn each_operation_is_described_scoped_and_listed() {
523 for op in CoordinationOp::ALL {
524 assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name());
525 assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op)));
526 assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name());
527 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
528 let scope = scope_for(op.name()).unwrap();
529 assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name());
530 let sessions = op.name().contains("session");
531 let expected = match (sessions, op.reads()) {
532 (true, true) => Scope::PullRequestsRead,
533 (true, false) => Scope::PullRequestsWrite,
534 (false, true) => Scope::IssuesRead,
535 (false, false) => Scope::IssuesWrite,
536 };
537 assert_eq!(scope, expected, "{}", op.name());
538 }
539 }
540
541 #[test]
542 fn routes_reach_each_operation_with_its_input() {
543 let resolve = |method: &str, path: &str, query: &[(&str, &str)], body: Value| {
544 let query: Vec<(String, String)> = query.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
545 let (route, input) = crate::rest::resolve(method, path, &query, body).unwrap();
546 (route.op, input)
547 };
548 let coordination = |op| Op::Coordination(op);
549 let (op, input) = resolve("POST", "/repos/acme/web/sessions", &[], json!({ "harness": "claude_code" }));
550 assert_eq!(op, coordination(CoordinationOp::StartSession));
551 assert_eq!(input, json!({ "harness": "claude_code", "repo": "acme/web" }));
552 let (op, input) = resolve("PATCH", "/repos/acme/web/sessions/ses_1", &[], json!({}));
553 assert_eq!(op, coordination(CoordinationOp::UpdateSession));
554 assert_eq!(input, json!({ "id": "ses_1", "repo": "acme/web" }));
555 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/end", &[], json!({}));
556 assert_eq!(op, coordination(CoordinationOp::EndSession));
557 let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null);
558 assert_eq!(op, coordination(CoordinationOp::GetSession));
559 assert_eq!(input["after"], "40");
560 let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("issue", "4")], Value::Null);
561 assert_eq!(op, coordination(CoordinationOp::ListSessions));
562 assert_eq!(input, json!({ "state": "idle", "issue": "4", "repo": "acme/web" }));
563 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] }));
564 assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries));
565 let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" }));
566 assert_eq!(op, coordination(CoordinationOp::ClaimIssue));
567 assert_eq!(input, json!({ "note": "on it", "number": 4, "repo": "acme/web" }));
Merge branch 'worktree-agent-aef0bb4ceda89e542'568 // Work with no issue yet: one is opened and claimed.
569 let (op, input) = resolve("POST", "/repos/acme/web/claims", &[], json!({ "title": "Fix previews" }));
570 assert_eq!(op, coordination(CoordinationOp::ClaimIssue));
571 assert_eq!(input, json!({ "title": "Fix previews", "repo": "acme/web" }));
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'572 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({}));
573 assert_eq!(op, coordination(CoordinationOp::ReleaseClaim));
574 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" }));
575 assert_eq!(op, coordination(CoordinationOp::HandOffIssue));
576 let (op, _) = resolve("GET", "/repos/acme/web/claims", &[("state", "active")], Value::Null);
577 assert_eq!(op, coordination(CoordinationOp::ListClaims));
578 let (op, input) = resolve("GET", "/repos/acme/web/issues/4/work", &[], Value::Null);
579 assert_eq!(op, coordination(CoordinationOp::GetWork));
580 assert_eq!(input, json!({ "number": 4, "repo": "acme/web" }));
581 }
582
583 #[test]
584 fn mcp_actions_reach_each_operation() {
585 use crate::tools::{Tool, resolve};
586 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" }));
587 assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession)));
588 assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession)));
589 assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries)));
590 assert_eq!(call("issue", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimIssue)));
Merge branch 'worktree-agent-aef0bb4ceda89e542'591 let claim_new = resolve(Tool::by_name("issue").unwrap(), &json!({ "action": "claim", "repo": "a/b", "title": "Fix previews" }));
592 assert_eq!(claim_new, Ok(Op::Coordination(CoordinationOp::ClaimIssue)));
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'593 assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork)));
594 }
595
596 #[test]
597 fn numbers_come_as_numbers_digits_or_with_a_hash() {
598 assert_eq!(blocked_by(&json!({})), Ok(None));
599 assert_eq!(blocked_by(&json!({ "blocked_by": [3, "4", "#5"] })), Ok(Some(vec![3, 4, 5])));
600 assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![])));
601 assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err());
602 assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err());
603 assert_eq!(optional_number(&json!({ "issue": "" }), "issue"), Ok(None));
604 assert!(optional_number(&json!({ "issue": "soon" }), "issue").is_err());
605 }
606}

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