@gtc6244/immediacy-daemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/src/actions.d.ts +22 -0
  2. package/dist/src/actions.js +44 -0
  3. package/dist/src/actions.js.map +1 -0
  4. package/dist/src/agent/agent.d.ts +89 -0
  5. package/dist/src/agent/agent.js +22 -0
  6. package/dist/src/agent/agent.js.map +1 -0
  7. package/dist/src/agent/claude.d.ts +70 -0
  8. package/dist/src/agent/claude.js +404 -0
  9. package/dist/src/agent/claude.js.map +1 -0
  10. package/dist/src/agent/codex.d.ts +44 -0
  11. package/dist/src/agent/codex.js +303 -0
  12. package/dist/src/agent/codex.js.map +1 -0
  13. package/dist/src/agent/mock.d.ts +31 -0
  14. package/dist/src/agent/mock.js +155 -0
  15. package/dist/src/agent/mock.js.map +1 -0
  16. package/dist/src/agent/model-error.d.ts +8 -0
  17. package/dist/src/agent/model-error.js +25 -0
  18. package/dist/src/agent/model-error.js.map +1 -0
  19. package/dist/src/agent/openrouter.d.ts +38 -0
  20. package/dist/src/agent/openrouter.js +60 -0
  21. package/dist/src/agent/openrouter.js.map +1 -0
  22. package/dist/src/agent/workspace-scope.d.ts +25 -0
  23. package/dist/src/agent/workspace-scope.js +71 -0
  24. package/dist/src/agent/workspace-scope.js.map +1 -0
  25. package/dist/src/allowlist.d.ts +56 -0
  26. package/dist/src/allowlist.js +141 -0
  27. package/dist/src/allowlist.js.map +1 -0
  28. package/dist/src/chatlog.d.ts +50 -0
  29. package/dist/src/chatlog.js +179 -0
  30. package/dist/src/chatlog.js.map +1 -0
  31. package/dist/src/cli/paths.d.ts +40 -0
  32. package/dist/src/cli/paths.js +76 -0
  33. package/dist/src/cli/paths.js.map +1 -0
  34. package/dist/src/cli/service.d.ts +20 -0
  35. package/dist/src/cli/service.js +245 -0
  36. package/dist/src/cli/service.js.map +1 -0
  37. package/dist/src/cli.d.ts +2 -0
  38. package/dist/src/cli.js +227 -0
  39. package/dist/src/cli.js.map +1 -0
  40. package/dist/src/config.d.ts +33 -0
  41. package/dist/src/config.js +122 -0
  42. package/dist/src/config.js.map +1 -0
  43. package/dist/src/credentials.d.ts +38 -0
  44. package/dist/src/credentials.js +120 -0
  45. package/dist/src/credentials.js.map +1 -0
  46. package/dist/src/crypto.d.ts +49 -0
  47. package/dist/src/crypto.js +202 -0
  48. package/dist/src/crypto.js.map +1 -0
  49. package/dist/src/daemon-identity.d.ts +31 -0
  50. package/dist/src/daemon-identity.js +97 -0
  51. package/dist/src/daemon-identity.js.map +1 -0
  52. package/dist/src/envelope.d.ts +14 -0
  53. package/dist/src/envelope.js +28 -0
  54. package/dist/src/envelope.js.map +1 -0
  55. package/dist/src/events.d.ts +40 -0
  56. package/dist/src/events.js +24 -0
  57. package/dist/src/events.js.map +1 -0
  58. package/dist/src/forward.d.ts +51 -0
  59. package/dist/src/forward.js +170 -0
  60. package/dist/src/forward.js.map +1 -0
  61. package/dist/src/fs-scope.d.ts +17 -0
  62. package/dist/src/fs-scope.js +47 -0
  63. package/dist/src/fs-scope.js.map +1 -0
  64. package/dist/src/github.d.ts +70 -0
  65. package/dist/src/github.js +178 -0
  66. package/dist/src/github.js.map +1 -0
  67. package/dist/src/gitops.d.ts +113 -0
  68. package/dist/src/gitops.js +557 -0
  69. package/dist/src/gitops.js.map +1 -0
  70. package/dist/src/hub.d.ts +50 -0
  71. package/dist/src/hub.js +92 -0
  72. package/dist/src/hub.js.map +1 -0
  73. package/dist/src/index.d.ts +22 -0
  74. package/dist/src/index.js +56 -0
  75. package/dist/src/index.js.map +1 -0
  76. package/dist/src/jobs/scheduler.d.ts +94 -0
  77. package/dist/src/jobs/scheduler.js +373 -0
  78. package/dist/src/jobs/scheduler.js.map +1 -0
  79. package/dist/src/jobs/store.d.ts +33 -0
  80. package/dist/src/jobs/store.js +111 -0
  81. package/dist/src/jobs/store.js.map +1 -0
  82. package/dist/src/log.d.ts +61 -0
  83. package/dist/src/log.js +166 -0
  84. package/dist/src/log.js.map +1 -0
  85. package/dist/src/migrate-state-dir.d.ts +1 -0
  86. package/dist/src/migrate-state-dir.js +47 -0
  87. package/dist/src/migrate-state-dir.js.map +1 -0
  88. package/dist/src/models.d.ts +36 -0
  89. package/dist/src/models.js +71 -0
  90. package/dist/src/models.js.map +1 -0
  91. package/dist/src/permission-gate.d.ts +48 -0
  92. package/dist/src/permission-gate.js +93 -0
  93. package/dist/src/permission-gate.js.map +1 -0
  94. package/dist/src/permission-scope.d.ts +77 -0
  95. package/dist/src/permission-scope.js +244 -0
  96. package/dist/src/permission-scope.js.map +1 -0
  97. package/dist/src/ports.d.ts +71 -0
  98. package/dist/src/ports.js +216 -0
  99. package/dist/src/ports.js.map +1 -0
  100. package/dist/src/projects.d.ts +83 -0
  101. package/dist/src/projects.js +205 -0
  102. package/dist/src/projects.js.map +1 -0
  103. package/dist/src/protocol.d.ts +1226 -0
  104. package/dist/src/protocol.js +16 -0
  105. package/dist/src/protocol.js.map +1 -0
  106. package/dist/src/repo-sync.d.ts +49 -0
  107. package/dist/src/repo-sync.js +83 -0
  108. package/dist/src/repo-sync.js.map +1 -0
  109. package/dist/src/server.d.ts +33 -0
  110. package/dist/src/server.js +973 -0
  111. package/dist/src/server.js.map +1 -0
  112. package/dist/src/terminal.d.ts +32 -0
  113. package/dist/src/terminal.js +92 -0
  114. package/dist/src/terminal.js.map +1 -0
  115. package/dist/src/titler.d.ts +15 -0
  116. package/dist/src/titler.js +72 -0
  117. package/dist/src/titler.js.map +1 -0
  118. package/dist/src/types.d.ts +99 -0
  119. package/dist/src/types.js +3 -0
  120. package/dist/src/types.js.map +1 -0
  121. package/dist/src/usage-limit.d.ts +49 -0
  122. package/dist/src/usage-limit.js +83 -0
  123. package/dist/src/usage-limit.js.map +1 -0
  124. package/dist/src/usage.d.ts +23 -0
  125. package/dist/src/usage.js +63 -0
  126. package/dist/src/usage.js.map +1 -0
  127. package/dist/src/workspace.d.ts +303 -0
  128. package/dist/src/workspace.js +1086 -0
  129. package/dist/src/workspace.js.map +1 -0
  130. package/dist/src/worktree.d.ts +133 -0
  131. package/dist/src/worktree.js +561 -0
  132. package/dist/src/worktree.js.map +1 -0
  133. package/package.json +67 -0
