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.

216 linesCodeBlameRaw
1/**
2 * Sessions: the runs of agents across a workspace, through the REST API
3 * (`GET /workspaces/{workspace}/sessions`). Not the app's sign-in, which
4 * is lib/session.tsx. As in lib/work.ts these are the API's snake_case
5 * shapes (the camelCase ones are in @g1t/contracts), and only the fields
6 * the app shows are named. The hooks are in lib/work.ts.
7 *
8 * Kept free of React Native imports so `npm test` runs it under Node.
9 */
10
11export type Harness = "claude_code" | "claude_managed_agents" | "codex" | "cursor" | "copilot" | "other";
12
13/**
14 * Someone's own `harness`, one g1t started on the workspace's Anthropic
15 * provider (`hosted`), g1t's own `agent` on a pull request, or what a
16 * coding agent `recorded` on a pull request with no session of its own.
17 */
18export type SessionKind = "harness" | "hosted" | "agent" | "recorded";
19
20/** Active: heard from in 15 minutes; idle: not since; ended: ended, or quiet for 30 minutes. */
21export type SessionState = "active" | "idle" | "ended";
22
23export type PullStatus = "draft" | "open" | "merged" | "closed";
24
25/** One run of a harness on a repository: a session, or one of its subagents. */
26export type ApiSession = {
27 id: string;
28 repo_id: string;
29 item: number | null;
30 pull: number | null;
31 branch: string | null;
32 actor: { id: string; username: string };
33 harness: Harness;
34 title: string | null;
35 state: SessionState;
36 started_at: string;
37 last_seen_at: string;
38 ended_at: string | null;
39 end_reason: string | null;
40 parent_id?: string | null;
41 agent_type?: string | null;
42 task?: string | null;
43 model?: string | null;
44 subagents?: number;
45 first_prompt?: string | null;
46};
47
48/** One session of a workspace, whatever its kind. */
49export type ApiWorkspaceSession = {
50 kind: SessionKind;
51 /** `ses_…`, `run_…` for g1t's agent, the pull request's id for a recorded one. */
52 id: string;
53 /** `owner/name`. */
54 repo: string;
55 repo_id: string;
56 title: string | null;
57 /** Who runs it, by username; `g1t` for g1t's own. */
58 actor: string | null;
59 harness: Harness | null;
60 /** For g1t's agent and a recorded one: the agent's label. */
61 agent: string | null;
62 branch: string | null;
63 item: number | null;
64 item_title: string | null;
65 pull: number | null;
66 pull_title: string | null;
67 pull_status: PullStatus | null;
68 state: SessionState;
69 started_at: string;
70 last_activity_at: string;
71 subagents: number;
72 /** For g1t's agent: what it is doing now. */
73 step: string | null;
74 session: ApiSession | null;
75 children?: ApiSession[];
76};
77
78export type ApiWorkspaceSessions = {
79 sessions: ApiWorkspaceSession[];
80 repos: { id: string; path: string; default_branch: string }[];
81};
82
83/** What the list shows; each is one of the API's filters. */
84export type SessionsFilter = {
85 /** `live`: going now; `recent`: every one, newest first; `ended`: those that ended. */
86 state: "live" | "recent" | "ended";
87 /** A username. */
88 person?: string;
89 /** A repository of the workspace, by name. */
90 repo?: string;
91 item?: number;
92};
93
94export type EntryKind = "prompt" | "message" | "tool_call" | "tool_result" | "note" | "skill";
95
96/** One step a session recorded. */
97export type ApiSessionEntry = {
98 seq: number;
99 kind: EntryKind;
100 text: string;
101 tool: string | null;
102 commit: string | null;
103 at: string;
104 /** Its text is not shown to this viewer. */
105 withheld?: boolean;
106};
107
108export type ApiSessionTouch = { session_id: string; kind: "file" | "commit" | "branch" | "pull" | "item"; value: string; at: string };
109
110/** A work item or pull request the session, or one of its subagents, worked on. */
111export type ApiSessionLink = { kind: "item" | "pull"; number: number; title: string; state: string; via: string[]; session_ids: string[] };
112
113/** `GET /workspaces/{workspace}/sessions/{id}`: a session, what it recorded, and its repository. */
114export type ApiSessionDetail = {
115 session: ApiSession;
116 entries: ApiSessionEntry[];
117 children?: ApiSession[];
118 touched?: ApiSessionTouch[];
119 links?: ApiSessionLink[];
120 skills?: { session_id: string; name: string; uses: number }[];
121 root?: ApiSession | null;
122 /** The viewer sees that it runs, who runs it and what it is tied to, nothing more. */
123 limited?: boolean;
124 /** `owner/name`. */
125 repo: string;
126};
127
128/** How many entries one answer brings at most; more come with `after`. */
129export const ENTRY_PAGE = 500;
130
131const HARNESS_NAMES: Record<Harness, string> = {
132 claude_code: "Claude Code",
133 claude_managed_agents: "Claude Managed Agents",
134 codex: "Codex",
135 cursor: "Cursor",
136 copilot: "Copilot",
137 other: "Another harness",
138};
139
140export function harnessName(harness: Harness | null | undefined): string | null {
141 return harness ? (HARNESS_NAMES[harness] ?? harness) : null;
142}
143
144/** What runs it: the harness, or the agent's label for g1t's agent and a recorded one. */
145export function runsIn(session: Pick<ApiWorkspaceSession, "kind" | "harness" | "agent">): string {
146 if (session.kind === "agent") return session.agent ?? "g1t";
147 if (session.kind === "recorded") return session.agent ?? "Recorded";
148 return harnessName(session.harness) ?? "Session";
149}
150
151/** Live: active or idle, not ended. */
152export function isLive(session: { state: SessionState }): boolean {
153 return session.state !== "ended";
154}
155
156/** A session's name: its title, else the task or first prompt it recorded. */
157export function sessionTitle(session: Pick<ApiSession, "title" | "task" | "first_prompt" | "agent_type"> | null, fallback?: string | null): string {
158 const text = session?.title ?? fallback ?? session?.task ?? session?.first_prompt ?? null;
159 const line = text?.trim().split("\n", 1)[0];
160 return line || (session?.agent_type ? `${session.agent_type} subagent` : "Untitled session");
161}
162
163/** A workspace session's name in a list. */
164export function listTitle(session: ApiWorkspaceSession): string {
165 if (session.session) return sessionTitle(session.session, session.title);
166 return session.title?.trim() || session.pull_title || (session.pull ? `Pull request #${session.pull}` : "Untitled session");
167}
168
169/** Whether a session has a page of its own in the app: someone's harness and hosted ones do. */
170export function hasPage(session: Pick<ApiWorkspaceSession, "kind">): boolean {
171 return session.kind === "harness" || session.kind === "hosted";
172}
173
174/**
175 * A session's subagents as a tree, in the order they started, each with
176 * how deep it is under `root` (1 for its own). Ones whose parent is not
177 * in the list sit under the root.
178 */
179export function subagentTree<T extends { id: string; parent_id?: string | null; started_at: string }>(
180 root: string,
181 children: T[],
182): { session: T; depth: number }[] {
183 const ids = new Set(children.map((child) => child.id));
184 const under = new Map<string, T[]>();
185 for (const child of [...children].sort((a, b) => a.started_at.localeCompare(b.started_at))) {
186 const parent = child.parent_id && child.parent_id !== child.id && ids.has(child.parent_id) ? child.parent_id : root;
187 under.set(parent, [...(under.get(parent) ?? []), child]);
188 }
189 const out: { session: T; depth: number }[] = [];
190 const seen = new Set<string>();
191 const walk = (id: string, depth: number) => {
192 for (const child of under.get(id) ?? []) {
193 if (seen.has(child.id)) continue;
194 seen.add(child.id);
195 out.push({ session: child, depth });
196 walk(child.id, depth + 1);
197 }
198 };
199 walk(root, 1);
200 return out;
201}
202
203/** The files a session and its subagents touched, once each, in the order first touched. */
204export function touchedFiles(touched: ApiSessionTouch[] | undefined): string[] {
205 const files = (touched ?? []).filter((one) => one.kind === "file").sort((a, b) => a.at.localeCompare(b.at));
206 return [...new Set(files.map((one) => one.value))];
207}
208
209/** The query string for a filter. */
210export function sessionsQuery(filter: SessionsFilter): string {
211 const params = new URLSearchParams({ state: filter.state });
212 if (filter.person) params.set("person", filter.person);
213 if (filter.repo) params.set("repo", filter.repo);
214 if (filter.item) params.set("item", String(filter.item));
215 return params.toString();
216}