Skip to content

Commit

Merge branch 'worktree-agent-ad531323a21f4c975'

syntaqxcommitted Parentsb051eb6f867867Browse files
26 files+1400−2210 files not counted0/26 viewed
+231−0
1+//! `list_events`: a repository's timeline, and following it.
2+//!
3+//! Without `after` it is the timeline, newest first, paged back with
4+//! `before`. With `after` (an event id) it is what the log wrote after that
5+//! event, oldest first, and the last event's id is the cursor for the next
6+//! call. With `after` and `wait`, a call that finds nothing holds on until
7+//! something matching is written or `wait` seconds pass, asking the events
8+//! service about once a second, so an agent with no public address can
9+//! follow a repository with one request at a time instead of a webhook.
10+//!
11+//! Holding a request costs a Worker wall time, not CPU time: between polls
12+//! it awaits a timer, which is not billed or limited as CPU. Each poll is
13+//! one subrequest, so a call makes at most `MAX_WAIT_SECONDS` + 1 of them,
14+//! well under the per-request limit, and the hold stays far below the time
15+//! clients and proxies give an idle response.
16+
17+use g1t_contracts::events::{Event, ListArgs};
18+use serde_json::Value;
19+use worker::{Fetcher, Result};
20+
21+/// The longest a call holds on, in seconds.
22+pub const MAX_WAIT_SECONDS: u64 = 25;
23+/// How often a held call asks again.
24+const POLL_MS: u64 = 1_000;
25+/// The most events one call returns; the events service's own page.
26+const MAX_LIMIT: u32 = 200;
27+
28+/// What a `list_events` call asks for, read from its input.
29+#[derive(Debug, Default, PartialEq)]
30+pub struct Asked {
31+ pub after: Option<String>,
32+ pub before: Option<String>,
33+ pub types: Vec<String>,
34+ pub numbers: Vec<u32>,
35+ pub since: Option<String>,
36+ pub limit: Option<u32>,
37+ /// Milliseconds to hold on; zero without `after`.
38+ pub wait_ms: u64,
39+}
40+
41+/// A list given as an array or as one comma-separated string.
42+fn list(value: &Value) -> Vec<String> {
43+ let items: Vec<String> = match value {
44+ Value::String(text) => text.split(',').map(str::to_owned).collect(),
45+ Value::Array(items) => items
46+ .iter()
47+ .map(|item| match item {
48+ Value::String(text) => text.clone(),
49+ other => other.to_string(),
50+ })
51+ .collect(),
52+ Value::Number(number) => vec![number.to_string()],
53+ _ => Vec::new(),
54+ };
55+ items
56+ .into_iter()
57+ .map(|item| item.trim().trim_start_matches('#').to_owned())
58+ .filter(|item| !item.is_empty())
59+ .collect()
60+}
61+
62+fn text(input: &Value, key: &str) -> Option<String> {
63+ input[key]
64+ .as_str()
65+ .map(str::trim)
66+ .filter(|value| !value.is_empty())
67+ .map(str::to_owned)
68+}
69+
70+fn whole(value: &Value) -> Option<u64> {
71+ match value {
72+ Value::Number(number) => number.as_u64().or_else(|| number.as_f64().filter(|n| *n >= 0.0).map(|n| n as u64)),
73+ Value::String(digits) => digits.trim().parse().ok(),
74+ _ => None,
75+ }
76+}
77+
78+/// Reads a call's input, or says what is wrong with it.
79+pub fn asked(input: &Value) -> std::result::Result<Asked, String> {
80+ let mut numbers = Vec::new();
81+ for item in list(&input["numbers"]) {
82+ match item.parse::<u32>() {
83+ Ok(number) if number > 0 => numbers.push(number),
84+ _ => return Err(format!("numbers are issue and pull request numbers, and {item} is not one.")),
85+ }
86+ }
87+ numbers.sort_unstable();
88+ numbers.dedup();
89+ let mut types = list(&input["types"]);
90+ types.sort();
91+ types.dedup();
92+ let limit = match &input["limit"] {
93+ Value::Null => None,
94+ value => match whole(value) {
95+ Some(limit) if limit > 0 => Some(u32::try_from(limit).unwrap_or(MAX_LIMIT).min(MAX_LIMIT)),
96+ _ => return Err("limit is a whole number from 1 to 200.".to_owned()),
97+ },
98+ };
99+ let after = text(input, "after");
100+ let wait = match &input["wait"] {
101+ Value::Null => 0,
102+ value => match whole(value) {
103+ Some(seconds) => seconds.min(MAX_WAIT_SECONDS),
104+ None => return Err(format!("wait is a number of seconds, at most {MAX_WAIT_SECONDS}.")),
105+ },
106+ };
107+ let before = text(input, "before");
108+ if after.is_some() && before.is_some() {
109+ return Err("Give after to follow the timeline or before to page back through it, not both.".to_owned());
110+ }
111+ Ok(Asked {
112+ wait_ms: if after.is_some() { wait * 1_000 } else { 0 },
113+ after,
114+ before,
115+ types,
116+ numbers,
117+ since: text(input, "since"),
118+ limit,
119+ })
120+}
121+
122+impl Asked {
123+ fn args(&self, repo_id: &str) -> ListArgs {
124+ ListArgs {
125+ repo_id: Some(repo_id.to_owned()),
126+ types: self.types.clone(),
127+ before: self.before.clone(),
128+ after: self.after.clone(),
129+ numbers: self.numbers.clone(),
130+ since: self.since.clone(),
131+ limit: self.limit,
132+ ..ListArgs::default()
133+ }
134+ }
135+}
136+
137+/// How long to sleep before asking again, at `now`, for a call that holds
138+/// on until `deadline`: `None` when it is time to answer with nothing.
139+/// The last poll falls on the deadline itself.
140+pub fn next_poll(now: u64, deadline: u64) -> Option<u64> {
141+ if now >= deadline {
142+ None
143+ } else {
144+ Some(POLL_MS.min(deadline - now))
145+ }
146+}
147+
148+/// The events of the repository `repo_id` as asked, holding on for them
149+/// when asked to.
150+pub async fn list_events(events: &Fetcher, repo_id: &str, asked: &Asked) -> Result<Vec<Event>> {
151+ let args = asked.args(repo_id);
152+ let deadline = g1t_kit::now_ms() + asked.wait_ms;
153+ loop {
154+ let found: Vec<Event> = g1t_kit::call(events, "list", &args).await?;
155+ if !found.is_empty() {
156+ return Ok(found);
157+ }
158+ match next_poll(g1t_kit::now_ms(), deadline) {
159+ Some(ms) => worker::Delay::from(std::time::Duration::from_millis(ms)).await,
160+ None => return Ok(found),
161+ }
162+ }
163+}
164+
165+#[cfg(test)]
166+mod tests {
167+ use super::*;
168+ use serde_json::json;
169+
170+ #[test]
171+ fn lists_come_as_commas_or_arrays() {
172+ let a = asked(&json!({
173+ "types": "work.claimed, integration.failed,,work.claimed",
174+ "numbers": "12,#7, 12",
175+ }))
176+ .unwrap();
177+ assert_eq!(a.types, vec!["integration.failed", "work.claimed"]);
178+ assert_eq!(a.numbers, vec![7, 12]);
179+ let b = asked(&json!({ "types": ["push"], "numbers": [3, "4"] })).unwrap();
180+ assert_eq!(b.types, vec!["push"]);
181+ assert_eq!(b.numbers, vec![3, 4]);
182+ assert!(asked(&json!({ "numbers": "seven" })).is_err());
183+ assert!(asked(&json!({ "numbers": [0] })).is_err());
184+ }
185+
186+ #[test]
187+ fn wait_holds_only_a_follow_and_never_past_25_seconds() {
188+ assert_eq!(asked(&json!({ "wait": 10 })).unwrap().wait_ms, 0, "nothing to wait for without after");
189+ assert_eq!(asked(&json!({ "after": "evt_1", "wait": "10" })).unwrap().wait_ms, 10_000);
190+ assert_eq!(asked(&json!({ "after": "evt_1", "wait": 600 })).unwrap().wait_ms, 25_000);
191+ assert_eq!(asked(&json!({ "after": "evt_1" })).unwrap().wait_ms, 0);
192+ assert!(asked(&json!({ "after": "evt_1", "wait": "soon" })).is_err());
193+ assert!(asked(&json!({ "after": "evt_1", "wait": -1 })).is_err());
194+ }
195+
196+ #[test]
197+ fn limits_are_kept_to_a_page() {
198+ assert_eq!(asked(&json!({ "limit": "500" })).unwrap().limit, Some(200));
199+ assert_eq!(asked(&json!({ "limit": 20 })).unwrap().limit, Some(20));
200+ assert_eq!(asked(&json!({})).unwrap().limit, None);
201+ assert!(asked(&json!({ "limit": 0 })).is_err());
202+ }
203+
204+ #[test]
205+ fn after_and_before_do_not_mix() {
206+ assert!(asked(&json!({ "after": "evt_2", "before": "evt_1" })).is_err());
207+ let a = asked(&json!({ "after": "evt_2", "since": "2026-10-12T00:00:00Z" })).unwrap();
208+ let args = a.args("rep_1");
209+ assert_eq!(args.after.as_deref(), Some("evt_2"));
210+ assert_eq!(args.since.as_deref(), Some("2026-10-12T00:00:00Z"));
211+ assert_eq!(args.repo_id.as_deref(), Some("rep_1"));
212+ }
213+
214+ #[test]
215+ fn polls_come_about_once_a_second_and_the_last_on_the_deadline() {
216+ let start = 1_000_000;
217+ let deadline = start + 25_000;
218+ let mut now = start;
219+ let mut polls = 1; // the first, before any sleep
220+ while let Some(ms) = next_poll(now, deadline) {
221+ assert!(ms > 0 && ms <= 1_000);
222+ now += ms;
223+ polls += 1;
224+ }
225+ assert_eq!(now, deadline);
226+ assert_eq!(polls, 26, "one call makes at most 26 polls");
227+ assert_eq!(next_poll(5, 5), None);
228+ assert_eq!(next_poll(5, 4), None, "a slow poll past the deadline answers");
229+ assert_eq!(next_poll(0, 300), Some(300));
230+ }
231+}
+1−0
2525 mod oidc;
2626 mod packages;
2727 mod folios;
28+mod follow;
2829 mod people;
2930 mod openapi;
3031 mod pins;
+27−13
1111 };
1212 use g1t_contracts::codeowners::CodeOwnersErrorsArgs;
1313 use g1t_contracts::identity::AgentScope;
14−use g1t_contracts::events::{Event, ListArgs as ListEventsArgs};
14+use g1t_contracts::events::Event;
1515 use g1t_contracts::identity::{CreateWorkspaceArgs, UpdateWorkspaceArgs, Workspace};
1616 use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath};
1717 use g1t_contracts::teams::{
14661466 "Land a pull request on its base, the branch it merges into (the default branch unless it names another). Merging needs the Write role or higher, and only once it is marked ready and it meets every rule that holds for its base (see rules and required_checks on get_pull_request: approvals, checks, deployments, merge windows and the rest, from the repository's and its workspace's rulesets); the refusal names the first rule not met. With ignore_checks, someone who may merge can bypass required checks where the rule allows it; with bypass_rules, someone a ruleset lists as a bypass actor merges past its rules, and it is recorded. Merging into the default branch resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded; merging into another branch leaves the issue open. Where the repository has a merge queue, a pull request into the default branch joins the queue instead of landing at once. If its base has moved since the pull request was opened, it is brought up to date first and lands when that is done; a repository that requires pull requests into its default branch to be up to date refuses instead, so pull the base into its fork or branch, push, and merge again. Check status in the result to see whether it has landed."
14671467 }
14681468 Op::ListEvents => {
1469− "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first."
1469+ "The timeline of a repository: pushes, issues, pull requests, comments, sessions, claims and integration results, newest first; page back with before. To follow it, give after (an event id): the events written after that one come oldest first, and the last one's id is the next call's after. With after and wait (seconds, at most 25), a call that finds nothing holds on until a matching event is written or the wait ends, then returns what it has, perhaps nothing. Narrow it with types, numbers (issues and pull requests, matching events about them and comments on them), since and limit (50 by default, at most 200)."
14701470 }
14711471 Op::ListIntegrations => {
14721472 "A workspace's integrations: its AI providers, the alert sources that open issues (Sentry, Datadog, webhooks), and the trackers whose tickets agents can read (Jira, Linear). Secrets are never returned. Members only."
24322432 Op::ListEvents => object(
24332433 json!({
24342434 "repo": repo_schema(),
2435− "before": { "type": "string", "description": "Event id to page back from." },
2435+ "before": { "type": "string", "description": "Event id to page back from: older events, newest first." },
2436+ "after": { "type": "string", "description": "Event id to follow from: the events written after it, oldest first. Not with before." },
2437+ "types": {
2438+ "type": "array",
2439+ "items": { "type": "string" },
2440+ "description": "Only these event types (work.claimed, integration.failed, ...). In a query string, a comma-separated list.",
2441+ },
2442+ "numbers": {
2443+ "type": "array",
2444+ "items": { "type": "integer" },
2445+ "description": "Only events about these issues or pull requests, and comments, reviews and links on them. In a query string, a comma-separated list.",
2446+ },
2447+ "since": { "type": "string", "description": "Only events at or after this time (RFC 3339)." },
2448+ "limit": { "type": "integer", "minimum": 1, "maximum": 200, "description": "How many events at most; 50 by default." },
2449+ "wait": {
2450+ "type": "integer",
2451+ "minimum": 0,
2452+ "maximum": 25,
2453+ "description": "With after: seconds to hold on for a matching event when there is none yet. At most 25.",
2454+ },
24362455 }),
24372456 &["repo"],
24382457 ),
47504769 .await
47514770 }
47524771 Op::ListEvents => {
4772+ let asked = match crate::follow::asked(input) {
4773+ Ok(asked) => asked,
4774+ Err(message) => return failed(FailureCode::Invalid, &message),
4775+ };
47534776 let found: Outcome<Repo> = call(
47544777 repos,
47554778 "get",
47634786 Outcome::Ok(repo) => repo,
47644787 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
47654788 };
4766− let timeline: Vec<Event> = g1t_kit::call(
4767− events,
4768− "list",
4769− &ListEventsArgs {
4770− repo_id: Some(repo.id),
4771− before: optional_text(input, "before"),
4772− ..ListEventsArgs::default()
4773− },
4774− )
4775− .await?;
4789+ let timeline: Vec<Event> = crate::follow::list_events(events, &repo.id, &asked).await?;
47764790 ok(&timeline)
47774791 }
47784792 // Who has access: identity decides, from the repository as the
+1−1
946946 }
947947 }
948948 ],
949− "notes": "Returns 50 events a page. To page back, send the `id` of the last event you have as `before`. `actor` is a user id, and `data` depends on `type`."
949+ "notes": "Returns 50 events a page, newest first. To page back, send the `id` of the last event you have as `before`. `actor` is a user id, and `data` depends on `type`.\n\nTo follow a repository without a webhook, send `after` instead: the events written after that event come oldest first, and the last one's `id` is your next `after`. Add `wait` (seconds, at most 25) and a call that finds nothing holds on until a matching event is written or the time is up, then returns what it has, which may be an empty list; call again with the same `after`. Events come in the order g1t wrote them, so none is skipped when two arrive close together.\n\n```sh\ncurl -H \"Authorization: Bearer $G1T_TOKEN\" \\\n \"https://api.g1t.sh/repos/acme/rocket/events?after=evt_01m43smh3ve52vkcna207vqctf&types=work.claimed,integration.failed&numbers=12&wait=25\"\n```\n\n`types` and `numbers` are comma-separated lists in a query string, arrays in a body or an MCP call. `numbers` matches events about those issues and pull requests (`data.number`) and comments, reviews and links on them (`data.issue`). `since` is an RFC 3339 time. `after` and `before` cannot be given together."
950950 },
951951 "list_issues": {
952952 "query": {
+30−1
530530 "GET",
531531 "/repos/:owner/:name/events",
532532 Op::ListEvents,
533− &[("before", "before")],
533+ &[
534+ ("before", "before"),
535+ ("after", "after"),
536+ ("types", "types"),
537+ ("numbers", "numbers"),
538+ ("since", "since"),
539+ ("limit", "limit"),
540+ ("wait", "wait"),
541+ ],
534542 ),
535543 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
536544 route("POST", "/repos/:owner/:name/labels", Op::CreateLabel, &[]),
13351343 }
13361344
13371345 #[test]
1346+ fn events_are_followed_with_a_cursor_filters_and_a_wait() {
1347+ let query = [
1348+ ("after".to_owned(), "evt_1".to_owned()),
1349+ ("types".to_owned(), "work.claimed,integration.failed".to_owned()),
1350+ ("numbers".to_owned(), "12,14".to_owned()),
1351+ ("wait".to_owned(), "25".to_owned()),
1352+ ("limit".to_owned(), "20".to_owned()),
1353+ ("since".to_owned(), "2026-10-12T00:00:00Z".to_owned()),
1354+ ];
1355+ let (route, input) = resolve("GET", "/repos/acme/rocket/events", &query, Value::Null).unwrap();
1356+ assert_eq!(route.op, Op::ListEvents);
1357+ let asked = crate::follow::asked(&input).unwrap();
1358+ assert_eq!(asked.after.as_deref(), Some("evt_1"));
1359+ assert_eq!(asked.types, vec!["integration.failed", "work.claimed"]);
1360+ assert_eq!(asked.numbers, vec![12, 14]);
1361+ assert_eq!(asked.wait_ms, 25_000);
1362+ assert_eq!(asked.limit, Some(20));
1363+ assert_eq!(asked.since.as_deref(), Some("2026-10-12T00:00:00Z"));
1364+ }
1365+
1366+ #[test]
13381367 fn notifications_are_addressed_as_threads_and_by_issue() {
13391368 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap();
13401369 assert_eq!(route.op, Op::MarkThreadDone);
+117−0
281281
282282 Your open pull requests and their checks, reviews asked of you, what is
283283 assigned to you, and failing checks, across every repository you can see.
284+Inside a working copy it also lists your active sessions and claims in that
285+repository.
286+
287+## Coordinating work
288+
289+When several people and agents work in one repository, `g1t session` and
290+`g1t work` say who is on what, so nobody starts work that is already under
291+way and everyone hears what changes under them. Claims are advisory: a
292+claim never stops anyone, it tells them.
293+
294+### Sessions
295+
296+A session is one run of an agent's harness, such as one Claude Code
297+session. Starting one remembers it for the working copy, and the commands
298+after it use it:
299+
300+```sh
301+g1t session start --harness claude-code --issue 12
302+g1t session log "Split the parser before touching the lexer."
303+g1t session attach --pull 14 --head-commit "$(git rev-parse HEAD)"
304+g1t session heartbeat
305+g1t session end --reason done
306+```
307+
308+| Command | What it does |
309+| --- | --- |
310+| `g1t session start` | Starts a session and prints its id. `--harness` (`claude-code`, `codex`, `cursor`, `copilot`, `other`), `--harness-session-id`, `--issue`, `--pull`, `--branch` (the current one by default) and `--title`. Starting again with the same `--harness-session-id` returns the same session. |
311+| `g1t session heartbeat` | Says the session is still working, and renews its claims. A session with no heartbeat for 15 minutes reads as idle, and after 2 hours as ended. |
312+| `g1t session log TEXT` | Records a note. `--kind` takes `note`, `prompt`, `message` or `tool-call`, `--tool` names the tool, and `--key` makes recording the same entry twice do nothing. `-` reads the text from stdin; put `--` before text that starts with a dash. |
313+| `g1t session attach` | Points the session at an `--issue`, a `--pull`, a `--branch` or its latest `--head-commit`. |
314+| `g1t session end` | Ends it, with an optional `--reason`, and forgets it. |
315+| `g1t session list` | The repository's sessions; `--state active`, `--issue`, `--pull`. |
316+| `g1t session view [ID]` | A session and what it recorded; the current one without an id. |
317+
318+The id is kept in `.git/g1t/session`, in the working copy's own git
319+directory, so each worktree has its own. `G1T_SESSION` overrides it, for
320+a harness that runs several sessions in one working copy.
321+
322+### Claims and context
323+
324+```sh
325+g1t work context 12 # everything about #12's work
326+g1t work claim 12 --note "Taking the parser" --ttl 1h
327+g1t work release 12 --reason done
328+g1t work handoff 12 --to ana --note "Parser done; lexer tests still fail."
329+g1t work list # active claims and sessions here
330+```
331+
332+`g1t work context` is the one call to make before starting: the issue and
333+its definition of done, who has claimed it, its sessions, its pull
334+requests with their checks, overlaps and place in the merge queue, what it
335+waits on and what waits on it, its decisions, its last integration failure
336+and its recent events.
337+
338+`g1t work claim` says you are working on an issue. It lasts 30 minutes
339+unless `--ttl` says otherwise (at most `4h`), and a heartbeat of the session
340+it was made in renews it. It is made in the current session, if there is
341+one. If others have claimed the issue too, or it waits on issues that are
342+not done, the answer says so. `g1t work handoff` ends your claim and asks
343+`--to` someone, or anyone without it, to take the issue over, with a
344+`--note` saying where it stands.
345+
346+### Dependencies
347+
348+```sh
349+g1t work block 12 --by 9 --by 10 # #12 waits on #9 and #10
350+g1t work unblock 12 --by 9
351+```
352+
353+These change the issue's `blocked_by` through `update_issue`. An issue
354+cannot wait on itself, and g1t refuses a cycle.
355+
356+### Following events
357+
358+`g1t work events` prints the repository's recent events, oldest first.
359+With `--follow` it keeps printing them as they happen, without a webhook or
360+a public address, until you stop it with Ctrl-C:
361+
362+```sh
363+g1t work events --follow --issue 12
364+g1t work events --follow --type integration.failed --type overlap.detected --json
365+```
366+
367+| Flag | |
368+| --- | --- |
369+| `--type`, `-t` | Only events of this type, such as `work.claimed`; repeat for more. |
370+| `--issue`, `-i` | Only events about this issue or pull request; repeat for more. |
371+| `--since`, `-s` | Only events since then: `15m`, `2h`, `1d`, or an RFC 3339 time. |
372+| `--after` | Start after this event id, to resume where an earlier follow stopped. |
373+| `--limit`, `-L` | How many past events to show first; 20 by default. |
374+
375+Each event is one line: its time, type, issue or pull request, a summary
376+and its id. With `--json`, each is one compact JSON object on a line of its
377+own, so a program can read them as they come. Following asks
378+[`list_events`](/reference/api/repositories/list-events/) with `after` and
379+`wait`, so the server holds each request until something happens; when a
380+call fails, `g1t` waits (1 second, then longer, up to 30) and resumes from
381+the last event it printed. A refused token stops it with exit code `4`.
382+
383+### Why a line is as it is
384+
385+```sh
386+g1t why src/parser.rs:120
387+g1t why src/parser.rs --ref main
388+```
389+
390+`g1t why` follows a line to the commit that last changed it, the pull
391+request that merged that commit, the issue the pull request was for, and
392+the decisions and sessions recorded on them. A path is read from where you
393+are in the working copy.
394+
395+### In an agent's hooks
396+
397+These commands are made to be run by a harness's hooks in the background:
398+they never ask anything, they fail at once with exit code `4` when there is
399+no token, and they give up on a call after 15 seconds. Against a g1t server
400+older than the CLI, a command it cannot do yet fails saying so.
284401
285402 ## Scripting with JSON
286403
+9−0
169169 refused. To receive webhooks on your own machine while you build, use a
170170 tunnel such as Cloudflare Tunnel.
171171
172+## Without a public address
173+
174+An agent on a laptop, or anything else that cannot take requests from the
175+internet, can follow a repository's events instead:
176+[`list_events`](/reference/api/repositories/list-events/) with `after` and
177+`wait` holds each request until an event is written, so one request at a
178+time hears about events within a second or so. `g1t work events --follow`
179+does this for you ([The g1t CLI](/guides/cli/#following-events)).
180+
172181 ## From the API
173182
174183 The same routes, and the MCP server's `webhook` tool, work for a
+265−2
735735
736736 ## g1t status
737737
738−What needs you: your pull requests, reviews asked of you, your issues, failing checks.
738+What needs you: your pull requests, reviews asked of you, your issues, failing checks, and your sessions and claims here.
739739
740740 ```text
741741 g1t status [OPTIONS]
742742 ```
743743
744−Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/).
744+Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/), [`list_sessions`](/reference/api/sessions/list-sessions/), [`list_claims`](/reference/api/issues/list-claims/).
745+
746+## g1t session
747+
748+Sessions: one run of an agent's harness, recorded as it works.
749+
750+### g1t session start
751+
752+Start a session and remember it for this working copy. Prints its id.
753+
754+```text
755+g1t session start [OPTIONS]
756+```
757+
758+| Argument or flag | |
759+| --- | --- |
760+| `-i, --issue <ISSUE>` | The issue it works on. |
761+| `-p, --pull <PULL>` | The pull request it works on. |
762+| `--harness <HARNESS>` | The harness running it. One of `claude-code`, `codex`, `cursor`, `copilot`, `other`. Default: `other`. |
763+| `--harness-session-id <ID>` | The harness's own id for the run, which makes starting again return the same session. |
764+| `-t, --title <TITLE>` | |
765+| `-b, --branch <BRANCH>` | The branch it works on; the current one when left out. |
766+
767+```text
768+The id is kept in .git/g1t/session (each worktree has its own); G1T_SESSION overrides it.
769+Starting again with the same --harness-session-id returns the same session.
770+```
771+
772+Calls [`start_session`](/reference/api/sessions/start-session/).
773+
774+### g1t session heartbeat
775+
776+Say the current session is still working; renews its claims.
777+
778+```text
779+g1t session heartbeat [OPTIONS]
780+```
781+
782+Calls [`update_session`](/reference/api/sessions/update-session/).
783+
784+### g1t session log
785+
786+Record a note, prompt or message in the current session.
787+
788+```text
789+g1t session log [OPTIONS] <TEXT>
790+```
791+
792+| Argument or flag | |
793+| --- | --- |
794+| `<TEXT>` | What to record; - reads it from stdin. |
795+| `-k, --kind <KIND>` | One of `note`, `prompt`, `message`, `tool-call`. Default: `note`. |
796+| `--tool <TOOL>` | The tool, for a tool_call. |
797+| `--key <KEY>` | A key that makes recording it again do nothing. |
798+
799+Calls [`record_session_entries`](/reference/api/sessions/record-session-entries/).
800+
801+### g1t session attach
802+
803+Attach the current session to an issue, a pull request or a branch.
804+
805+```text
806+g1t session attach [OPTIONS] <--issue <ISSUE>|--pull <PULL>|--branch <BRANCH>|--head-commit <SHA>>
807+```
808+
809+| Argument or flag | |
810+| --- | --- |
811+| `-i, --issue <ISSUE>` | |
812+| `-p, --pull <PULL>` | |
813+| `-b, --branch <BRANCH>` | |
814+| `--head-commit <SHA>` | The commit it pushed last. |
815+
816+Calls [`update_session`](/reference/api/sessions/update-session/).
817+
818+### g1t session end
819+
820+End the current session.
821+
822+```text
823+g1t session end [OPTIONS]
824+```
825+
826+| Argument or flag | |
827+| --- | --- |
828+| `-r, --reason <REASON>` | Why it ended: done, abandoned, handed_off... |
829+
830+Calls [`end_session`](/reference/api/sessions/end-session/).
831+
832+### g1t session list
833+
834+List the repository's sessions.
835+
836+```text
837+g1t session list [OPTIONS]
838+```
839+
840+| Argument or flag | |
841+| --- | --- |
842+| `-s, --state <STATE>` | Only sessions in this state. One of `active`, `idle`, `ended`. |
843+| `-i, --issue <ISSUE>` | |
844+| `-p, --pull <PULL>` | |
845+| `-L, --limit <LIMIT>` | Default: `20`. |
846+
847+Calls [`list_sessions`](/reference/api/sessions/list-sessions/).
848+
849+### g1t session view
850+
851+Show a session and what it recorded; the current one when left out.
852+
853+```text
854+g1t session view [OPTIONS] [ID]
855+```
856+
857+| Argument or flag | |
858+| --- | --- |
859+| `[ID]` | The session's id (ses_…); the current one when left out. |
860+
861+Calls [`get_session`](/reference/api/sessions/get-session/).
862+
863+## g1t work
864+
865+Coordinate work on issues: context, claims, hand-offs, dependencies, events.
866+
867+### g1t work context
868+
869+Everything about an issue's work: claims, sessions, pull requests, dependencies, decisions.
870+
871+```text
872+g1t work context [OPTIONS] <NUMBER>
873+```
874+
875+| Argument or flag | |
876+| --- | --- |
877+| `<NUMBER>` | The issue's number. |
878+
879+Calls [`get_work`](/reference/api/issues/get-work/).
880+
881+### g1t work claim
882+
883+Claim an issue: say you are working on it. Others are told, never stopped.
884+
885+```text
886+g1t work claim [OPTIONS] <NUMBER>
887+```
888+
889+| Argument or flag | |
890+| --- | --- |
891+| `<NUMBER>` | The issue's number. |
892+| `-n, --note <NOTE>` | What you are doing about it. |
893+| `--ttl <DURATION>` | How long the claim lasts unless renewed: 30m, 2h (at most 4h). |
894+
895+Calls [`claim_issue`](/reference/api/issues/claim-issue/).
896+
897+### g1t work release
898+
899+Release your claim on an issue.
900+
901+```text
902+g1t work release [OPTIONS] <NUMBER>
903+```
904+
905+| Argument or flag | |
906+| --- | --- |
907+| `<NUMBER>` | |
908+| `-r, --reason <REASON>` | Why: done, blocked, abandoned... |
909+
910+Calls [`release_claim`](/reference/api/issues/release-claim/).
911+
912+### g1t work handoff
913+
914+Hand an issue over to someone else, or to anyone.
915+
916+```text
917+g1t work handoff [OPTIONS] --note <NOTE> <NUMBER>
918+```
919+
920+| Argument or flag | |
921+| --- | --- |
922+| `<NUMBER>` | |
923+| `--to <LOGIN>` | Who takes it over; anyone when left out. |
924+| `-n, --note <NOTE>` | Where it stands and what is left, for whoever takes it. |
925+
926+Calls [`hand_off_issue`](/reference/api/issues/hand-off-issue/).
927+
928+### g1t work list
929+
930+Active claims and sessions in the repository.
931+
932+```text
933+g1t work list [OPTIONS]
934+```
935+
936+Calls [`list_claims`](/reference/api/issues/list-claims/), [`list_sessions`](/reference/api/sessions/list-sessions/).
937+
938+### g1t work block
939+
940+Say an issue waits on others: it is blocked by them.
941+
942+```text
943+g1t work block [OPTIONS] --by <NUMBER> <NUMBER>
944+```
945+
946+| Argument or flag | |
947+| --- | --- |
948+| `<NUMBER>` | The issue that waits. |
949+| `--by <NUMBER>` | The issue it waits on; repeat for more. |
950+
951+Calls [`get_issue`](/reference/api/issues/get-issue/), [`update_issue`](/reference/api/issues/update-issue/).
952+
953+### g1t work unblock
954+
955+Say an issue no longer waits on others.
956+
957+```text
958+g1t work unblock [OPTIONS] --by <NUMBER> <NUMBER>
959+```
960+
961+| Argument or flag | |
962+| --- | --- |
963+| `<NUMBER>` | The issue that waits. |
964+| `--by <NUMBER>` | The issue it waits on; repeat for more. |
965+
966+Calls [`get_issue`](/reference/api/issues/get-issue/), [`update_issue`](/reference/api/issues/update-issue/).
967+
968+### g1t work events
969+
970+The repository's events; --follow keeps printing them as they happen.
971+
972+```text
973+g1t work events [OPTIONS]
974+```
975+
976+| Argument or flag | |
977+| --- | --- |
978+| `-f, --follow` | Keep printing events as they happen, until stopped. |
979+| `-t, --type <TYPE>` | Only events of this type; repeat for more. |
980+| `-i, --issue <NUMBER>` | Only events about this issue or pull request; repeat for more. |
981+| `-s, --since <SINCE>` | Only events since then: 15m, 2h, 1d, or a time like 2026-10-12T09:00:00Z. |
982+| `--after <EVENT_ID>` | Start after this event id, as a follow that stopped would resume. |
983+| `-L, --limit <LIMIT>` | How many past events to show first. Default: `20`. |
984+
985+```text
986+Examples:
987+ g1t work events --follow --issue 12
988+ g1t work events --follow --type integration.failed --type overlap.detected --json
989+With --json, each event is one JSON object on a line of its own.
990+```
991+
992+Calls [`list_events`](/reference/api/repositories/list-events/).
993+
994+## g1t why
995+
996+Why a file or line is as it is: its commit, pull request, issue, decisions and sessions.
997+
998+```text
999+g1t why [OPTIONS] <TARGET>
1000+```
1001+
1002+| Argument or flag | |
1003+| --- | --- |
1004+| `<TARGET>` | A file, or a line of it as PATH:LINE. |
1005+| `--ref <REF>` | The branch, tag or commit to read it at; the default branch when left out. |
1006+
1007+Calls [`why`](/reference/api/repositories/why/).
7451008
7461009 ## g1t api
7471010
+1−1
269269 | [`create_milestone`](/reference/api/labels-and-milestones/create-milestone/) | Create a milestone with `title`, `description` and `due_on` (`YYYY-MM-DD`). Write role. | `repo`, `title` | `issues:write` |
270270 | [`update_milestone`](/reference/api/labels-and-milestones/update-milestone/) | Change its `title`, `description`, `due_on` (`""` clears it) or `state` (`open` or `closed`). Write role. | `repo`, `milestone` | `issues:write` |
271271 | [`delete_milestone`](/reference/api/labels-and-milestones/delete-milestone/) | Delete a milestone; what was in it is in none. Write role. | `repo`, `milestone` | `issues:write` |
272−| [`list_events`](/reference/api/repositories/list-events/) | Its timeline, newest first. `before` pages back. | `repo` | `repo:read` |
272+| [`list_events`](/reference/api/repositories/list-events/) | Its timeline, newest first; `before` pages back. To follow it, give `after` (an event id): the events written after it come oldest first, and the last one's `id` is the next `after`. With `wait` (seconds, at most 25) a call that finds nothing holds on until a matching event is written. `types` and `numbers` (arrays) narrow it, with `since` and `limit` (at most 200). | `repo` | `repo:read` |
273273 | [`languages`](/reference/api/repository-insights/get-languages/) | Its [languages](/guides/managing-repositories/#languages) by bytes, each with `color` and `percent`, for the default branch's `commit`; `pending` while it is first read. | `repo` | `repo:read` |
274274 | [`contributors`](/reference/api/repository-insights/list-contributors/) | Its [contributors](/guides/managing-repositories/#contributors): `kind` (`user`, `g1t` or `author`), `commits` and `weeks`, and the repository's commits by week. | `repo` | `repo:read` |
275275 | [`license`](/reference/api/repository-insights/get-license/) | The license its `LICENSE` file holds: `spdx_id`, `name`, `path`. | `repo` | `repo:read` |
+0−0

Binary or large file; its contents are not shown.

+8−1
564564 pub data: serde_json::Value,
565565 }
566566
567−/// `list`: events from the log, newest first. Returns `Vec<Event>`.
567+/// `list`: events from the log, newest first, or with `after` oldest first.
568+/// Returns `Vec<Event>`.
568569 #[derive(Debug, Default, Serialize, serde::Deserialize)]
569570 #[serde(rename_all = "camelCase")]
570571 pub struct ListArgs {
576577 /// Only events older than this event id.
577578 #[serde(default)]
578579 pub before: Option<String>,
580+ /// Only events the log recorded after this event id, and then oldest
581+ /// first: a cursor to follow the log with. Events are ordered by when
582+ /// the log wrote them, not by their ids, so one published a moment
583+ /// before another but written after it is not skipped.
584+ #[serde(default)]
585+ pub after: Option<String>,
579586 /// Only events by this account id.
580587 #[serde(default)]
581588 pub actor: Option<String>,
+277−1
9999 Deploy(DeployCommand),
100100 /// A project's logs and errors.
101101 Logs(LogsArgs),
102− /// What needs you: your pull requests, reviews asked of you, your issues, failing checks.
102+ /// What needs you: your pull requests, reviews asked of you, your issues, failing checks, and your sessions and claims here.
103103 Status(StatusArgs),
104+ /// Sessions: one run of an agent's harness, recorded as it works.
105+ #[command(subcommand)]
106+ Session(SessionCommand),
107+ /// Coordinate work on issues: context, claims, hand-offs, dependencies, events.
108+ #[command(subcommand)]
109+ Work(WorkCommand),
110+ /// Why a file or line is as it is: its commit, pull request, issue, decisions and sessions.
111+ Why(WhyArgs),
104112 /// Call any API operation or path, with your token.
105113 Api(ApiArgs),
106114 /// Open the repository, an issue or a pull request in the browser.
781789 pub limit: usize,
782790 }
783791
792+// --- session ----------------------------------------------------------
793+
794+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
795+pub enum Harness {
796+ ClaudeCode,
797+ Codex,
798+ Cursor,
799+ Copilot,
800+ Other,
801+}
802+
803+impl Harness {
804+ pub fn name(self) -> &'static str {
805+ match self {
806+ Harness::ClaudeCode => "claude_code",
807+ Harness::Codex => "codex",
808+ Harness::Cursor => "cursor",
809+ Harness::Copilot => "copilot",
810+ Harness::Other => "other",
811+ }
812+ }
813+}
814+
815+#[derive(Debug, Subcommand)]
816+pub enum SessionCommand {
817+ /// Start a session and remember it for this working copy. Prints its id.
818+ #[command(after_help = "\
819+The id is kept in .git/g1t/session (each worktree has its own); G1T_SESSION overrides it.
820+Starting again with the same --harness-session-id returns the same session.")]
821+ Start(SessionStartArgs),
822+ /// Say the current session is still working; renews its claims.
823+ Heartbeat,
824+ /// Record a note, prompt or message in the current session.
825+ Log(SessionLogArgs),
826+ /// Attach the current session to an issue, a pull request or a branch.
827+ Attach(SessionAttachArgs),
828+ /// End the current session.
829+ End(SessionEndArgs),
830+ /// List the repository's sessions.
831+ List(SessionListArgs),
832+ /// Show a session and what it recorded; the current one when left out.
833+ View(SessionViewArgs),
834+}
835+
836+#[derive(Debug, Args)]
837+pub struct SessionStartArgs {
838+ /// The issue it works on.
839+ #[arg(short, long)]
840+ pub issue: Option<u64>,
841+ /// The pull request it works on.
842+ #[arg(short, long)]
843+ pub pull: Option<u64>,
844+ /// The harness running it.
845+ #[arg(long, value_enum, default_value_t = Harness::Other)]
846+ pub harness: Harness,
847+ /// The harness's own id for the run, which makes starting again return the same session.
848+ #[arg(long, value_name = "ID")]
849+ pub harness_session_id: Option<String>,
850+ #[arg(short, long)]
851+ pub title: Option<String>,
852+ /// The branch it works on; the current one when left out.
853+ #[arg(short, long)]
854+ pub branch: Option<String>,
855+}
856+
857+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
858+pub enum EntryKind {
859+ Note,
860+ Prompt,
861+ Message,
862+ ToolCall,
863+}
864+
865+impl EntryKind {
866+ pub fn name(self) -> &'static str {
867+ match self {
868+ EntryKind::Note => "note",
869+ EntryKind::Prompt => "prompt",
870+ EntryKind::Message => "message",
871+ EntryKind::ToolCall => "tool_call",
872+ }
873+ }
874+}
875+
876+#[derive(Debug, Args)]
877+pub struct SessionLogArgs {
878+ /// What to record; - reads it from stdin.
879+ pub text: String,
880+ #[arg(short, long, value_enum, default_value_t = EntryKind::Note)]
881+ pub kind: EntryKind,
882+ /// The tool, for a tool_call.
883+ #[arg(long)]
884+ pub tool: Option<String>,
885+ /// A key that makes recording it again do nothing.
886+ #[arg(long)]
887+ pub key: Option<String>,
888+}
889+
890+#[derive(Debug, Args)]
891+#[command(group(clap::ArgGroup::new("what").required(true).multiple(true).args(["issue", "pull", "branch", "head_commit"])))]
892+pub struct SessionAttachArgs {
893+ #[arg(short, long)]
894+ pub issue: Option<u64>,
895+ #[arg(short, long)]
896+ pub pull: Option<u64>,
897+ #[arg(short, long)]
898+ pub branch: Option<String>,
899+ /// The commit it pushed last.
900+ #[arg(long, value_name = "SHA")]
901+ pub head_commit: Option<String>,
902+}
903+
904+#[derive(Debug, Args)]
905+pub struct SessionEndArgs {
906+ /// Why it ended: done, abandoned, handed_off...
907+ #[arg(short, long)]
908+ pub reason: Option<String>,
909+}
910+
911+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
912+pub enum SessionState {
913+ Active,
914+ Idle,
915+ Ended,
916+}
917+
918+#[derive(Debug, Args)]
919+pub struct SessionListArgs {
920+ /// Only sessions in this state.
921+ #[arg(short, long, value_enum)]
922+ pub state: Option<SessionState>,
923+ #[arg(short, long)]
924+ pub issue: Option<u64>,
925+ #[arg(short, long)]
926+ pub pull: Option<u64>,
927+ #[arg(short = 'L', long, default_value_t = 20)]
928+ pub limit: usize,
929+}
930+
931+#[derive(Debug, Args)]
932+pub struct SessionViewArgs {
933+ /// The session's id (ses_…); the current one when left out.
934+ pub id: Option<String>,
935+}
936+
937+// --- work -------------------------------------------------------------
938+
939+#[derive(Debug, Subcommand)]
940+pub enum WorkCommand {
941+ /// Everything about an issue's work: claims, sessions, pull requests, dependencies, decisions.
942+ Context(WorkNumber),
943+ /// Claim an issue: say you are working on it. Others are told, never stopped.
944+ Claim(WorkClaimArgs),
945+ /// Release your claim on an issue.
946+ Release(WorkReleaseArgs),
947+ /// Hand an issue over to someone else, or to anyone.
948+ Handoff(WorkHandoffArgs),
949+ /// Active claims and sessions in the repository.
950+ List,
951+ /// Say an issue waits on others: it is blocked by them.
952+ Block(WorkBlockArgs),
953+ /// Say an issue no longer waits on others.
954+ Unblock(WorkBlockArgs),
955+ /// The repository's events; --follow keeps printing them as they happen.
956+ #[command(after_help = "\
957+Examples:
958+ g1t work events --follow --issue 12
959+ g1t work events --follow --type integration.failed --type overlap.detected --json
960+With --json, each event is one JSON object on a line of its own.")]
961+ Events(WorkEventsArgs),
962+}
963+
964+#[derive(Debug, Args)]
965+pub struct WorkNumber {
966+ /// The issue's number.
967+ pub number: u64,
968+}
969+
970+#[derive(Debug, Args)]
971+pub struct WorkClaimArgs {
972+ /// The issue's number.
973+ pub number: u64,
974+ /// What you are doing about it.
975+ #[arg(short, long)]
976+ pub note: Option<String>,
977+ /// How long the claim lasts unless renewed: 30m, 2h (at most 4h).
978+ #[arg(long, value_name = "DURATION", value_parser = parse_ttl)]
979+ pub ttl: Option<u64>,
980+}
981+
982+#[derive(Debug, Args)]
983+pub struct WorkReleaseArgs {
984+ pub number: u64,
985+ /// Why: done, blocked, abandoned...
986+ #[arg(short, long)]
987+ pub reason: Option<String>,
988+}
989+
990+#[derive(Debug, Args)]
991+pub struct WorkHandoffArgs {
992+ pub number: u64,
993+ /// Who takes it over; anyone when left out.
994+ #[arg(long, value_name = "LOGIN")]
995+ pub to: Option<String>,
996+ /// Where it stands and what is left, for whoever takes it.
997+ #[arg(short, long)]
998+ pub note: String,
999+}
1000+
1001+#[derive(Debug, Args)]
1002+pub struct WorkBlockArgs {
1003+ /// The issue that waits.
1004+ pub number: u64,
1005+ /// The issue it waits on; repeat for more.
1006+ #[arg(long = "by", value_name = "NUMBER", required = true)]
1007+ pub by: Vec<u64>,
1008+}
1009+
1010+#[derive(Debug, Args)]
1011+pub struct WorkEventsArgs {
1012+ /// Keep printing events as they happen, until stopped.
1013+ #[arg(short, long)]
1014+ pub follow: bool,
1015+ /// Only events of this type; repeat for more.
1016+ #[arg(short = 't', long = "type", value_name = "TYPE")]
1017+ pub types: Vec<String>,
1018+ /// Only events about this issue or pull request; repeat for more.
1019+ #[arg(short, long = "issue", value_name = "NUMBER")]
1020+ pub issues: Vec<u64>,
1021+ /// Only events since then: 15m, 2h, 1d, or a time like 2026-10-12T09:00:00Z.
1022+ #[arg(short, long)]
1023+ pub since: Option<String>,
1024+ /// Start after this event id, as a follow that stopped would resume.
1025+ #[arg(long, value_name = "EVENT_ID")]
1026+ pub after: Option<String>,
1027+ /// How many past events to show first.
1028+ #[arg(short = 'L', long, default_value_t = 20)]
1029+ pub limit: usize,
1030+}
1031+
1032+/// `30m`, `2h`, `90` (minutes) as minutes, from 1 minute to 4 hours.
1033+pub fn parse_ttl(text: &str) -> Result<u64, String> {
1034+ let text = text.trim();
1035+ let (number, unit) = text.split_at(text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len()));
1036+ let wrong = || format!("`{text}` is not a duration: write it as 30m or 2h");
1037+ let number: u64 = number.parse().map_err(|_| wrong())?;
1038+ let minutes = match unit {
1039+ "" | "m" | "min" => number,
1040+ "h" => number * 60,
1041+ _ => return Err(wrong()),
1042+ };
1043+ if minutes == 0 || minutes > 240 {
1044+ return Err("a claim lasts from 1 minute to 4 hours".to_owned());
1045+ }
1046+ Ok(minutes)
1047+}
1048+
1049+// --- why --------------------------------------------------------------
1050+
1051+#[derive(Debug, Args)]
1052+pub struct WhyArgs {
1053+ /// A file, or a line of it as PATH:LINE.
1054+ pub target: String,
1055+ /// The branch, tag or commit to read it at; the default branch when left out.
1056+ #[arg(long = "ref", value_name = "REF")]
1057+ pub reference: Option<String>,
1058+}
1059+
7841060 // --- status, api, browse ---------------------------------------------
7851061
7861062 #[derive(Debug, Args)]
+30−0
7171 }
7272 }
7373
74+ /// The same client, giving up sooner: for commands an agent's hooks run
75+ /// in the background, which must not hang on a slow network.
76+ pub fn with_timeouts(mut self, connect: Duration, read: Duration) -> Client {
77+ self.agent = ureq::AgentBuilder::new()
78+ .timeout_connect(connect)
79+ .timeout_read(read)
80+ .timeout_write(read)
81+ .user_agent(&format!("g1t-cli/{}", env!("CARGO_PKG_VERSION")))
82+ .redirects(5)
83+ .build();
84+ self
85+ }
86+
7487 pub fn has_token(&self) -> bool {
7588 self.token.is_some()
7689 }
175188 ) -> Result<Value> {
176189 let bytes = body.map(|b| serde_json::to_vec(b).expect("json"));
177190 let reply = self.raw(method, path, query, &[], bytes)?;
191+ if older_server(&reply) {
192+ return Err(Error::failed(format!(
193+ "{} has no {method} {path}: the g1t server is older than this CLI ({}), and cannot do this yet.",
194+ self.host.api,
195+ env!("CARGO_PKG_VERSION")
196+ )));
197+ }
178198 if !(200..300).contains(&reply.status) {
179199 return Err(self.api_error(&reply));
180200 }
231251 }
232252 }
233253
254+/// Whether the API answered that it has no such route at all, as a server
255+/// older than the CLI does for an operation it does not have, rather than
256+/// that the thing asked about was not found.
257+pub fn older_server(reply: &Reply) -> bool {
258+ reply.status == 404
259+ && reply
260+ .json()
261+ .is_some_and(|body| body["error"]["message"].as_str() == Some("No such endpoint."))
262+}
263+
234264 /// One call to an operation, its path filled in as it is built.
235265 pub struct Call<'a> {
236266 client: &'a Client,
+1−2
298298 fn list_ops(ctx: &Ctx, query: &str) -> Result<()> {
299299 let q = query.to_lowercase();
300300 let mapped = crate::mapping::by_operation();
301− let found: Vec<&Operation> = ops::OPERATIONS
302− .iter()
301+ let found: Vec<&Operation> = ops::all()
303302 .filter(|op| {
304303 q.is_empty()
305304 || op.id.contains(&q)
+49−0
1111 pub mod repo;
1212 pub mod run;
1313 pub mod secret;
14+pub mod session;
1415 pub mod status;
16+pub mod why;
17+pub mod work;
1518
1619 use clap::CommandFactory;
1720
4043 Command::Deploy(command) => deploy::run(&Ctx::new(cli.global)?, command),
4144 Command::Logs(args) => logs::run(&Ctx::new(cli.global)?, args),
4245 Command::Status(_) => status::run(&Ctx::new(cli.global)?),
46+ Command::Session(command) => session::run(&Ctx::new(cli.global)?, command),
47+ Command::Work(command) => work::run(&Ctx::new(cli.global)?, command),
48+ Command::Why(args) => why::run(&Ctx::new(cli.global)?, args),
4349 Command::Api(args) => api::run(&Ctx::new(cli.global)?, args),
4450 Command::Browse(args) => browse::run(&Ctx::new(cli.global)?, args),
4551 }
5460 }
5561 }
5662
63+/// The repository and a signed-in client for the coordination commands
64+/// (`g1t session`, `g1t work`, `g1t why`). Agents' hooks run them in the
65+/// background, so they fail at once without a token (exit code 4), never
66+/// ask anything, and give up on a call after `read` rather than hang.
67+pub fn coordination_client(
68+ ctx: &Ctx,
69+ read: std::time::Duration,
70+) -> Result<(crate::repo::Repo, crate::client::Client)> {
71+ let (repo, client) = ctx.repo_client()?;
72+ if !client.has_token() {
73+ return Err(crate::context::not_signed_in(&client.host));
74+ }
75+ Ok((repo, client.with_timeouts(std::time::Duration::from_secs(5), read)))
76+}
77+
78+/// How long a coordination command waits for an answer.
79+pub const QUICK: std::time::Duration = std::time::Duration::from_secs(15);
80+
81+/// Who a session, claim or decision belongs to: its `actor` (a login, or
82+/// an account with one), or its `user`.
83+pub fn login_of(value: &serde_json::Value) -> String {
84+ for key in ["actor", "user", "author"] {
85+ let who = &value[key];
86+ if let Some(login) = who
87+ .as_str()
88+ .or_else(|| who["username"].as_str())
89+ .or_else(|| who["login"].as_str())
90+ .filter(|login| !login.is_empty())
91+ {
92+ return login.to_owned();
93+ }
94+ }
95+ String::new()
96+}
97+
98+/// The object under `key` when the answer wraps it, or the answer itself.
99+pub fn unwrap_key(value: serde_json::Value, key: &str) -> serde_json::Value {
100+ match value.get(key) {
101+ Some(inner) if inner.is_object() => inner.clone(),
102+ _ => value,
103+ }
104+}
105+
57106 /// The items of a list answer, which comes bare or under a key.
58107 pub fn items(value: serde_json::Value, key: &str) -> Vec<serde_json::Value> {
59108 match value {
+353−0
1+//! `g1t session`: one run of an agent's harness, recorded as it works.
2+//!
3+//! The session a working copy is in is kept in `<git dir>/g1t/session`,
4+//! where `<git dir>` is the worktree's own (`git rev-parse
5+//! --absolute-git-dir`), so each worktree has its own; `G1T_SESSION`
6+//! overrides it. `g1t work claim` attaches its claim to it.
7+
8+use serde_json::{Value, json};
9+use std::io::Read;
10+use std::path::PathBuf;
11+
12+use crate::cli::{
13+ SessionAttachArgs, SessionCommand, SessionEndArgs, SessionListArgs, SessionLogArgs,
14+ SessionStartArgs, SessionState, SessionViewArgs,
15+};
16+use crate::client::Client;
17+use crate::context::Ctx;
18+use crate::error::{Error, Result};
19+use crate::output::{Cell, Output, ago, s};
20+use crate::repo::Repo;
21+
22+pub fn run(ctx: &Ctx, command: SessionCommand) -> Result<()> {
23+ match command {
24+ SessionCommand::Start(args) => start(ctx, args),
25+ SessionCommand::Heartbeat => heartbeat(ctx),
26+ SessionCommand::Log(args) => log(ctx, args),
27+ SessionCommand::Attach(args) => attach(ctx, args),
28+ SessionCommand::End(args) => end(ctx, args),
29+ SessionCommand::List(args) => list(ctx, args),
30+ SessionCommand::View(args) => view(ctx, args),
31+ }
32+}
33+
34+/// Where this working copy's session is kept: `<git dir>/g1t/session`.
35+fn file() -> Option<PathBuf> {
36+ crate::git::run(&["rev-parse", "--absolute-git-dir"])
37+ .ok()
38+ .filter(|dir| !dir.is_empty())
39+ .map(|dir| PathBuf::from(dir).join("g1t").join("session"))
40+}
41+
42+/// The session id in `G1T_SESSION`, else the one kept in `file`.
43+fn chosen(env: Option<String>, file: Option<PathBuf>) -> Option<String> {
44+ env.map(|id| id.trim().to_owned())
45+ .filter(|id| !id.is_empty())
46+ .or_else(|| {
47+ let text = std::fs::read_to_string(file?).ok()?;
48+ let id = text.lines().next()?.trim().to_owned();
49+ (!id.is_empty()).then_some(id)
50+ })
51+}
52+
53+/// The current session's id, if there is one.
54+pub fn current() -> Option<String> {
55+ chosen(std::env::var("G1T_SESSION").ok(), file())
56+}
57+
58+fn required() -> Result<String> {
59+ current().ok_or_else(|| {
60+ Error::usage("No session here: start one with `g1t session start`, or set G1T_SESSION.")
61+ })
62+}
63+
64+fn remember(id: &str) -> Result<()> {
65+ let Some(path) = file() else {
66+ // Outside a working copy there is nowhere to keep it; G1T_SESSION does.
67+ return Ok(());
68+ };
69+ if let Some(dir) = path.parent() {
70+ std::fs::create_dir_all(dir)?;
71+ }
72+ std::fs::write(&path, format!("{id}\n"))?;
73+ Ok(())
74+}
75+
76+/// Forgets the kept session, when it is `id`.
77+fn forget(id: &str) {
78+ if let Some(path) = file()
79+ && chosen(None, Some(path.clone())).as_deref() == Some(id)
80+ {
81+ let _ = std::fs::remove_file(path);
82+ }
83+}
84+
85+/// The session in an answer that may wrap it (`{session, entries}`).
86+fn session_of(found: Value) -> Value {
87+ super::unwrap_key(found, "session")
88+}
89+
90+fn start(ctx: &Ctx, args: SessionStartArgs) -> Result<()> {
91+ let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
92+ let branch = args.branch.clone().or_else(crate::git::current_branch);
93+ let head = crate::git::run(&["rev-parse", "HEAD"]).ok().filter(|h| !h.is_empty());
94+ let found = client
95+ .op("start_session")
96+ .repo(&repo)
97+ .field("harness", args.harness.name())
98+ .field_opt("harness_session_id", args.harness_session_id.clone())
99+ .field_opt("issue", args.issue)
100+ .field_opt("pull", args.pull)
101+ .field_opt("branch", branch)
102+ .field_opt("title", args.title.clone())
103+ .field_opt("head_commit", head)
104+ .send()?;
105+ let session = session_of(found);
106+ let id = s(&session, "id").to_owned();
107+ if id.is_empty() {
108+ return Err(Error::failed("The API did not say which session it started."));
109+ }
110+ remember(&id)?;
111+ if ctx.out.structured() {
112+ return ctx.out.emit(&session);
113+ }
114+ println!("{id}");
115+ Ok(())
116+}
117+
118+fn heartbeat(ctx: &Ctx) -> Result<()> {
119+ let id = required()?;
120+ let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
121+ let found = client.op("update_session").repo(&repo).arg("id", &id).send()?;
122+ if ctx.out.structured() {
123+ return ctx.out.emit(&session_of(found));
124+ }
125+ Ok(())
126+}
127+
128+fn log(ctx: &Ctx, args: SessionLogArgs) -> Result<()> {
129+ let id = required()?;
130+ let text = if args.text == "-" {
131+ let mut text = String::new();
132+ std::io::stdin().read_to_string(&mut text)?;
133+ text
134+ } else {
135+ args.text.clone()
136+ };
137+ if text.trim().is_empty() {
138+ return Err(Error::usage("Nothing to record: give the text."));
139+ }
140+ let mut entry = json!({ "kind": args.kind.name(), "text": text.trim_end() });
141+ if let Some(tool) = &args.tool {
142+ entry["tool"] = json!(tool);
143+ }
144+ if let Some(key) = &args.key {
145+ entry["key"] = json!(key);
146+ }
147+ let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
148+ let found = client
149+ .op("record_session_entries")
150+ .repo(&repo)
151+ .arg("id", &id)
152+ .field("entries", json!([entry]))
153+ .send()?;
154+ if ctx.out.structured() {
155+ return ctx.out.emit(&found);
156+ }
157+ Ok(())
158+}
159+
160+/// Where a session is: `#12`, `pull #14`, its branch.
161+fn place(session: &Value) -> String {
162+ let mut parts = Vec::new();
163+ if let Some(issue) = session["issue"].as_u64() {
164+ parts.push(format!("#{issue}"));
165+ }
166+ if let Some(pull) = session["pull"].as_u64() {
167+ parts.push(format!("pull #{pull}"));
168+ }
169+ if let Some(branch) = session["branch"].as_str().filter(|b| !b.is_empty()) {
170+ parts.push(branch.to_owned());
171+ }
172+ parts.join(", ")
173+}
174+
175+fn attach(ctx: &Ctx, args: SessionAttachArgs) -> Result<()> {
176+ let id = required()?;
177+ let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
178+ let found = client
179+ .op("update_session")
180+ .repo(&repo)
181+ .arg("id", &id)
182+ .field_opt("issue", args.issue)
183+ .field_opt("pull", args.pull)
184+ .field_opt("branch", args.branch.clone())
185+ .field_opt("head_commit", args.head_commit.clone())
186+ .send()?;
187+ let session = session_of(found);
188+ if ctx.out.structured() {
189+ return ctx.out.emit(&session);
190+ }
191+ eprintln!("Session {id} is on {}.", place(&session));
192+ Ok(())
193+}
194+
195+fn end(ctx: &Ctx, args: SessionEndArgs) -> Result<()> {
196+ let id = required()?;
197+ let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
198+ let found = client
199+ .op("end_session")
200+ .repo(&repo)
201+ .arg("id", &id)
202+ .field_opt("reason", args.reason.clone())
203+ .send()?;
204+ forget(&id);
205+ if ctx.out.structured() {
206+ return ctx.out.emit(&session_of(found));
207+ }
208+ eprintln!("Ended session {id}.");
209+ Ok(())
210+}
211+
212+pub fn fetch_list(
213+ client: &Client,
214+ repo: &Repo,
215+ state: Option<&str>,
216+ issue: Option<u64>,
217+ pull: Option<u64>,
218+) -> Result<Vec<Value>> {
219+ let found = client
220+ .op("list_sessions")
221+ .repo(repo)
222+ .query_opt("state", state)
223+ .query_opt("issue", issue)
224+ .query_opt("pull", pull)
225+ .send()?;
226+ Ok(super::items(found, "sessions"))
227+}
228+
229+fn list(ctx: &Ctx, args: SessionListArgs) -> Result<()> {
230+ let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
231+ let state = args.state.map(|state| match state {
232+ SessionState::Active => "active",
233+ SessionState::Idle => "idle",
234+ SessionState::Ended => "ended",
235+ });
236+ let mut sessions = fetch_list(&client, &repo, state, args.issue, args.pull)?;
237+ sessions.truncate(args.limit);
238+ if ctx.out.structured() {
239+ return ctx.out.emit(&Value::Array(sessions));
240+ }
241+ if sessions.is_empty() {
242+ eprintln!("No {} sessions in {repo}.", state.unwrap_or("matching"));
243+ return Ok(());
244+ }
245+ print_list(&ctx.out, &sessions, current().as_deref())
246+}
247+
248+pub fn print_list(out: &Output, sessions: &[Value], mine: Option<&str>) -> Result<()> {
249+ let rows: Vec<Vec<Cell>> = sessions
250+ .iter()
251+ .map(|session| {
252+ let id = s(session, "id");
253+ let marked = if Some(id) == mine { format!("{id} *") } else { id.to_owned() };
254+ vec![
255+ Cell::new(marked),
256+ Cell::styled(s(session, "state"), out.state(s(session, "state"))),
257+ Cell::new(super::login_of(session)),
258+ Cell::new(s(session, "harness")),
259+ Cell::new(place(session)),
260+ Cell::styled(ago(s(session, "last_seen_at")), out.dim(&ago(s(session, "last_seen_at")))),
261+ ]
262+ })
263+ .collect();
264+ out.table(&["session", "state", "who", "harness", "on", "last seen"], &rows)
265+}
266+
267+fn view(ctx: &Ctx, args: SessionViewArgs) -> Result<()> {
268+ let id = match args.id {
269+ Some(id) => id,
270+ None => required()?,
271+ };
272+ let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
273+ let found = client.op("get_session").repo(&repo).arg("id", &id).send()?;
274+ let entries = found
275+ .get("entries")
276+ .cloned()
277+ .unwrap_or_else(|| json!([]));
278+ let mut session = session_of(found);
279+ if let Some(map) = session.as_object_mut() {
280+ map.insert("entries".into(), entries);
281+ }
282+ if ctx.out.structured() {
283+ return ctx.out.emit(&session);
284+ }
285+ let out = &ctx.out;
286+ let title = s(&session, "title");
287+ println!(
288+ "{} {}",
289+ out.bold(if title.is_empty() { s(&session, "id") } else { title }),
290+ out.dim(&format!("{repo} {}", s(&session, "id")))
291+ );
292+ let mut facts = vec![out.state(s(&session, "state"))];
293+ let who = super::login_of(&session);
294+ if !who.is_empty() {
295+ facts.push(format!("{who} with {}", s(&session, "harness")));
296+ }
297+ facts.push(format!("started {}", ago(s(&session, "started_at"))));
298+ if let Some(seen) = session["last_seen_at"].as_str() {
299+ facts.push(format!("last seen {}", ago(seen)));
300+ }
301+ println!("{}", facts.join(" · "));
302+ let on = place(&session);
303+ if !on.is_empty() {
304+ println!("{} {on}", out.dim("On:"));
305+ }
306+ if let Some(head) = session["head_commit"].as_str() {
307+ println!("{} {}", out.dim("Head:"), head.get(..12).unwrap_or(head));
308+ }
309+ if let Some(reason) = session["end_reason"].as_str() {
310+ println!("{} {reason}", out.dim("Ended:"));
311+ }
312+ let entries = session["entries"].as_array().cloned().unwrap_or_default();
313+ if !entries.is_empty() {
314+ println!();
315+ }
316+ for entry in &entries {
317+ let kind = s(entry, "kind");
318+ let label = match entry["tool"].as_str() {
319+ Some(tool) => format!("{kind} {tool}"),
320+ None => kind.to_owned(),
321+ };
322+ let text = s(entry, "text").trim();
323+ let first = text.lines().next().unwrap_or("");
324+ println!("{} {first}", out.dim(&format!("{label:>10}")));
325+ }
326+ Ok(())
327+}
328+
329+#[cfg(test)]
330+mod tests {
331+ use super::*;
332+
333+ #[test]
334+ fn the_environment_overrides_the_kept_session() {
335+ let dir = std::env::temp_dir().join(format!("g1t-session-{}", std::process::id()));
336+ std::fs::create_dir_all(&dir).unwrap();
337+ let path = dir.join("session");
338+ std::fs::write(&path, "ses_kept\n").unwrap();
339+ assert_eq!(chosen(None, Some(path.clone())).as_deref(), Some("ses_kept"));
340+ assert_eq!(chosen(Some("ses_env".into()), Some(path.clone())).as_deref(), Some("ses_env"));
341+ assert_eq!(chosen(Some(" ".into()), Some(path.clone())).as_deref(), Some("ses_kept"));
342+ std::fs::write(&path, "\n").unwrap();
343+ assert_eq!(chosen(None, Some(path)).as_deref(), None);
344+ assert_eq!(chosen(None, None), None);
345+ let _ = std::fs::remove_dir_all(dir);
346+ }
347+
348+ #[test]
349+ fn a_session_says_where_it_is() {
350+ assert_eq!(place(&json!({ "issue": 12, "pull": 14, "branch": "fix-x" })), "#12, pull #14, fix-x");
351+ assert_eq!(place(&json!({ "issue": null, "branch": "" })), "");
352+ }
353+}
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…