| 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 | |
| 11 | export 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 | */ |
| 18 | export type SessionKind = "harness" | "hosted" | "agent" | "recorded"; |
| 19 | |
| 20 | /** Active: heard from in 15 minutes; idle: not since; ended: ended, or quiet for 30 minutes. */ |
| 21 | export type SessionState = "active" | "idle" | "ended"; |
| 22 | |
| 23 | export type PullStatus = "draft" | "open" | "merged" | "closed"; |
| 24 | |
| 25 | /** One run of a harness on a repository: a session, or one of its subagents. */ |
| 26 | export 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. */ |
| 49 | export 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 | |
| 78 | export 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. */ |
| 84 | export 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 | |
| 94 | export type EntryKind = "prompt" | "message" | "tool_call" | "tool_result" | "note" | "skill"; |
| 95 | |
| 96 | /** One step a session recorded. */ |
| 97 | export 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 | |
| 108 | export 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. */ |
| 111 | export 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. */ |
| 114 | export 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`. */ |
| 129 | export const ENTRY_PAGE = 500; |
| 130 | |
| 131 | const 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 | |
| 140 | export 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. */ |
| 145 | export 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. */ |
| 152 | export 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. */ |
| 157 | export 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. */ |
| 164 | export 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. */ |
| 170 | export 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 | */ |
| 179 | export function subagentTree<T extends { id: string; parent_id?: string | null; started_at: string }>(root: string, children: T[]): { session: T; depth: number }[] { |
| 180 | const ids = new Set(children.map((child) => child.id)); |
| 181 | const under = new Map<string, T[]>(); |
| 182 | for (const child of [...children].sort((a, b) => a.started_at.localeCompare(b.started_at))) { |
| 183 | const parent = child.parent_id && child.parent_id !== child.id && ids.has(child.parent_id) ? child.parent_id : root; |
| 184 | under.set(parent, [...(under.get(parent) ?? []), child]); |
| 185 | } |
| 186 | const out: { session: T; depth: number }[] = []; |
| 187 | const seen = new Set<string>(); |
| 188 | const walk = (id: string, depth: number) => { |
| 189 | for (const child of under.get(id) ?? []) { |
| 190 | if (seen.has(child.id)) continue; |
| 191 | seen.add(child.id); |
| 192 | out.push({ session: child, depth }); |
| 193 | walk(child.id, depth + 1); |
| 194 | } |
| 195 | }; |
| 196 | walk(root, 1); |
| 197 | return out; |
| 198 | } |
| 199 | |
| 200 | /** The files a session and its subagents touched, once each, in the order first touched. */ |
| 201 | export function touchedFiles(touched: ApiSessionTouch[] | undefined): string[] { |
| 202 | const files = (touched ?? []).filter((one) => one.kind === "file").sort((a, b) => a.at.localeCompare(b.at)); |
| 203 | return [...new Set(files.map((one) => one.value))]; |
| 204 | } |
| 205 | |
| 206 | /** The query string for a filter. */ |
| 207 | export function sessionsQuery(filter: SessionsFilter): string { |
| 208 | const params = new URLSearchParams({ state: filter.state }); |
| 209 | if (filter.person) params.set("person", filter.person); |
| 210 | if (filter.repo) params.set("repo", filter.repo); |
| 211 | if (filter.item) params.set("item", String(filter.item)); |
| 212 | return params.toString(); |
| 213 | } |