@@ -0,0 +1,1086 @@
1
+ /**
2
+ * WorkspaceManager — owns the set of workspaces and, per workspace, its
3
+ * worktree, agent, and terminal. Translates agent events into broadcast wire
4
+ * messages and persists metadata so sessions survive a daemon restart (resume).
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { existsSync } from "node:fs";
8
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { ChatLog } from "./chatlog.js";
12
+ import { formatTurnUsage } from "./usage.js";
13
+ import { ProjectStore } from "./projects.js";
14
+ import { AppConfigStore } from "./config.js";
15
+ import { CredentialsStore } from "./credentials.js";
16
+ import { AllowlistStore } from "./allowlist.js";
17
+ import { MockAgent } from "./agent/mock.js";
18
+ import { ClaudeAgent } from "./agent/claude.js";
19
+ import { CodexAgent } from "./agent/codex.js";
20
+ import { OpenRouterAgent } from "./agent/openrouter.js";
21
+ import { providerFor } from "./models.js";
22
+ import { Terminal } from "./terminal.js";
23
+ import { createWorktree, createWorktreeFromExistingBranch, createWorktreeFromPr, listBranches, removeWorktree, deleteLocalBranch, currentBranch, diffWorktree, diffStat, ensureCleanWorktree, listTree, readWorktreeFile, writeWorktreeFile, } from "./worktree.js";
24
+ import { gitStatus, gitCommit, gitPush, gitResolve, gitMerge, prCreate, prMerge, detectMerged, prInfo, openWorktree, originUrl } from "./gitops.js";
25
+ import { maybeSyncRepo } from "./repo-sync.js";
26
+ import { listPullRequests, prHeadRef, listIssues, createIssue } from "./github.js";
27
+ import { composeActionPrompt } from "./actions.js";
28
+ import { generateChatTitle } from "./titler.js";
29
+ const STATE_FILE = process.env.STATE_FILE ?? join(homedir(), ".immediacy", "state.json");
30
+ /** Root for repo-less chat workspaces' scratch dirs (one per chat). */
31
+ const CHATS_DIR = process.env.CHATS_DIR ?? join(homedir(), ".immediacy", "chats");
32
+ const AGENT_MODE = (process.env.AGENT_MODE ?? "claude").toLowerCase();
33
+ /**
34
+ * The sidebar-label form of a branch/ref: drops a leading owner/account segment
35
+ * so the label reads as the work, not who pushed it —
36
+ * `"Brutus-GTC6245/amman"` → `"amman"`. Only the first segment (the account) is
37
+ * removed; any deeper path is kept, and a ref with no `/` is returned unchanged.
38
+ */
39
+ function branchLabel(ref) {
40
+ const i = ref.indexOf("/");
41
+ const label = i >= 0 ? ref.slice(i + 1) : ref;
42
+ return label || ref;
43
+ }
44
+ export class WorkspaceManager {
45
+ hub;
46
+ chatlog;
47
+ events;
48
+ projects;
49
+ credentials;
50
+ appConfig;
51
+ allowlist;
52
+ workspaces = new Map();
53
+ /**
54
+ * The last workspace list we fanned out, JSON-encoded — so
55
+ * `broadcastWorkspaceList` can no-op when a recompute yields identical rows.
56
+ */
57
+ lastListJson = "";
58
+ constructor(hub,
59
+ /** Durable per-workspace transcript, so history survives phone reboots. */
60
+ chatlog = new ChatLog(),
61
+ /**
62
+ * In-process domain-event bus. Optional so existing callers/tests need no
63
+ * change; when present, the manager emits at each action chokepoint so the
64
+ * JobScheduler can drive reactive ("N minutes after X") triggers.
65
+ */
66
+ events,
67
+ /** Registry of projects worktrees hang under (persisted separately). */
68
+ projects = new ProjectStore(),
69
+ /**
70
+ * Provider API keys (app-managed, env-seeded). Read at agent-spawn time to
71
+ * pick a session's credential: a stored/env key, else the CLI/SDK's own SSO
72
+ * login. Defaulted so existing callers/tests need no change.
73
+ */
74
+ credentials = new CredentialsStore(),
75
+ /**
76
+ * Daemon-owned app config (prompts + git behavior). Read live at spawn time
77
+ * (`prompts.general` → agent system prompt) and at worktree-create time
78
+ * (`git.branchPrefix*`). Defaulted so existing callers/tests need no change.
79
+ */
80
+ appConfig = new AppConfigStore(),
81
+ /**
82
+ * Daemon-global "Allow all" list. Handed to every spawned agent so a prior
83
+ * grant auto-approves matching calls, and appended to when a client answers
84
+ * a permission request with `allow_all`. Defaulted for existing callers/tests.
85
+ */
86
+ allowlist = new AllowlistStore()) {
87
+ this.hub = hub;
88
+ this.chatlog = chatlog;
89
+ this.events = events;
90
+ this.projects = projects;
91
+ this.credentials = credentials;
92
+ this.appConfig = appConfig;
93
+ this.allowlist = allowlist;
94
+ }
95
+ /** The daemon-global allowlist (for server-side admin: list/revoke/clear). */
96
+ allowlistStore() {
97
+ return this.allowlist;
98
+ }
99
+ /** Is there a live workspace with this id? (used by the job scheduler). */
100
+ has(id) {
101
+ return this.workspaces.has(id);
102
+ }
103
+ async load() {
104
+ await this.chatlog.load();
105
+ await this.projects.load();
106
+ let migrated = false;
107
+ try {
108
+ const raw = await readFile(STATE_FILE, "utf8");
109
+ const records = JSON.parse(raw);
110
+ for (const r of records) {
111
+ // Worktrees may have been removed out of band; only keep live ones.
112
+ if (!existsSync(r.worktreePath))
113
+ continue;
114
+ let projectId = r.projectId;
115
+ // Legacy records (pre-projects) or ones pointing at a since-removed
116
+ // project get find-or-created a project from their repo so every
117
+ // worktree still groups under something.
118
+ if (!projectId || !this.projects.get(projectId)) {
119
+ const project = await this.projects.ensureForSource({ repo: r.repo });
120
+ projectId = project.id;
121
+ migrated = true;
122
+ }
123
+ this.workspaces.set(r.id, {
124
+ record: { ...r, projectId, status: "idle" },
125
+ terminals: new Map(),
126
+ });
127
+ }
128
+ console.log(`[ws] restored ${this.workspaces.size} workspace(s)`);
129
+ }
130
+ catch {
131
+ /* no prior state */
132
+ }
133
+ if (migrated)
134
+ await this.persist();
135
+ }
136
+ /** Registered projects (the sidebar groups). */
137
+ listProjects() {
138
+ return this.projects.list();
139
+ }
140
+ /**
141
+ * Reorder the registered projects. `ids` is the full list of project ids in
142
+ * the desired display order (see ProjectStore.reorder for how unknown/missing
143
+ * ids are handled).
144
+ */
145
+ async reorderProjects(ids) {
146
+ await this.projects.reorder(ids);
147
+ }
148
+ /**
149
+ * Register a project from a blank repo (`git init`), a GitHub source, or an
150
+ * existing local path. GitHub/local are idempotent (by full name / resolved
151
+ * path); blank always creates. Does NOT create a worktree — it's a standing
152
+ * "+ new worktree" target.
153
+ */
154
+ async createProject(opts) {
155
+ return this.projects.ensureForSource(opts);
156
+ }
157
+ /**
158
+ * Branches available to base a new worktree on for a project (local + remote,
159
+ * deduped). Powers the "create worktree from an existing branch" pickers.
160
+ */
161
+ async listBranches(projectId) {
162
+ const project = this.projects.get(projectId);
163
+ if (!project)
164
+ throw new Error(`unknown project: ${projectId}`);
165
+ const branches = await listBranches(project.repo);
166
+ return { branches, defaultBranch: project.github?.defaultBranch };
167
+ }
168
+ /**
169
+ * A project's open pull requests, for the "create worktree from an open PR"
170
+ * picker. Empty when the project has no GitHub remote or `gh` isn't available.
171
+ */
172
+ async listPullRequests(projectId) {
173
+ const project = this.projects.get(projectId);
174
+ if (!project)
175
+ throw new Error(`unknown project: ${projectId}`);
176
+ return listPullRequests(project.repo);
177
+ }
178
+ /**
179
+ * A project's open issues (with bodies), for the "create worktree from an
180
+ * issue" picker + confirm step. Empty when there's no GitHub remote / gh.
181
+ */
182
+ async listIssues(projectId) {
183
+ const project = this.projects.get(projectId);
184
+ if (!project)
185
+ throw new Error(`unknown project: ${projectId}`);
186
+ return listIssues(project.repo);
187
+ }
188
+ /** Remove a project. Rejected while any worktree still references it. */
189
+ async deleteProject(id) {
190
+ const inUse = [...this.workspaces.values()].some((l) => l.record.projectId === id);
191
+ if (inUse) {
192
+ throw new Error("project still has worktrees; remove them first");
193
+ }
194
+ await this.projects.remove(id);
195
+ }
196
+ /** Persisted transcript for a workspace (chronological). */
197
+ history(id) {
198
+ return this.chatlog.entries(id);
199
+ }
200
+ async persist() {
201
+ await mkdir(dirname(STATE_FILE), { recursive: true });
202
+ const records = [...this.workspaces.values()].map((l) => l.record);
203
+ await writeFile(STATE_FILE, JSON.stringify(records, null, 2), "utf8");
204
+ }
205
+ list() {
206
+ return [...this.workspaces.values()].map((l) => l.record);
207
+ }
208
+ /**
209
+ * Create a workspace (worktree) under a project. Preferred: pass `projectId`.
210
+ * The legacy path — a bare `repo`/`github` with no projectId — find-or-creates
211
+ * the matching project first, so every worktree ends up grouped.
212
+ */
213
+ async create(opts) {
214
+ // Resolve the owning project: an explicit id, or find-or-create from source.
215
+ let project;
216
+ if (opts.projectId) {
217
+ project = this.projects.get(opts.projectId);
218
+ if (!project)
219
+ throw new Error(`unknown project: ${opts.projectId}`);
220
+ }
221
+ else {
222
+ project = await this.projects.ensureForSource({
223
+ repo: opts.repo,
224
+ github: opts.github,
225
+ name: opts.name,
226
+ });
227
+ }
228
+ const repo = project.repo;
229
+ // GitHub-backed projects branch off the remote default unless overridden.
230
+ const gh = opts.github ?? project.github;
231
+ const branchBase = opts.branchBase ??
232
+ (gh ? `origin/${gh.defaultBranch ?? "HEAD"}` : undefined);
233
+ const id = randomUUID();
234
+ const sourceBranch = opts.sourceBranch?.trim();
235
+ const sourcePr = opts.sourcePr;
236
+ // Resolve the PR's head branch up front so it can also seed the workspace
237
+ // name (a meaningful label) and drive the checkout below.
238
+ const prHead = sourcePr ? await prHeadRef(repo, sourcePr) : undefined;
239
+ // When basing off an existing branch or PR, default the workspace name to
240
+ // that branch (a meaningful label), minus any owner/account prefix
241
+ // (branchLabel: "Brutus-GTC6245/amman" → "amman"); otherwise fall back to a
242
+ // short id. An explicit name from the client wins untouched.
243
+ const wsName = opts.name?.trim() ||
244
+ (sourceBranch && branchLabel(sourceBranch)) ||
245
+ (prHead && branchLabel(prHead)) ||
246
+ `ws-${id.slice(0, 6)}`;
247
+ const gitCfg = this.appConfig.get().git;
248
+ // Three shapes: check out an existing PR's head branch, check out an existing
249
+ // branch, or fork a fresh one off the base.
250
+ const wt = prHead
251
+ ? await createWorktreeFromPr(repo, id, sourcePr, prHead, branchBase)
252
+ : sourceBranch
253
+ ? await createWorktreeFromExistingBranch(repo, id, sourceBranch, branchBase)
254
+ : await createWorktree(repo, id, wsName, branchBase, {
255
+ type: gitCfg.branchPrefixType,
256
+ custom: gitCfg.branchPrefix,
257
+ });
258
+ const record = {
259
+ id,
260
+ name: wsName,
261
+ projectId: project.id,
262
+ repo,
263
+ worktreePath: wt.worktreePath,
264
+ branch: wt.branch,
265
+ baseBranch: wt.baseBranch,
266
+ status: "idle",
267
+ model: opts.model,
268
+ };
269
+ this.workspaces.set(id, { record, terminals: new Map() });
270
+ await this.persist();
271
+ this.hub.broadcast("workspace.created", id, {
272
+ ws: id,
273
+ name: wsName,
274
+ branch: wt.branch,
275
+ });
276
+ this.events?.emitSystem({ kind: "workspace.created", ws: id, context: { name: wsName } });
277
+ // "Create worktree from an issue": kick the agent off with the confirmed
278
+ // issue as its first task, now that the worktree exists.
279
+ const initialPrompt = opts.initialPrompt?.trim();
280
+ if (initialPrompt)
281
+ this.prompt(id, initialPrompt);
282
+ return record;
283
+ }
284
+ /**
285
+ * Create a repo-less "chat" workspace — a free-form conversation with the
286
+ * agent, not tied to any GitHub project or git worktree (the Claude/ChatGPT
287
+ * desktop model). It starts named "New Chat"; the first prompt triggers an
288
+ * auto-title (see `prompt`). The agent still gets a real cwd — a private
289
+ * scratch dir under `~/.immediacy/chats/<id>` — so file/bash tools work,
290
+ * scoped to that folder by the usual workspace-scope rule.
291
+ */
292
+ async createChat(opts = {}) {
293
+ const id = randomUUID();
294
+ const scratch = join(CHATS_DIR, id);
295
+ await mkdir(scratch, { recursive: true });
296
+ const record = {
297
+ id,
298
+ name: "New Chat",
299
+ kind: "chat",
300
+ projectId: "",
301
+ repo: scratch,
302
+ worktreePath: scratch,
303
+ branch: "",
304
+ baseBranch: "",
305
+ status: "idle",
306
+ model: opts.model,
307
+ };
308
+ this.workspaces.set(id, { record, terminals: new Map() });
309
+ await this.persist();
310
+ this.hub.broadcast("workspace.created", id, {
311
+ ws: id,
312
+ name: record.name,
313
+ branch: "",
314
+ kind: "chat",
315
+ });
316
+ this.events?.emitSystem({ kind: "workspace.created", ws: id, context: { name: record.name } });
317
+ return record;
318
+ }
319
+ /**
320
+ * Rename a workspace and fan the new label out to every client. Used by the
321
+ * chat auto-title flow; the sidebar row updates in place without a full list
322
+ * refresh.
323
+ */
324
+ async renameWorkspace(id, name) {
325
+ const live = this.workspaces.get(id);
326
+ if (!live || !name.trim())
327
+ return;
328
+ live.record.name = name.trim();
329
+ await this.persist();
330
+ this.hub.broadcast("workspace.renamed", id, { ws: id, name: live.record.name });
331
+ }
332
+ /**
333
+ * Summarize a chat's first prompt into a short title and rename the workspace.
334
+ * Fire-and-forget: guards against a racing second prompt (only renames while
335
+ * the label is still the placeholder) and swallows any failure.
336
+ */
337
+ async autoTitleChat(id, firstPrompt) {
338
+ const sdkEnv = envForKey("ANTHROPIC_API_KEY", this.credentials.getKey("anthropic"));
339
+ const title = await generateChatTitle(firstPrompt, sdkEnv);
340
+ if (!title)
341
+ return;
342
+ const live = this.workspaces.get(id);
343
+ // Re-check: the user (or a prior title) may have renamed it meanwhile.
344
+ if (!live || live.record.kind !== "chat" || live.record.name !== "New Chat")
345
+ return;
346
+ await this.renameWorkspace(id, title);
347
+ }
348
+ /** Added/removed line totals vs base, per workspace (for the sidebar rows). */
349
+ async diffStat(id) {
350
+ const live = this.workspaces.get(id);
351
+ if (!live)
352
+ return null;
353
+ if (live.record.kind === "chat")
354
+ return { added: 0, removed: 0, filesChanged: 0 };
355
+ // Freshen origin first: the stat is measured against merge-base(origin/<base>,
356
+ // HEAD), so a stale remote-tracking ref regresses that merge-base and inflates
357
+ // the counts by sweeping in commits the branch only merged, not authored (the
358
+ // +2.7k-vs-+425 drift). Throttled + coalesced per repo, so it's a no-op when a
359
+ // recent poll already fetched. See diffBaseRef in worktree.ts.
360
+ await maybeSyncRepo(live.record.repo);
361
+ return diffStat(live.record.worktreePath, live.record.baseBranch);
362
+ }
363
+ /**
364
+ * The sidebar summaries: one row per workspace with its live diff-stat. Diffs
365
+ * are measured in parallel and best-effort (0/0 on any git hiccup); `added`/
366
+ * `removed` are omitted when zero so a clean worktree renders no counter.
367
+ */
368
+ async summaries() {
369
+ const list = this.list();
370
+ // Same freshness requirement as diffStat/diff: the per-row counts are diffed
371
+ // against merge-base(origin/<base>, HEAD), so a stale origin inflates them.
372
+ // Freshen each distinct base repo before measuring — throttled + coalesced per
373
+ // repo, so a workspace-heavy account still costs at most one fetch per repo per
374
+ // interval, and it's a no-op when a recent poll already fetched.
375
+ // Chats have no git repo — only freshen the worktree-backed ones.
376
+ await Promise.all([...new Set(list.filter((r) => r.kind !== "chat").map((r) => r.repo))].map((repo) => maybeSyncRepo(repo)));
377
+ return Promise.all(list.map(async (r) => {
378
+ // A chat has no diff or origin — emit a bare row carrying only its kind.
379
+ if (r.kind === "chat") {
380
+ return {
381
+ ws: r.id,
382
+ name: r.name,
383
+ branch: r.branch,
384
+ status: r.status,
385
+ projectId: r.projectId,
386
+ kind: "chat",
387
+ ...(r.model ? { model: r.model } : {}),
388
+ };
389
+ }
390
+ const stat = (await diffStat(r.worktreePath, r.baseBranch)) ??
391
+ { added: 0, removed: 0, filesChanged: 0 };
392
+ const repoUrl = await originUrl(r.repo);
393
+ return {
394
+ ws: r.id,
395
+ name: r.name,
396
+ branch: r.branch,
397
+ status: r.status,
398
+ projectId: r.projectId,
399
+ model: r.model,
400
+ ...(stat.added ? { added: stat.added } : {}),
401
+ ...(stat.removed ? { removed: stat.removed } : {}),
402
+ ...(stat.filesChanged ? { filesChanged: stat.filesChanged } : {}),
403
+ ...(repoUrl ? { repoUrl } : {}),
404
+ };
405
+ }));
406
+ }
407
+ /**
408
+ * Recompute the sidebar summaries and fan them out to every client — but only
409
+ * when something actually changed since the last fan-out. This is the single
410
+ * entry point for keeping the left-pane rows (diff-stat counters, name,
411
+ * branch, status) live: callers fire it whenever a worktree's state may have
412
+ * moved (an agent turn finished, files changed, the base advanced, a branch or
413
+ * PR was renamed) without having to know whether the numbers really shifted.
414
+ * The change-check keeps a frequently-polled path (git.status) from spamming
415
+ * identical lists at every connected device.
416
+ */
417
+ async broadcastWorkspaceList() {
418
+ const list = await this.summaries();
419
+ const json = JSON.stringify(list);
420
+ if (json === this.lastListJson)
421
+ return; // nothing moved — skip the fan-out
422
+ this.lastListJson = json;
423
+ this.hub.broadcast("workspace.list.result", null, { workspaces: list });
424
+ }
425
+ /**
426
+ * Freshen every base repo's remote-tracking refs, then reconcile the sidebar.
427
+ * This is the "an app just (re)connected — make the left pane reflect GitHub,
428
+ * not a snapshot from whenever we last fetched" path: the client's first
429
+ * `workspace.list` gets an immediate (locally-computed) reply for a snappy
430
+ * paint, and this runs alongside it so a since-pushed base lands a moment
431
+ * later. One fetch per distinct repo, throttled + coalesced per repo (see
432
+ * maybeSyncRepo), so a workspace-heavy account still costs one fetch per repo
433
+ * per interval; the broadcast's change-check no-ops when nothing moved.
434
+ * Best-effort — an offline/no-remote repo keeps its last-known refs.
435
+ */
436
+ async syncAllReposAndRefresh() {
437
+ const repos = new Set(this.list().map((r) => r.repo));
438
+ await Promise.all([...repos].map((repo) => maybeSyncRepo(repo)));
439
+ await this.broadcastWorkspaceList();
440
+ }
441
+ async delete(id) {
442
+ const live = this.workspaces.get(id);
443
+ if (!live)
444
+ return;
445
+ // A chat has no worktree/branch to preserve — tear down its scratch dir.
446
+ if (live.record.kind === "chat") {
447
+ await this.deleteChat(live);
448
+ return;
449
+ }
450
+ await ensureCleanWorktree(live.record.worktreePath);
451
+ await live.agent?.stop();
452
+ for (const dt of live.terminals.values())
453
+ dt.terminal?.dispose();
454
+ // Soft delete: remove the worktree, keep the branch + base repo (§11).
455
+ await removeWorktree(live.record.repo, live.record.worktreePath);
456
+ this.workspaces.delete(id);
457
+ await this.chatlog.remove(id);
458
+ await this.persist();
459
+ }
460
+ /** Tear down a repo-less chat: stop the agent, drop its scratch dir + log. */
461
+ async deleteChat(live) {
462
+ const id = live.record.id;
463
+ await live.agent?.stop();
464
+ for (const dt of live.terminals.values())
465
+ dt.terminal?.dispose();
466
+ await rm(live.record.worktreePath, { recursive: true, force: true }).catch(() => { });
467
+ this.workspaces.delete(id);
468
+ await this.chatlog.remove(id);
469
+ await this.persist();
470
+ }
471
+ /**
472
+ * Archive a workspace: a deliberate teardown that force-removes the worktree
473
+ * (discarding any uncommitted changes) and — only when
474
+ * git.deleteBranchOnArchive is set — deletes the *local* branch. The remote
475
+ * (pushed branch, PR, commits on origin) is never touched, so archiving a
476
+ * merged workspace is non-destructive to the shipped work. Distinct from
477
+ * delete, which always preserves the branch (§11).
478
+ */
479
+ async archive(id) {
480
+ const live = this.workspaces.get(id);
481
+ if (!live)
482
+ return;
483
+ // A chat has no worktree/branch — archive is the same teardown as delete.
484
+ if (live.record.kind === "chat") {
485
+ await this.deleteChat(live);
486
+ return;
487
+ }
488
+ const { repo, worktreePath, branch } = live.record;
489
+ await live.agent?.stop();
490
+ for (const dt of live.terminals.values())
491
+ dt.terminal?.dispose();
492
+ await removeWorktree(repo, worktreePath, /* force */ true);
493
+ if (this.appConfig.get().git.deleteBranchOnArchive) {
494
+ await deleteLocalBranch(repo, branch);
495
+ }
496
+ this.workspaces.delete(id);
497
+ await this.chatlog.remove(id);
498
+ await this.persist();
499
+ }
500
+ /**
501
+ * Run a prompt-backed agent action (code review, create PR, fix errors,
502
+ * resolve merge conflicts, rename branch). Composes the built-in instruction
503
+ * plus the user's matching prompts.* custom text, then injects it through the
504
+ * normal prompt path — the agent does the work with its own permission-gated
505
+ * tools. Returns false when the workspace is unknown.
506
+ */
507
+ runAction(id, kind) {
508
+ if (!this.workspaces.has(id))
509
+ return false;
510
+ this.prompt(id, composeActionPrompt(kind, this.appConfig.get().prompts));
511
+ return true;
512
+ }
513
+ /**
514
+ * Mass-delete worktrees and/or whole projects. Selected projects contribute
515
+ * all of their remaining worktrees to the removal set (a project can't be
516
+ * deleted while any worktree still references it), then the projects
517
+ * themselves are removed. Each item is attempted independently: a dirty
518
+ * worktree that refuses removal is collected as a failure rather than
519
+ * aborting the rest — and its owning project then fails too (correctly, since
520
+ * it still has a worktree). Callers broadcast fresh lists afterwards.
521
+ */
522
+ async deleteBatch(opts) {
523
+ const projectIds = new Set(opts.projects ?? []);
524
+ const wsIds = new Set(opts.workspaces ?? []);
525
+ // Fold in every worktree owned by a selected project.
526
+ for (const live of this.workspaces.values()) {
527
+ if (projectIds.has(live.record.projectId))
528
+ wsIds.add(live.record.id);
529
+ }
530
+ const deletedWorkspaces = [];
531
+ const deletedProjects = [];
532
+ const failures = [];
533
+ for (const id of wsIds) {
534
+ // Unknown/stale id (e.g. removed by another device between list and
535
+ // batch): skip it rather than report a phantom removal — delete() itself
536
+ // would no-op silently and we'd falsely count it as deleted.
537
+ if (!this.workspaces.has(id))
538
+ continue;
539
+ try {
540
+ await this.delete(id);
541
+ deletedWorkspaces.push(id);
542
+ }
543
+ catch (err) {
544
+ failures.push({ id, kind: "workspace", message: errText(err) });
545
+ }
546
+ }
547
+ // Projects last: their worktrees are gone (or failed, which will surface
548
+ // here as a "still has worktrees" project failure).
549
+ for (const id of projectIds) {
550
+ if (!this.projects.get(id))
551
+ continue; // unknown/stale project id: skip
552
+ try {
553
+ await this.deleteProject(id);
554
+ deletedProjects.push(id);
555
+ }
556
+ catch (err) {
557
+ failures.push({ id, kind: "project", message: errText(err) });
558
+ }
559
+ }
560
+ return { deletedWorkspaces, deletedProjects, failures };
561
+ }
562
+ prompt(id, text, attachments) {
563
+ const live = this.workspaces.get(id);
564
+ if (!live) {
565
+ this.hub.broadcast("error", id, { code: "no_such_ws", message: "unknown workspace", ws: id });
566
+ return;
567
+ }
568
+ if (!live.agent)
569
+ live.agent = this.spawnAgent(live);
570
+ // A fresh chat is "New Chat" until its first prompt; summarize that prompt
571
+ // into a short label (one cheap LLM call, best-effort, non-blocking).
572
+ if (live.record.kind === "chat" && live.record.name === "New Chat" && text.trim()) {
573
+ void this.autoTitleChat(id, text);
574
+ }
575
+ // Persist only attachment metadata (name + type) — never the bytes — so the
576
+ // transcript stays small and replay shows a chip without re-shipping files.
577
+ const attachmentMeta = attachments?.length
578
+ ? attachments.map((a) => ({ name: a.name, mediaType: a.mediaType }))
579
+ : undefined;
580
+ void this.chatlog.append(id, {
581
+ ts: Date.now(),
582
+ role: "user",
583
+ text,
584
+ ...(attachmentMeta ? { attachments: attachmentMeta } : {}),
585
+ });
586
+ // The workspace is working the moment we hand off the prompt. Publish
587
+ // `running` now — the agent only emits its own `running` once at spawn, so a
588
+ // resumed turn would otherwise stay stale at the previous turn's `done`
589
+ // until its first streamed chunk, leaving the client's working timer/badge
590
+ // dark until the first response lands.
591
+ this.setStatus(live, "running");
592
+ live.agent.prompt(text, attachments);
593
+ }
594
+ /**
595
+ * Interrupt the running agent turn. The session itself survives (its id is
596
+ * persisted), so the next prompt resumes the conversation in a fresh agent.
597
+ */
598
+ async stopAgent(id) {
599
+ const live = this.workspaces.get(id);
600
+ if (!live?.agent)
601
+ return;
602
+ const agent = live.agent;
603
+ live.agent = undefined;
604
+ await agent.stop();
605
+ void this.chatlog.append(id, { ts: Date.now(), role: "system", text: "■ stopped" });
606
+ this.setStatus(live, "idle");
607
+ }
608
+ respondPermission(id, requestId, decision) {
609
+ this.workspaces.get(id)?.agent?.respondPermission(requestId, decision);
610
+ // The transcript records only the effect (allow_all lands as an allow); the
611
+ // durable rule itself lives in the allowlist store, not the chat log.
612
+ const resolved = decision === "deny" ? "deny" : "allow";
613
+ void this.chatlog.resolvePermission(id, requestId, resolved);
614
+ }
615
+ answerQuestion(id, requestId, answers, response) {
616
+ this.workspaces.get(id)?.agent?.answerQuestion(requestId, answers, response);
617
+ void this.chatlog.resolveQuestion(id, requestId, answers, response);
618
+ }
619
+ async diff(id) {
620
+ const live = this.workspaces.get(id);
621
+ if (!live)
622
+ return null;
623
+ if (live.record.kind === "chat")
624
+ return [];
625
+ // Freshen origin first, for the same reason as diffStat: a stale merge-base
626
+ // makes the file browser show every file the branch merely merged in, not just
627
+ // what it changed. Throttled + coalesced per repo (no-op right after a poll).
628
+ await maybeSyncRepo(live.record.repo);
629
+ return diffWorktree(live.record.worktreePath, live.record.baseBranch);
630
+ }
631
+ /** Full-codebase file listing for a workspace. */
632
+ async tree(id) {
633
+ const live = this.workspaces.get(id);
634
+ if (!live)
635
+ return null;
636
+ return listTree(live.record.worktreePath);
637
+ }
638
+ /** Read one worktree-relative file (for the in-app viewer). */
639
+ async readFile(id, path) {
640
+ const live = this.workspaces.get(id);
641
+ if (!live)
642
+ return null;
643
+ return readWorktreeFile(live.record.worktreePath, path);
644
+ }
645
+ /** Overwrite one worktree file (in-app editor save). Returns bytes written, or null for an unknown workspace. */
646
+ async writeFile(id, path, content) {
647
+ const live = this.workspaces.get(id);
648
+ if (!live)
649
+ return null;
650
+ return writeWorktreeFile(live.record.worktreePath, path, content);
651
+ }
652
+ /** Get (or create) a device's terminal slot, re-binding it to `socket`. */
653
+ deviceTerm(live, deviceId, socket) {
654
+ const dt = live.terminals.get(deviceId);
655
+ if (dt) {
656
+ // A reconnect (or just a fresh interaction) points the stream at the
657
+ // device's current socket so output follows it across reconnects.
658
+ dt.socket = socket;
659
+ return dt;
660
+ }
661
+ const fresh = { socket };
662
+ live.terminals.set(deviceId, fresh);
663
+ return fresh;
664
+ }
665
+ /**
666
+ * Dispose any terminal keyed by `deviceId` across every workspace. Called when
667
+ * a socket closes, passing that connection's fallback `connId`: a client that
668
+ * sends no stable deviceId gets a fresh connId per connection, so without this
669
+ * its PTY would leak on every reconnect. Terminals under a *stable* deviceId
670
+ * are intentionally left intact so their scrollback survives a reconnect.
671
+ */
672
+ reapDevice(deviceId) {
673
+ for (const live of this.workspaces.values()) {
674
+ const dt = live.terminals.get(deviceId);
675
+ if (!dt)
676
+ continue;
677
+ dt.terminal?.dispose();
678
+ live.terminals.delete(deviceId);
679
+ }
680
+ }
681
+ terminalInput(id, deviceId, data, socket) {
682
+ const live = this.workspaces.get(id);
683
+ if (!live)
684
+ return;
685
+ const dt = this.deviceTerm(live, deviceId, socket);
686
+ if (!dt.terminal || dt.terminal.isExited) {
687
+ dt.terminal = new Terminal(live.record.worktreePath,
688
+ // Stream ONLY to this device's socket — not a broadcast — so each
689
+ // device sees its own pty at its own size.
690
+ (d) => this.hub.sendTo(dt.socket, "terminal.data", id, { ws: id, data: d }), {
691
+ ...dt.pendingSize,
692
+ // On shell exit, drop the dead PTY so the next keystroke respawns a
693
+ // fresh shell instead of writing to a closed process.
694
+ onExit: () => {
695
+ const l = this.workspaces.get(id);
696
+ const e = l?.terminals.get(deviceId);
697
+ if (e)
698
+ e.terminal = undefined;
699
+ },
700
+ });
701
+ }
702
+ dt.terminal.write(data);
703
+ }
704
+ terminalResize(id, deviceId, cols, rows, socket) {
705
+ const live = this.workspaces.get(id);
706
+ if (!live)
707
+ return;
708
+ const dt = this.deviceTerm(live, deviceId, socket);
709
+ // Remember the geometry even if the shell hasn't spawned yet, so the PTY
710
+ // comes up at the right size on first keystroke rather than 80x24. Only
711
+ // THIS device's pty is resized — a peer with a different screen is untouched.
712
+ dt.pendingSize = { cols, rows };
713
+ dt.terminal?.resize(cols, rows);
714
+ }
715
+ /** This device's retained PTY scrollback ("" until it has started one). */
716
+ terminalSnapshot(id, deviceId, socket) {
717
+ const live = this.workspaces.get(id);
718
+ if (!live)
719
+ return null;
720
+ // Binds the stream to `socket` so live output resumes after a reconnect,
721
+ // even before the device sends its next keystroke.
722
+ const dt = this.deviceTerm(live, deviceId, socket);
723
+ return dt.terminal?.snapshot() ?? "";
724
+ }
725
+ async gitStatus(id, sync = false) {
726
+ const live = this.workspaces.get(id);
727
+ if (!live)
728
+ return null;
729
+ // Chats have no git — no status to report.
730
+ if (live.record.kind === "chat")
731
+ return null;
732
+ // Refresh the base repo's remote-tracking refs before measuring against
733
+ // them, so ahead/behind and the line counts reflect what's actually on the
734
+ // remote rather than a snapshot from whenever the repo was last fetched.
735
+ // Throttled + coalesced per base repo (see maybeSyncRepo): a project with N
736
+ // worktrees all polling still costs one fetch per interval, not N. `sync` is
737
+ // set when the client is opening/refreshing the worktree on purpose — it
738
+ // forces the fetch past the throttle so a just-pushed base is reflected on
739
+ // the click rather than up to an interval later.
740
+ await maybeSyncRepo(live.record.repo, { force: sync });
741
+ // Self-heal: the rename_branch action lets the agent rename its own branch,
742
+ // which would leave record.branch (used for push/PR) stale. Re-sync from the
743
+ // worktree's real HEAD on each status poll. When the branch was renamed to
744
+ // something other than what it was created as, the new name is the
745
+ // meaningful identity for this work, so track it as the sidebar label too
746
+ // (the created label was a starting placeholder). broadcastWorkspaceList
747
+ // below fans the rename out to every client.
748
+ const cur = await currentBranch(live.record.worktreePath);
749
+ if (cur && cur !== live.record.branch) {
750
+ live.record.branch = cur;
751
+ // Track the label to the branch (minus any owner/account prefix — see
752
+ // branchLabel) — but not to a bare "HEAD", which is what a detached
753
+ // checkout (mid-rebase/bisect) reports; that's a transient git state, not a
754
+ // rename the user should see in the sidebar.
755
+ if (cur !== "HEAD")
756
+ live.record.name = branchLabel(cur);
757
+ await this.persist();
758
+ }
759
+ // Auto-archive on merge: when enabled and the branch's PR has merged, tear
760
+ // the workspace down (honoring deleteBranchOnArchive) and report it gone so
761
+ // clients drop it. Gated on the flag so the extra `gh` call only runs for
762
+ // opted-in workspaces. Returns null → the git.status handler re-broadcasts
763
+ // the workspace list.
764
+ if (this.appConfig.get().git.archiveOnMerge && (await detectMerged(live.record))) {
765
+ await this.archive(id);
766
+ return null;
767
+ }
768
+ const result = await gitStatus(live.record);
769
+ // Always match the label to the remote: once a branch has a PR (open or
770
+ // merged), that PR's title is the human-facing identity this work lands as,
771
+ // so adopt it as the sidebar label. This forward-heals a label that was
772
+ // previously set to the full branch, and keeps a friendly creation name in
773
+ // sync with the PR the user maintains on GitHub. Reuses the prInfo cache
774
+ // gitStatus just populated, so it costs no extra `gh` call.
775
+ if (result.hasPr || result.prMerged) {
776
+ const pr = await prInfo(live.record);
777
+ const label = pr.title ? pr.title.trim() : "";
778
+ if (label && label !== live.record.name) {
779
+ live.record.name = label;
780
+ await this.persist();
781
+ }
782
+ }
783
+ // This poll is the client's periodic "is anything new?" — the natural beat
784
+ // to reconcile the sidebar. It may have just renamed the label (above),
785
+ // freshened the base (maybeSyncRepo, so a since-landed Resolve/merge moved
786
+ // the fork point and the line counts with it), or simply be the first poll
787
+ // after edits that no other path fanned out. Push the fresh rows; the
788
+ // change-check no-ops when nothing actually moved, so a steady poll on an
789
+ // untouched worktree stays quiet.
790
+ void this.broadcastWorkspaceList();
791
+ return result;
792
+ }
793
+ async gitCommit(id, message) {
794
+ const live = this.workspaces.get(id);
795
+ if (!live)
796
+ return null;
797
+ const result = await gitCommit(live.record, message);
798
+ if (result.ok)
799
+ this.events?.emitSystem({ kind: "commit", ws: id });
800
+ return result;
801
+ }
802
+ async gitPush(id) {
803
+ const live = this.workspaces.get(id);
804
+ if (!live)
805
+ return null;
806
+ const result = await gitPush(live.record, this.appConfig.get().git.worktreePushAutoSetupRemote);
807
+ if (result.ok)
808
+ this.events?.emitSystem({ kind: "push", ws: id });
809
+ return result;
810
+ }
811
+ async gitResolve(id) {
812
+ const live = this.workspaces.get(id);
813
+ if (!live)
814
+ return null;
815
+ return gitResolve(live.record);
816
+ }
817
+ async gitMerge(id, action, paths) {
818
+ const live = this.workspaces.get(id);
819
+ if (!live)
820
+ return null;
821
+ return gitMerge(live.record, action, paths);
822
+ }
823
+ async prCreate(id, title, body) {
824
+ const live = this.workspaces.get(id);
825
+ if (!live)
826
+ return null;
827
+ const result = await prCreate(live.record, title, body);
828
+ if (result.ok) {
829
+ // The PR carries the meaningful name for this work — adopt its title as
830
+ // the sidebar label when it differs from whatever the worktree was created
831
+ // as, then fan the change out to every client.
832
+ const prTitle = title.trim();
833
+ if (prTitle && prTitle !== live.record.name) {
834
+ live.record.name = prTitle;
835
+ await this.persist();
836
+ void this.broadcastWorkspaceList();
837
+ }
838
+ this.events?.emitSystem({
839
+ kind: "pr.created",
840
+ ws: id,
841
+ context: result.url ? { url: result.url } : undefined,
842
+ });
843
+ }
844
+ return result;
845
+ }
846
+ /** Merge the workspace branch's open PR on the remote (`gh pr merge`). */
847
+ async prMerge(id) {
848
+ const live = this.workspaces.get(id);
849
+ if (!live)
850
+ return null;
851
+ return prMerge(live.record);
852
+ }
853
+ /**
854
+ * Open a GitHub issue from a workspace's output — the mirror of `prCreate`.
855
+ * The result rides the shared `git.result` channel (`op: "issue"`) so clients
856
+ * reuse their existing PR-create plumbing (busy state, result banner).
857
+ */
858
+ async issueCreate(id, title, body) {
859
+ const live = this.workspaces.get(id);
860
+ if (!live)
861
+ return null;
862
+ const ws = live.record.id;
863
+ const trimmed = title.trim();
864
+ if (!trimmed) {
865
+ return { ws, op: "issue", ok: false, message: "issue title is empty" };
866
+ }
867
+ try {
868
+ const url = await createIssue(live.record.worktreePath, trimmed, body ?? "");
869
+ this.events?.emitSystem({
870
+ kind: "issue.created",
871
+ ws: id,
872
+ context: url ? { url } : undefined,
873
+ });
874
+ return { ws, op: "issue", ok: true, message: "issue opened", url };
875
+ }
876
+ catch (err) {
877
+ return {
878
+ ws,
879
+ op: "issue",
880
+ ok: false,
881
+ message: err instanceof Error ? err.message : "creating issue failed",
882
+ };
883
+ }
884
+ }
885
+ async open(id, target) {
886
+ const live = this.workspaces.get(id);
887
+ if (!live)
888
+ return;
889
+ await openWorktree(live.record, target);
890
+ }
891
+ // --- internals ---
892
+ spawnAgent(live) {
893
+ // prompts.general is appended to every session's system prompt (empty →
894
+ // omitted, so no-config behavior is unchanged).
895
+ const general = this.appConfig.get().prompts.general.trim();
896
+ const ctx = {
897
+ workspaceId: live.record.id,
898
+ worktreePath: live.record.worktreePath,
899
+ resumeSessionId: live.record.sessionId,
900
+ model: live.record.model,
901
+ appendSystemPrompt: general || undefined,
902
+ allowlist: this.allowlist,
903
+ };
904
+ // AGENT_MODE=mock forces the scripted backend for every workspace (testing
905
+ // /CI, no credits). Otherwise the selected model's provider picks the
906
+ // backend: Claude via the Agent SDK, OpenAI via the Codex CLI, or an
907
+ // OpenRouter-hosted model via the same Agent SDK pointed at OpenRouter.
908
+ // Inferred as the union of the concrete classes (all EventEmitters), so
909
+ // `.on` is available without a cast; the return type narrows to Agent.
910
+ const provider = providerFor(live.record.model);
911
+ // Resolve the session's credential from the store (stored key → env → SSO):
912
+ // - codex/openai: inject OPENAI_API_KEY when present, else let the Codex CLI
913
+ // use its cached ChatGPT login.
914
+ // - claude/anthropic: inject ANTHROPIC_API_KEY when present, else let the
915
+ // Agent SDK use its cached Claude Code login. (undefined sdkEnv → no
916
+ // override, the native SSO path.)
917
+ // - openrouter: pass the resolved key straight through (no login fallback).
918
+ const agent = AGENT_MODE === "mock"
919
+ ? new MockAgent(ctx)
920
+ : provider === "codex"
921
+ ? new CodexAgent(ctx, envForKey("OPENAI_API_KEY", this.credentials.getKey("openai")))
922
+ : provider === "openrouter"
923
+ ? new OpenRouterAgent(ctx, this.credentials.getKey("openrouter"))
924
+ : new ClaudeAgent(ctx, envForKey("ANTHROPIC_API_KEY", this.credentials.getKey("anthropic")));
925
+ agent.on("event", (e) => this.onAgentEvent(live, e));
926
+ return agent;
927
+ }
928
+ onAgentEvent(live, e) {
929
+ const id = live.record.id;
930
+ switch (e.kind) {
931
+ case "assistant":
932
+ this.hub.broadcast("event.assistant_message", id, { ws: id, text: e.text });
933
+ void this.chatlog.append(id, { ts: Date.now(), role: "assistant", text: e.text });
934
+ break;
935
+ case "tool_use":
936
+ this.hub.broadcast("event.tool_use", id, {
937
+ ws: id,
938
+ tool: e.tool,
939
+ inputSummary: e.inputSummary,
940
+ });
941
+ void this.chatlog.append(id, {
942
+ ts: Date.now(),
943
+ role: "tool",
944
+ text: `${e.tool}: ${e.inputSummary}`,
945
+ });
946
+ break;
947
+ case "permission":
948
+ this.hub.broadcast("event.permission_request", id, {
949
+ ws: id,
950
+ requestId: e.requestId,
951
+ tool: e.tool,
952
+ inputSummary: e.inputSummary,
953
+ });
954
+ void this.chatlog.append(id, {
955
+ ts: Date.now(),
956
+ role: "permission",
957
+ text: e.inputSummary,
958
+ requestId: e.requestId,
959
+ tool: e.tool,
960
+ });
961
+ break;
962
+ case "permission_resolved":
963
+ // A new "allow all" rule retroactively cleared this request — tell every
964
+ // client to collapse its card, and stamp the transcript so a reconnect
965
+ // (or another device) reflects the same outcome.
966
+ this.hub.broadcast("event.permission_resolved", id, {
967
+ ws: id,
968
+ requestId: e.requestId,
969
+ decision: e.decision,
970
+ });
971
+ void this.chatlog.resolvePermission(id, e.requestId, e.decision);
972
+ break;
973
+ case "ask_question":
974
+ this.hub.broadcast("event.ask_question", id, {
975
+ ws: id,
976
+ requestId: e.requestId,
977
+ questions: e.questions,
978
+ });
979
+ void this.chatlog.append(id, {
980
+ ts: Date.now(),
981
+ role: "question",
982
+ text: e.questions.map((q) => q.question).join(" · "),
983
+ requestId: e.requestId,
984
+ questions: e.questions,
985
+ });
986
+ break;
987
+ case "session":
988
+ live.record.sessionId = e.sessionId;
989
+ void this.persist();
990
+ this.hub.broadcast("event.session", id, {
991
+ ws: id,
992
+ sessionId: e.sessionId,
993
+ status: live.record.status,
994
+ });
995
+ break;
996
+ case "status":
997
+ this.setStatus(live, e.status);
998
+ break;
999
+ case "done": {
1000
+ const usageText = e.usage ? formatTurnUsage(e.usage) : undefined;
1001
+ this.hub.broadcast("event.agent_done", id, { ws: id, summary: e.summary, usageText });
1002
+ void this.chatlog.append(id, {
1003
+ ts: Date.now(),
1004
+ role: "system",
1005
+ text: `✓ done${e.summary ? ` — ${e.summary}` : ""}` +
1006
+ (usageText ? ` · ${usageText}` : ""),
1007
+ });
1008
+ // The turn almost certainly touched files, so the sidebar diff-stat is
1009
+ // now stale — recompute and fan the fresh counts out (no-ops if the turn
1010
+ // changed nothing). This keeps a background workspace's counter live
1011
+ // even when no client is polling its git.status.
1012
+ void this.broadcastWorkspaceList();
1013
+ // Reactive-trigger seam: an agent turn just finished in this workspace.
1014
+ this.events?.emitSystem({
1015
+ kind: "agent.done",
1016
+ ws: id,
1017
+ context: e.summary ? { summary: e.summary } : undefined,
1018
+ });
1019
+ break;
1020
+ }
1021
+ case "limit":
1022
+ // Surface the limit so a client can offer to schedule a restart job at
1023
+ // the reset time. Stamp the transcript so a reconnecting client still
1024
+ // sees it happened (the actionable card rides the live event below).
1025
+ this.hub.broadcast("event.session_limit", id, {
1026
+ ws: id,
1027
+ message: e.message,
1028
+ resetAt: e.resetAt,
1029
+ });
1030
+ // Persist as a `limit` entry (not a plain system line) so a reconnecting
1031
+ // client replays the actionable restart card, not just static text.
1032
+ void this.chatlog.append(id, {
1033
+ ts: Date.now(),
1034
+ role: "limit",
1035
+ text: e.message,
1036
+ resetAt: e.resetAt,
1037
+ });
1038
+ // Like `error`, a limit ends the turn without a `done`; finalize any
1039
+ // dispatched job run so a scheduled restart that re-hits the limit
1040
+ // doesn't leave a run (or ephemeral worktree) dangling.
1041
+ this.events?.emitSystem({ kind: "agent.error", ws: id, context: { error: e.message } });
1042
+ break;
1043
+ case "error":
1044
+ this.hub.broadcast("error", id, { code: "agent_error", message: e.message, ws: id });
1045
+ // A failed turn emits no `done`; tell the scheduler so a dispatched job
1046
+ // run finalizes (and its ephemeral worktree is not orphaned).
1047
+ this.events?.emitSystem({
1048
+ kind: "agent.error",
1049
+ ws: id,
1050
+ context: e.message ? { error: e.message } : undefined,
1051
+ });
1052
+ break;
1053
+ }
1054
+ }
1055
+ setStatus(live, status) {
1056
+ live.record.status = status;
1057
+ const id = live.record.id;
1058
+ // status rides on event.session so the client updates the badge uniformly.
1059
+ this.hub.broadcast("event.session", id, {
1060
+ ws: id,
1061
+ sessionId: live.record.sessionId ?? "",
1062
+ status,
1063
+ });
1064
+ }
1065
+ async shutdown() {
1066
+ for (const live of this.workspaces.values()) {
1067
+ await live.agent?.stop();
1068
+ for (const dt of live.terminals.values())
1069
+ dt.terminal?.dispose();
1070
+ }
1071
+ await this.persist();
1072
+ }
1073
+ }
1074
+ /**
1075
+ * A one-key env override for an agent subprocess, or `undefined` when no key is
1076
+ * set — so the agent constructor leaves the environment untouched and its
1077
+ * backend falls back to its own cached login (the SSO path).
1078
+ */
1079
+ function envForKey(name, key) {
1080
+ return key ? { [name]: key } : undefined;
1081
+ }
1082
+ /** Short human-readable reason from a thrown value, for batch-delete failures. */
1083
+ function errText(err) {
1084
+ return (err instanceof Error ? err.message : String(err)).slice(0, 500);
1085
+ }
1086
+ //# sourceMappingURL=workspace.js.map