@bridge4dev/runner 0.11.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/adapters/claude.d.ts +19 -0
  4. package/dist/adapters/claude.js +631 -0
  5. package/dist/adapters/codex-home.d.ts +61 -0
  6. package/dist/adapters/codex-home.js +234 -0
  7. package/dist/adapters/codex-protocol.d.ts +59 -0
  8. package/dist/adapters/codex-protocol.js +204 -0
  9. package/dist/adapters/codex.d.ts +61 -0
  10. package/dist/adapters/codex.js +1406 -0
  11. package/dist/adapters/types.d.ts +183 -0
  12. package/dist/adapters/types.js +5 -0
  13. package/dist/async-queue.d.ts +11 -0
  14. package/dist/async-queue.js +50 -0
  15. package/dist/attachments.d.ts +72 -0
  16. package/dist/attachments.js +149 -0
  17. package/dist/auth-relay.d.ts +57 -0
  18. package/dist/auth-relay.js +289 -0
  19. package/dist/config.d.ts +96 -0
  20. package/dist/config.js +73 -0
  21. package/dist/fsview.d.ts +20 -0
  22. package/dist/fsview.js +122 -0
  23. package/dist/git.d.ts +54 -0
  24. package/dist/git.js +168 -0
  25. package/dist/gitops.d.ts +136 -0
  26. package/dist/gitops.js +596 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +352 -0
  29. package/dist/journal.d.ts +118 -0
  30. package/dist/journal.js +300 -0
  31. package/dist/log.d.ts +7 -0
  32. package/dist/log.js +19 -0
  33. package/dist/paths.d.ts +7 -0
  34. package/dist/paths.js +33 -0
  35. package/dist/policy.d.ts +17 -0
  36. package/dist/policy.js +272 -0
  37. package/dist/protocol.d.ts +754 -0
  38. package/dist/protocol.js +154 -0
  39. package/dist/self-update.d.ts +75 -0
  40. package/dist/self-update.js +221 -0
  41. package/dist/status-file.d.ts +14 -0
  42. package/dist/status-file.js +29 -0
  43. package/dist/supervisor.d.ts +216 -0
  44. package/dist/supervisor.js +1648 -0
  45. package/dist/version.d.ts +2 -0
  46. package/dist/version.js +3 -0
  47. package/dist/ws-client.d.ts +30 -0
  48. package/dist/ws-client.js +171 -0
  49. package/package.json +52 -0
@@ -0,0 +1,1648 @@
1
+ import { log } from './log.js';
2
+ import { maskSecrets, maskString } from './policy.js';
3
+ import { JournalStore } from './journal.js';
4
+ import { deleteSessionBranch, ensureSessionWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
5
+ import { applySession, gitCommit, gitDiff, gitLog, gitShow, gitStatus, revertApply, } from './gitops.js';
6
+ import { fsView } from './fsview.js';
7
+ import { agentAuthStatuses, AuthRelay } from './auth-relay.js';
8
+ import { selfUpdate } from './self-update.js';
9
+ import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
10
+ export class Supervisor {
11
+ ws;
12
+ opts;
13
+ static ORPHAN_MESSAGE_CAP = 10;
14
+ /** A finished session's journal is kept this long for a late reconnect. */
15
+ static JOURNAL_TTL_MS = 72 * 3_600_000;
16
+ /** Backstop: events the API will never accept must not pile up forever. */
17
+ static JOURNAL_HARD_TTL_MS = 30 * 24 * 3_600_000;
18
+ sessions = new Map();
19
+ /**
20
+ * How many agents may run at once, as told by the API (session 8). Starts at
21
+ * 1 so a runner talking to an API that predates the field behaves exactly as
22
+ * it did before: one agent, others parked.
23
+ */
24
+ serverMaxSessions = 1;
25
+ /** Messages for sessions this runner does not track yet, keyed by session. */
26
+ orphanMessages = new Map();
27
+ journals;
28
+ authRelay = new AuthRelay();
29
+ /** Serialises repo-mutating git commands per workspace repo (QA-99 MAJOR-1). */
30
+ repoLocks = new Map();
31
+ /** An update is installing right now — a second one would fight it. */
32
+ selfUpdateInFlight = false;
33
+ constructor(ws, opts) {
34
+ this.ws = ws;
35
+ this.opts = opts;
36
+ this.journals = opts.journals ?? new JournalStore();
37
+ ws.on('frame', (frame) => {
38
+ void this.onFrame(frame).catch((error) => log.error('supervisor: frame handler failed', { type: frame.type, error: String(error) }));
39
+ });
40
+ }
41
+ get activeSessionIds() {
42
+ return [...this.sessions.keys()];
43
+ }
44
+ async onFrame(frame) {
45
+ switch (frame.type) {
46
+ case 'hello_ack':
47
+ this.setMaxSessions(frame.maxSessions);
48
+ await this.reconcile(frame.sessions);
49
+ break;
50
+ case 'server_settings':
51
+ this.setMaxSessions(frame.maxSessions);
52
+ break;
53
+ case 'session_start':
54
+ await this.startSession(frame.session);
55
+ break;
56
+ case 'session_message':
57
+ await this.onUserMessage(frame.sessionId, frame.text, frame.attachments);
58
+ break;
59
+ case 'permission_answer': {
60
+ const running = this.sessions.get(frame.sessionId);
61
+ running?.session?.answerPermission(frame.requestId, frame.allow, frame.note);
62
+ break;
63
+ }
64
+ case 'session_stop':
65
+ this.stopSession(frame.sessionId);
66
+ break;
67
+ case 'session_interrupt':
68
+ await this.interruptSession(frame.sessionId);
69
+ break;
70
+ case 'session_settings':
71
+ await this.applySettings(frame.sessionId, frame.model, frame.mode, frame.effort);
72
+ break;
73
+ case 'command':
74
+ await this.runCommand(frame);
75
+ break;
76
+ case 'event_ack':
77
+ case 'event_nack': {
78
+ if (frame.type === 'event_nack') {
79
+ log.warn('supervisor: event nacked, retiring seq', {
80
+ sessionId: frame.sessionId,
81
+ seq: frame.seq,
82
+ reason: frame.reason,
83
+ });
84
+ }
85
+ const journal = this.journals.open(frame.sessionId);
86
+ journal.ack(frame.seq); // nack retires the seq too — it is permanent
87
+ const running = this.sessions.get(frame.sessionId);
88
+ if (!running && journal.unacked().length === 0) {
89
+ this.journals.closeAndDelete(frame.sessionId);
90
+ }
91
+ break;
92
+ }
93
+ case 'error':
94
+ log.warn('supervisor: error frame from API', { message: frame.message });
95
+ break;
96
+ default:
97
+ break;
98
+ }
99
+ }
100
+ // ─── Lifecycle ─────────────────────────────────────────────────────
101
+ async startSession(descriptor) {
102
+ const existing = this.sessions.get(descriptor.id);
103
+ if (existing) {
104
+ // A resume (higher epoch) can land while the previous life is still
105
+ // winding down — the agent-error FAILED path in particular leaves the
106
+ // process streaming without `stopRequested`. Queue the relaunch and force
107
+ // the wind-down instead of silently dropping the new descriptor.
108
+ if (descriptor.epoch > existing.epoch) {
109
+ existing.pendingRestart = descriptor;
110
+ existing.stopRequested = true;
111
+ existing.session?.stop();
112
+ }
113
+ return;
114
+ }
115
+ // Up to `maxSessions` agents at once (session 8). Sessions idling after a
116
+ // finished turn (REVIEW / WAITING_INPUT) don't hold a slot: they are parked
117
+ // — the provider session survives on disk and relaunches on the next message.
118
+ if (!this.ensureCapacity(descriptor.id)) {
119
+ const limit = this.maxSessions;
120
+ this.reportStatus(descriptor.id, 'FAILED', {
121
+ errorMessage: limit === 1
122
+ ? 'Runner already has an active session'
123
+ : `Runner is already running ${limit} active sessions — stop one first`,
124
+ });
125
+ return;
126
+ }
127
+ if (!this.opts.adapters[descriptor.agent]) {
128
+ this.reportStatus(descriptor.id, 'FAILED', {
129
+ errorMessage: `${AGENT_LABELS[descriptor.agent] ?? descriptor.agent} is not installed on this server`,
130
+ });
131
+ return;
132
+ }
133
+ const running = {
134
+ descriptor,
135
+ journal: this.journals.open(descriptor.id),
136
+ session: null,
137
+ lastReported: descriptor.status,
138
+ costUsd: descriptor.costUsd,
139
+ costBaseUsd: descriptor.costUsd,
140
+ stopRequested: false,
141
+ parkRequested: false,
142
+ pendingMessages: [],
143
+ activeMs: descriptor.activeMsBase,
144
+ extraBudgetMinutes: descriptor.extraBudgetMinutes,
145
+ epoch: descriptor.epoch,
146
+ mode: descriptor.mode,
147
+ ...(descriptor.model ? { model: descriptor.model } : {}),
148
+ ...(descriptor.effort ? { effort: descriptor.effort } : {}),
149
+ lastPrompt: '',
150
+ };
151
+ // Seed the journal counter past whatever the API already stored: a wiped
152
+ // runner state dir would otherwise restart at seq 1 and every replayed
153
+ // event would be swallowed as a duplicate (QA-99 MAJOR-3).
154
+ running.journal.ensureSeqAbove(descriptor.lastSeq);
155
+ // Messages this session was handed before the daemon last stopped. The API
156
+ // considers them delivered, so the journal is the only place they still
157
+ // exist (session 9).
158
+ running.pendingMessages.push(...running.journal.pending());
159
+ this.sessions.set(descriptor.id, running);
160
+ // Messages that arrived for a session this runner did not know yet.
161
+ const orphaned = this.orphanMessages.get(descriptor.id);
162
+ if (orphaned) {
163
+ this.orphanMessages.delete(descriptor.id);
164
+ for (const message of orphaned) {
165
+ running.pendingMessages.push(running.journal.appendPending(message.text, message.attachments));
166
+ }
167
+ }
168
+ try {
169
+ // `worktree add` writes into the shared .git (registration + prune), so
170
+ // it takes the same repo lock as commit/apply/revert.
171
+ const { branch, worktreePath } = await this.withRepoLockFor(descriptor.workspace.path, () => ensureSessionWorktree(descriptor.workspace.path, descriptor.id, descriptor.branchHint, {
172
+ requireExistingBranch: hasWorkToResume(descriptor),
173
+ }));
174
+ running.branch = branch;
175
+ running.worktreePath = worktreePath;
176
+ }
177
+ catch (error) {
178
+ this.reportStatus(descriptor.id, 'FAILED', {
179
+ errorMessage: `Failed to prepare git worktree: ${maskSecretText(error)}`,
180
+ });
181
+ this.sessions.delete(descriptor.id);
182
+ return;
183
+ }
184
+ // A session that is already past STARTING (re-sent because this runner
185
+ // asked for it) resumes on the next message instead of replaying its
186
+ // original prompt.
187
+ if (descriptor.status === 'STARTING') {
188
+ this.launchAgent(running, composeInitialPrompt(descriptor), null);
189
+ }
190
+ else {
191
+ if (descriptor.epoch > 0) {
192
+ // The API owns the resume transition; the feed marker has to come from
193
+ // here because the runner is the only writer of the event seq.
194
+ this.sendEvent(running, 'system_note', {
195
+ text: 'Session resumed — send a message to continue where the agent left off.',
196
+ });
197
+ }
198
+ running.lastReported = descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT';
199
+ this.reportStatus(descriptor.id, running.lastReported, {
200
+ branch: running.branch,
201
+ worktreePath: running.worktreePath,
202
+ activeMs: running.activeMs,
203
+ });
204
+ }
205
+ this.flushPendingMessages(running);
206
+ }
207
+ /**
208
+ * Spin the adapter up — for a fresh session, a resume-on-next-message, or a
209
+ * free CHAT session with no prompt at all (the agent boots, reports its
210
+ * capabilities and waits for the first message).
211
+ *
212
+ * Returns whether an agent process actually started: a caller holding a user
213
+ * message needs to know, because a refused launch means the message has to
214
+ * stay queued rather than be marked delivered (session 9).
215
+ */
216
+ launchAgent(running, prompt, resumeId) {
217
+ const { descriptor } = running;
218
+ const adapter = this.opts.adapters[descriptor.agent];
219
+ if (!adapter || !running.worktreePath || !running.branch)
220
+ return false;
221
+ // An exhausted USD budget must not relaunch $0.01-floor processes (QA-96 F4).
222
+ // Codex reports no cost at all, so its costUsd never leaves 0 — gating on it
223
+ // would be a limit that can never fire while the UI shows $0.00. Those
224
+ // sessions are bounded by budgetMinutes instead.
225
+ if (reportsCost(descriptor.agent) &&
226
+ descriptor.workspace.budgetUsd !== null &&
227
+ descriptor.workspace.budgetUsd - running.costBaseUsd <= 0) {
228
+ // Not FAILED: nothing went wrong, the session simply spent its allowance.
229
+ // «Продолжить» raises the workspace budget and picks it back up.
230
+ running.budgetSpent = true;
231
+ this.sendEvent(running, 'notice', {
232
+ level: 'warn',
233
+ text: `The session has spent its whole $${descriptor.workspace.budgetUsd} budget. Raise the workspace budget to continue.`,
234
+ });
235
+ this.reportStatus(descriptor.id, 'STOPPED', {
236
+ costUsd: running.costUsd,
237
+ endReason: 'COST_BUDGET',
238
+ activeMs: running.activeMs,
239
+ errorMessage: `Session budget ($${descriptor.workspace.budgetUsd}) is exhausted`,
240
+ });
241
+ this.sessions.delete(descriptor.id);
242
+ return false;
243
+ }
244
+ // The time budget is already spent: relaunching would burn a process for
245
+ // nothing and immediately stop again.
246
+ if (running.budgetSpent) {
247
+ this.sendEvent(running, 'notice', {
248
+ level: 'warn',
249
+ text: 'The time budget is used up — press «Продолжить» to give the agent more time.',
250
+ });
251
+ return false;
252
+ }
253
+ running.lastPrompt = prompt;
254
+ running.session = adapter.startSession({
255
+ sessionId: descriptor.id,
256
+ cwd: running.worktreePath,
257
+ ...(prompt ? { prompt } : {}),
258
+ trustMode: descriptor.workspace.trustMode,
259
+ mode: running.mode,
260
+ ...(running.model ? { model: running.model } : {}),
261
+ ...(running.effort ? { effort: running.effort } : {}),
262
+ ...(resumeId ? { resumeProviderSessionId: resumeId } : {}),
263
+ // Descriptor MCP (auto-issued per-workspace key) wins over config.toml.
264
+ ...((descriptor.mcp ?? this.opts.mcp) ? { mcp: descriptor.mcp ?? this.opts.mcp } : {}),
265
+ // The SDK budget is per-process; hand the RESIDUAL session budget down.
266
+ ...(reportsCost(descriptor.agent) && descriptor.workspace.budgetUsd !== null
267
+ ? { maxBudgetUsd: Math.max(0.01, descriptor.workspace.budgetUsd - running.costBaseUsd) }
268
+ : {}),
269
+ });
270
+ // No prompt → nothing is running yet: the agent is up and waiting for the
271
+ // user's first message (free CHAT session). reportStatus drives the budget
272
+ // clock, so this call is also what starts (or does not start) billing.
273
+ this.reportStatus(descriptor.id, prompt ? 'RUNNING' : 'WAITING_INPUT', {
274
+ branch: running.branch,
275
+ worktreePath: running.worktreePath,
276
+ });
277
+ void this.pumpEvents(running);
278
+ return true;
279
+ }
280
+ // ─── Time budget (session 7) ───────────────────────────────────────
281
+ //
282
+ // The budget measures the AGENT's working time, not the calendar. Everything
283
+ // below hangs off one predicate and one sync point so there is no way to add
284
+ // a new waiting state and silently keep billing it.
285
+ /** Warn the user when this share of the budget is gone. */
286
+ static BUDGET_WARN_RATIO = 0.8;
287
+ /** Is the agent actually working right now (i.e. should the clock run)? */
288
+ static isBillable(running) {
289
+ return running.lastReported === 'RUNNING' || running.lastReported === 'STARTING';
290
+ }
291
+ /**
292
+ * Start or stop the budget clock to match the session's current state.
293
+ *
294
+ * MUST be called from every place that changes `lastReported` — not just
295
+ * reportStatus. `sendEvent` sets WAITING_PERMISSION directly and the API
296
+ * infers WAITING_INPUT from a `question` event, and those two states are
297
+ * exactly where an ask-mode session spends its time waiting for a human.
298
+ */
299
+ syncBudgetClock(running) {
300
+ const billable = Supervisor.isBillable(running) && Boolean(running.session);
301
+ if (billable) {
302
+ if (running.activeSince === undefined)
303
+ running.activeSince = Date.now();
304
+ this.armBudgetTimers(running);
305
+ return;
306
+ }
307
+ if (running.activeSince !== undefined) {
308
+ running.activeMs += Date.now() - running.activeSince;
309
+ delete running.activeSince;
310
+ }
311
+ this.clearBudgetTimers(running);
312
+ }
313
+ /**
314
+ * Agent time spent so far, INCLUDING the slice currently in flight. Reading
315
+ * `activeMs` alone made every RUNNING→RUNNING re-report (a follow-up message,
316
+ * a reconnect) re-arm the timer from a stale figure, so a busy session could
317
+ * slide past its limit indefinitely.
318
+ */
319
+ static spentMs(running) {
320
+ return (running.activeMs + (running.activeSince === undefined ? 0 : Date.now() - running.activeSince));
321
+ }
322
+ /** Total budget in ms, or null when the workspace has no time limit. */
323
+ budgetMsFor(running) {
324
+ const base = running.descriptor.workspace.budgetMinutes;
325
+ if (base === null)
326
+ return null;
327
+ return (base + (running.extraBudgetMinutes ?? 0)) * 60_000;
328
+ }
329
+ armBudgetTimers(running) {
330
+ const budgetMs = this.budgetMsFor(running);
331
+ if (budgetMs === null)
332
+ return;
333
+ this.clearBudgetTimers(running);
334
+ const remaining = budgetMs - Supervisor.spentMs(running);
335
+ if (remaining <= 0) {
336
+ // Deferred, NOT called inline. armBudgetTimers runs inside reportStatus,
337
+ // and pausing from here would re-enter reportStatus and then let the
338
+ // outer call send its own (now stale) status after the STOPPED frame.
339
+ const immediate = setTimeout(() => this.pauseForBudget(running), 0);
340
+ immediate.unref();
341
+ running.budgetTimer = immediate;
342
+ return;
343
+ }
344
+ if (!running.budgetWarned) {
345
+ const warnIn = remaining - budgetMs * (1 - Supervisor.BUDGET_WARN_RATIO);
346
+ if (warnIn > 0) {
347
+ running.budgetWarnTimer = setTimeout(() => {
348
+ running.budgetWarned = true;
349
+ const leftMin = Math.max(1, Math.round((budgetMs - Supervisor.spentMs(running)) / 60_000));
350
+ this.sendEvent(running, 'notice', {
351
+ level: 'warn',
352
+ text: `The agent has used ${Math.round(Supervisor.BUDGET_WARN_RATIO * 100)}% of its ${Math.round(budgetMs / 60_000)} minutes of working time — about ${leftMin} min left.`,
353
+ });
354
+ }, warnIn);
355
+ running.budgetWarnTimer.unref();
356
+ }
357
+ else {
358
+ running.budgetWarned = true;
359
+ }
360
+ }
361
+ running.budgetTimer = setTimeout(() => this.pauseForBudget(running), remaining);
362
+ running.budgetTimer.unref();
363
+ }
364
+ clearBudgetTimers(running) {
365
+ if (running.budgetTimer) {
366
+ clearTimeout(running.budgetTimer);
367
+ delete running.budgetTimer;
368
+ }
369
+ if (running.budgetWarnTimer) {
370
+ clearTimeout(running.budgetWarnTimer);
371
+ delete running.budgetWarnTimer;
372
+ }
373
+ }
374
+ /**
375
+ * The agent has worked for as long as it was allowed. Stop it cleanly and
376
+ * keep everything needed to pick it back up — this is NOT a failure, and
377
+ * reporting it as one is what made five of the first twelve prod sessions
378
+ * look broken.
379
+ */
380
+ pauseForBudget(running) {
381
+ const { descriptor } = running;
382
+ if (running.budgetSpent || isTerminal(running.lastReported))
383
+ return;
384
+ running.budgetSpent = true;
385
+ this.clearBudgetTimers(running);
386
+ if (running.activeSince !== undefined) {
387
+ running.activeMs += Date.now() - running.activeSince;
388
+ delete running.activeSince;
389
+ }
390
+ const minutes = Math.round((this.budgetMsFor(running) ?? 0) / 60_000);
391
+ log.warn('supervisor: time budget spent', { sessionId: descriptor.id, minutes });
392
+ // Interrupt so the worktree is left between tool calls rather than mid-edit.
393
+ // Never awaited: for Codex this is a no-op between turns and a request with
394
+ // a 60s ceiling during one — neither belongs in a timer callback.
395
+ void Promise.resolve(running.session?.interrupt()).catch(() => undefined);
396
+ this.sendEvent(running, 'notice', {
397
+ level: 'warn',
398
+ text: `The agent worked for its full ${minutes} minutes and was paused. Press «Продолжить» to give it more time.`,
399
+ });
400
+ running.stopRequested = true;
401
+ this.reportStatus(descriptor.id, 'STOPPED', {
402
+ costUsd: running.costUsd,
403
+ endReason: 'TIME_BUDGET',
404
+ activeMs: running.activeMs,
405
+ errorMessage: `Time budget (${minutes} min of agent work) reached`,
406
+ });
407
+ running.session?.stop();
408
+ }
409
+ async pumpEvents(running) {
410
+ const session = running.session;
411
+ if (!session)
412
+ return;
413
+ const { descriptor } = running;
414
+ try {
415
+ for await (const event of session.events) {
416
+ this.forwardEvent(running, event);
417
+ }
418
+ }
419
+ catch (error) {
420
+ log.error('supervisor: event stream failed', {
421
+ sessionId: descriptor.id,
422
+ error: String(error),
423
+ });
424
+ }
425
+ // Stream ended — the agent process is gone.
426
+ if (running.session !== session)
427
+ return; // superseded (shouldn't happen in v0)
428
+ // The process is down, so nothing is billable any more regardless of the
429
+ // last status we reported.
430
+ if (running.activeSince !== undefined) {
431
+ running.activeMs += Date.now() - running.activeSince;
432
+ delete running.activeSince;
433
+ }
434
+ this.clearBudgetTimers(running);
435
+ // Stale-resume recovery: relaunch once without a resume id.
436
+ if (running.freshRetry && !running.stopRequested) {
437
+ const { prompt } = running.freshRetry;
438
+ delete running.freshRetry;
439
+ running.freshRetryDone = true;
440
+ running.descriptor = { ...running.descriptor, providerSessionId: null };
441
+ running.costBaseUsd = running.costUsd;
442
+ this.launchAgent(running, prompt, null);
443
+ return;
444
+ }
445
+ // Auth recovery: one relaunch that KEEPS the provider session, so a
446
+ // credential hiccup does not cost the agent its whole conversation.
447
+ if (running.authRetry && !running.stopRequested) {
448
+ const { prompt } = running.authRetry;
449
+ delete running.authRetry;
450
+ running.authRetryDone = true;
451
+ running.costBaseUsd = running.costUsd;
452
+ this.launchAgent(running, prompt, running.descriptor.providerSessionId);
453
+ return;
454
+ }
455
+ if (running.stopRequested) {
456
+ // Report before removing from the map — reportStatus records
457
+ // lastReported on the live entry, and the journal cleanup below
458
+ // checks it to drop the file for terminal sessions. Skip the STOPPED
459
+ // frame when a terminal status (e.g. budget FAILED) was already sent.
460
+ if (!isTerminal(running.lastReported)) {
461
+ this.reportStatus(descriptor.id, 'STOPPED', {
462
+ costUsd: running.costUsd,
463
+ activeMs: running.activeMs,
464
+ });
465
+ }
466
+ this.sessions.delete(descriptor.id);
467
+ }
468
+ else if ((running.parkRequested || isSettled(running.lastReported)) &&
469
+ !isTerminal(running.lastReported) &&
470
+ running.descriptor.providerSessionId) {
471
+ // Idle process ended (parked or died between turns) — stay resumable.
472
+ running.session = null;
473
+ running.parkRequested = false;
474
+ running.costBaseUsd = running.costUsd; // next process starts from here
475
+ // Persist the clock: a parked session can sit for hours and the runner
476
+ // may be restarted before it ever runs again.
477
+ this.reportStatus(descriptor.id, statusForReport(running), {
478
+ costUsd: running.costUsd,
479
+ activeMs: running.activeMs,
480
+ });
481
+ // MUST drain before returning. This is the branch parking uses, i.e. the
482
+ // ordinary way a slot comes free — an early return here left every held
483
+ // message waiting forever while the runner sat idle (QA-102 MAJOR-1).
484
+ this.drainSessionsWaitingForCapacity();
485
+ return;
486
+ }
487
+ else if (!isSettled(running.lastReported)) {
488
+ this.reportStatus(descriptor.id, 'FAILED', {
489
+ costUsd: running.costUsd,
490
+ activeMs: running.activeMs,
491
+ errorMessage: 'Agent process exited unexpectedly',
492
+ });
493
+ this.sessions.delete(descriptor.id);
494
+ }
495
+ else {
496
+ this.sessions.delete(descriptor.id);
497
+ }
498
+ // Keep the journal while offline: the recorded terminal status is what
499
+ // reconcile replays after the next reconnect (QA-96 F1).
500
+ if (this.ws.connected &&
501
+ running.journal.unacked().length === 0 &&
502
+ isTerminal(running.lastReported)) {
503
+ this.journals.closeAndDelete(descriptor.id);
504
+ }
505
+ // A resume arrived while this life was winding down — start it now that the
506
+ // map entry is gone. One place, after every removal path above.
507
+ if (running.pendingRestart && !this.sessions.has(descriptor.id)) {
508
+ const next = running.pendingRestart;
509
+ delete running.pendingRestart;
510
+ void this.startSession(next).catch((error) => log.error('supervisor: queued resume failed', {
511
+ sessionId: descriptor.id,
512
+ error: String(error),
513
+ }));
514
+ }
515
+ // A slot just came free — hand it to whoever was told to wait for one.
516
+ this.drainSessionsWaitingForCapacity();
517
+ }
518
+ /**
519
+ * Deliver messages that were held because every slot was taken.
520
+ *
521
+ * Called whenever an agent process ends. Without it a message typed into a
522
+ * parked session while the server was full would sit in the queue until the
523
+ * next reconnect: the user would see their own bubble in the feed and no
524
+ * answer, which is the worst possible reading of "the server is busy".
525
+ */
526
+ drainSessionsWaitingForCapacity() {
527
+ for (const waiting of this.sessions.values()) {
528
+ if (waiting.session || waiting.stopRequested || waiting.budgetSpent)
529
+ continue;
530
+ if (waiting.pendingMessages.length === 0 || !waiting.worktreePath)
531
+ continue;
532
+ if (this.liveSessionCount(waiting.descriptor.id) >= this.maxSessions)
533
+ return;
534
+ this.flushPendingMessages(waiting);
535
+ }
536
+ }
537
+ /** The ceiling actually in force: the API's number, lowered by local config. */
538
+ get maxSessions() {
539
+ const local = this.opts.maxSessionsLimit;
540
+ return local === undefined ? this.serverMaxSessions : Math.min(local, this.serverMaxSessions);
541
+ }
542
+ /**
543
+ * Adopt a ceiling announced by the API.
544
+ *
545
+ * `undefined` means "this API does not know about the field" — only then do
546
+ * we fall back to the historical single slot. A number that is merely out of
547
+ * range is CLAMPED, never treated as absent: silently collapsing a server to
548
+ * one agent because of a typo in a config field is a much worse failure than
549
+ * capping it (QA-102 MINOR-5).
550
+ */
551
+ static MAX_SESSIONS_HARD_CAP = 64;
552
+ setMaxSessions(value) {
553
+ if (value === undefined)
554
+ return;
555
+ const next = Math.min(Supervisor.MAX_SESSIONS_HARD_CAP, Math.max(1, Math.floor(value)));
556
+ if (next === this.serverMaxSessions)
557
+ return;
558
+ const raised = next > this.serverMaxSessions;
559
+ this.serverMaxSessions = next;
560
+ log.info('supervisor: parallel session ceiling', {
561
+ fromApi: next,
562
+ fromConfig: this.opts.maxSessionsLimit ?? null,
563
+ effective: this.maxSessions,
564
+ });
565
+ // Raising the limit is the natural answer to "the runner is busy" — the
566
+ // messages that were told to wait must go out now, not at the next
567
+ // process exit (QA-102 MINOR-4).
568
+ if (raised)
569
+ this.drainSessionsWaitingForCapacity();
570
+ }
571
+ /**
572
+ * Sessions holding a slot right now. A session already asked to stop or park
573
+ * does not count: its process is winding down and its `session` handle stays
574
+ * set until `pumpEvents` sees the stream end — waiting for that would make
575
+ * parking look like it freed nothing.
576
+ */
577
+ liveSessionCount(exceptSessionId) {
578
+ let count = 0;
579
+ for (const existing of this.sessions.values()) {
580
+ if (existing.descriptor.id === exceptSessionId)
581
+ continue;
582
+ if (existing.stopRequested || existing.parkRequested || !existing.session)
583
+ continue;
584
+ count++;
585
+ }
586
+ return count;
587
+ }
588
+ /**
589
+ * Make room for one more agent process.
590
+ *
591
+ * Up to `maxSessions` agents run side by side, each in its own worktree. Over
592
+ * that, idle-but-resumable sessions (REVIEW / WAITING_INPUT) are parked —
593
+ * their provider session survives on disk and relaunches on the next message,
594
+ * so parking costs context nothing. Only mid-turn sessions (RUNNING /
595
+ * WAITING_PERMISSION) genuinely hold a slot; when they fill the ceiling this
596
+ * returns false and the caller tells the user rather than thrashing.
597
+ */
598
+ ensureCapacity(exceptSessionId) {
599
+ const limit = this.maxSessions;
600
+ if (this.liveSessionCount(exceptSessionId) < limit)
601
+ return true;
602
+ // Park the least recently useful first: a session waiting for a human is a
603
+ // better candidate than one that just finished a turn, but both are safe.
604
+ const parkable = [...this.sessions.values()].filter((existing) => existing.descriptor.id !== exceptSessionId &&
605
+ !existing.stopRequested &&
606
+ existing.session &&
607
+ this.isParkable(existing));
608
+ for (const candidate of parkable) {
609
+ if (this.liveSessionCount(exceptSessionId) < limit)
610
+ break;
611
+ this.park(candidate);
612
+ }
613
+ return this.liveSessionCount(exceptSessionId) < limit;
614
+ }
615
+ /** Idle after a finished turn — safe to kill the process and resume later. */
616
+ isParkable(running) {
617
+ return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
618
+ Boolean(running.descriptor.providerSessionId));
619
+ }
620
+ park(running) {
621
+ if (!running.session)
622
+ return;
623
+ running.parkRequested = true;
624
+ this.sendEvent(running, 'system_note', {
625
+ text: 'Session parked — the runner switched to another session. Send a message to resume.',
626
+ });
627
+ running.session.stop();
628
+ }
629
+ forwardEvent(running, event) {
630
+ const { descriptor } = running;
631
+ switch (event.type) {
632
+ case 'provider_session':
633
+ running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
634
+ this.reportStatus(descriptor.id, statusForReport(running), {
635
+ providerSessionId: event.providerSessionId,
636
+ });
637
+ return;
638
+ case 'cost':
639
+ // event.costUsd is the live process's running total; the session
640
+ // total also includes what earlier processes spent (park/restart).
641
+ running.costUsd = Math.max(running.costUsd, running.costBaseUsd + event.costUsd);
642
+ this.sendEvent(running, 'cost', {
643
+ costUsd: running.costUsd,
644
+ numTurns: event.numTurns,
645
+ durationMs: event.durationMs,
646
+ });
647
+ return;
648
+ case 'turn_end': {
649
+ this.sendEvent(running, 'turn_end', { ok: event.ok, errorMessage: event.errorMessage });
650
+ // Turn end is the natural checkpoint for the budget clock: the slice
651
+ // just closed, so this is the moment the API can persist it. Without a
652
+ // report here `agentActiveMs` stayed 0 and every restart or resume
653
+ // silently handed the session a full fresh budget.
654
+ if (event.ok) {
655
+ const next = descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
656
+ this.reportStatus(descriptor.id, next, {
657
+ costUsd: running.costUsd,
658
+ activeMs: Supervisor.spentMs(running),
659
+ });
660
+ }
661
+ else {
662
+ this.reportStatus(descriptor.id, 'FAILED', {
663
+ costUsd: running.costUsd,
664
+ activeMs: Supervisor.spentMs(running),
665
+ errorMessage: event.errorMessage ?? 'Agent turn failed',
666
+ });
667
+ }
668
+ return;
669
+ }
670
+ case 'error':
671
+ // A provider session the CLI no longer knows (state wiped, expired
672
+ // history): don't fail the session — relaunch it fresh once with the
673
+ // same prompt, the git worktree still holds all the work.
674
+ if (event.code === 'resume_failed' && !running.freshRetryDone) {
675
+ running.freshRetry = { prompt: running.lastPrompt };
676
+ this.sendEvent(running, 'notice', {
677
+ level: 'warn',
678
+ text: 'Previous agent context was lost — starting a fresh conversation in the same branch.',
679
+ });
680
+ return;
681
+ }
682
+ // A credential that went missing under the agent is usually repairable
683
+ // (the adapter re-asserts its home on start). Give it exactly one go
684
+ // before telling the user their login is broken — and keep the provider
685
+ // session, so nothing is lost if the retry works.
686
+ // No `lastPrompt` guard: a free CHAT session boots with an empty prompt
687
+ // and is exactly the case that hits an auth failure at startup, so
688
+ // requiring one excluded the sessions that need the retry most.
689
+ if (isAuthCode(event.code) && !running.authRetryDone) {
690
+ running.authRetry = { prompt: running.lastPrompt };
691
+ this.sendEvent(running, 'notice', {
692
+ level: 'warn',
693
+ text: 'The agent could not use its sign-in — retrying once.',
694
+ });
695
+ return;
696
+ }
697
+ // Forward the code: the API stores the payload as-is, so the dashboard
698
+ // can offer "Sign in" instead of a dead error card.
699
+ this.sendEvent(running, 'error', {
700
+ message: event.message,
701
+ ...(event.code ? { code: event.code } : {}),
702
+ });
703
+ this.reportStatus(descriptor.id, 'FAILED', {
704
+ costUsd: running.costUsd,
705
+ activeMs: Supervisor.spentMs(running),
706
+ errorMessage: event.message,
707
+ });
708
+ return;
709
+ case 'permission':
710
+ this.sendEvent(running, 'permission', {
711
+ requestId: event.requestId,
712
+ toolName: event.toolName,
713
+ title: event.title,
714
+ description: event.description,
715
+ input: event.input,
716
+ // Plan approvals carry the plan itself — the dashboard renders it as
717
+ // a plan card instead of a raw tool prompt.
718
+ plan: event.plan,
719
+ });
720
+ return;
721
+ case 'permission_resolved':
722
+ this.sendEvent(running, 'permission_resolved', {
723
+ requestId: event.requestId,
724
+ allow: event.allow,
725
+ source: event.source,
726
+ reason: event.reason,
727
+ });
728
+ if (event.source === 'user' &&
729
+ event.allow &&
730
+ running.lastReported === 'WAITING_PERMISSION') {
731
+ running.lastReported = 'RUNNING';
732
+ this.syncBudgetClock(running); // the human answered — bill again
733
+ }
734
+ return;
735
+ case 'message':
736
+ this.sendEvent(running, 'message', { role: event.role, text: event.text });
737
+ return;
738
+ case 'thinking':
739
+ this.sendEvent(running, 'thinking', { text: event.text });
740
+ return;
741
+ case 'question':
742
+ // The API maps `question` to WAITING_INPUT and notifies the user. The
743
+ // runner used to keep believing it was RUNNING — which disagreed with
744
+ // the API and, worse, kept the time budget running while the agent sat
745
+ // waiting for an answer.
746
+ this.sendEvent(running, 'question', { text: event.text, options: event.options });
747
+ running.lastReported = 'WAITING_INPUT';
748
+ this.syncBudgetClock(running);
749
+ return;
750
+ case 'capabilities':
751
+ this.sendEvent(running, 'capabilities', {
752
+ ...event.capabilities,
753
+ currentMode: running.mode,
754
+ ...(running.model ? { currentModel: running.model } : {}),
755
+ });
756
+ return;
757
+ case 'settings':
758
+ if (event.mode)
759
+ running.mode = event.mode;
760
+ if (event.model)
761
+ running.model = event.model;
762
+ // null = the agent dropped the pinned level (model without it) — the
763
+ // API must clear its column, so the value is forwarded as-is.
764
+ if (event.effort === null)
765
+ delete running.effort;
766
+ else if (event.effort)
767
+ running.effort = event.effort;
768
+ this.sendEvent(running, 'settings', {
769
+ model: event.model,
770
+ mode: event.mode,
771
+ ...(event.effort === undefined ? {} : { effort: event.effort }),
772
+ });
773
+ return;
774
+ case 'context_usage':
775
+ this.sendEvent(running, 'context_usage', {
776
+ usedTokens: event.usedTokens,
777
+ maxTokens: event.maxTokens,
778
+ });
779
+ return;
780
+ case 'notice': {
781
+ // Only adapter notices are de-duplicated here. The supervisor's own
782
+ // notices (turn interrupted, session parked, budget warnings) go
783
+ // through sendEvent directly and are meant to repeat.
784
+ const key = `${event.level}:${event.text}`;
785
+ running.seenNotices ??= new Set();
786
+ if (running.seenNotices.has(key))
787
+ return;
788
+ running.seenNotices.add(key);
789
+ this.sendEvent(running, 'notice', { level: event.level, text: event.text });
790
+ return;
791
+ }
792
+ case 'tool':
793
+ this.sendEvent(running, 'tool', {
794
+ phase: event.phase,
795
+ name: event.name,
796
+ toolUseId: event.toolUseId,
797
+ detail: event.detail,
798
+ text: event.text,
799
+ });
800
+ return;
801
+ default:
802
+ return;
803
+ }
804
+ }
805
+ async onUserMessage(sessionId, text, attachments) {
806
+ const running = this.sessions.get(sessionId);
807
+ if (!running) {
808
+ // The API believes this session lives here but the runner lost track of
809
+ // it (restart with a truncated reconnect list, state wipe). Ask for the
810
+ // descriptor and hold the message until it arrives (QA-99 MINOR-2)
811
+ // instead of dropping it silently.
812
+ log.warn('supervisor: message for unknown session — requesting descriptor', { sessionId });
813
+ const queued = this.orphanMessages.get(sessionId) ?? [];
814
+ queued.push({ text, ...(attachments?.length ? { attachments } : {}) });
815
+ this.orphanMessages.set(sessionId, queued.slice(-Supervisor.ORPHAN_MESSAGE_CAP));
816
+ this.ws.send({ type: 'session_unknown', sessionId });
817
+ return;
818
+ }
819
+ // The feed shows what the USER wrote plus the files they picked — not the
820
+ // composed prompt with workspace paths, which is an implementation detail.
821
+ this.sendEvent(running, 'message', {
822
+ role: 'user',
823
+ text,
824
+ ...(attachments?.length ? { attachments } : {}),
825
+ });
826
+ if (!running.worktreePath) {
827
+ // Session is still being prepared — deliver after launch (QA-96 F3).
828
+ // Attachments travel as metadata and are downloaded at delivery time,
829
+ // which is the first moment the worktree is guaranteed to exist.
830
+ running.pendingMessages.push(running.journal.appendPending(text, attachments));
831
+ return;
832
+ }
833
+ this.enqueueDelivery(running, async () => {
834
+ const composed = await this.materializeAttachments(running, text, attachments);
835
+ if (this.isStale(running)) {
836
+ // The session ended while the files downloaded. Park the message on
837
+ // disk rather than dropping it — «Продолжить» carries it to the agent.
838
+ running.journal.appendPending(text, attachments);
839
+ log.warn('supervisor: session ended before the message could be delivered', {
840
+ sessionId: running.descriptor.id,
841
+ });
842
+ return;
843
+ }
844
+ this.deliverMessage(running, composed);
845
+ });
846
+ }
847
+ /**
848
+ * Run delivery work for one session, strictly after whatever is already
849
+ * queued for it. Order is the whole point: two messages typed seconds apart
850
+ * must reach the agent in the order they were typed, however long the first
851
+ * one's attachments take to download.
852
+ */
853
+ enqueueDelivery(running, work) {
854
+ const previous = running.deliverChain ?? Promise.resolve();
855
+ running.deliverChain = previous
856
+ .catch(() => undefined)
857
+ .then(work)
858
+ .catch((error) => log.error('supervisor: message delivery failed', {
859
+ sessionId: running.descriptor.id,
860
+ error: String(error),
861
+ }));
862
+ }
863
+ /** Did this session go away (stop, teardown, resume) while we were awaiting? */
864
+ isStale(running) {
865
+ return running.stopRequested || this.sessions.get(running.descriptor.id) !== running;
866
+ }
867
+ /**
868
+ * Bring the user's files onto this machine and fold their paths into the
869
+ * prompt. Returns the text unchanged when there is nothing to fetch.
870
+ *
871
+ * A failure here never costs the message: the agent still gets what the user
872
+ * typed, plus a note naming the files that did not make it.
873
+ */
874
+ async materializeAttachments(running, text, attachments) {
875
+ if (!attachments?.length || !running.worktreePath)
876
+ return text;
877
+ const { apiUrl, runnerToken } = this.opts;
878
+ if (!apiUrl || !runnerToken) {
879
+ this.sendEvent(running, 'notice', {
880
+ level: 'warn',
881
+ text: 'This runner cannot fetch attachments — update it to the current version.',
882
+ });
883
+ return text;
884
+ }
885
+ const { saved, failed } = await saveAttachments({
886
+ worktreePath: running.worktreePath,
887
+ apiUrl,
888
+ token: runnerToken,
889
+ attachments,
890
+ });
891
+ if (failed.length > 0) {
892
+ this.sendEvent(running, 'notice', {
893
+ level: 'warn',
894
+ text: `Could not download ${failed.length === 1 ? 'the attachment' : 'some attachments'}: ${failed.join(', ')}. The message was sent without ${failed.length === 1 ? 'it' : 'them'}.`,
895
+ });
896
+ }
897
+ return composeMessageWithAttachments(text, saved);
898
+ }
899
+ /**
900
+ * Hand a message to the agent, or hold it if there is nowhere to put it yet.
901
+ *
902
+ * `held` carries the journal records this text was built from, so a delivery
903
+ * resolves them and a refusal puts exactly those records back in the queue —
904
+ * the message is retired from disk only once an agent has it.
905
+ */
906
+ deliverMessage(running, text, held = []) {
907
+ // Older instructions are already waiting: send them together, in order.
908
+ // Without this a message that CAN go now jumps the queue — and the ones it
909
+ // jumped are stranded, because the drain only runs when a process exits
910
+ // (QA-103 MINOR-9). They would then be replayed at the next daemon start,
911
+ // or lost with the journal if the session ended first.
912
+ if (held.length === 0 && running.pendingMessages.length > 0) {
913
+ // Routed through the flush rather than joined here: a queued record may
914
+ // carry files that still have to be downloaded, and joining raw texts
915
+ // would deliver the words while silently dropping the screenshot
916
+ // (QA-104 MAJOR-4). `text` is already composed by the caller, so it
917
+ // needs no attachments of its own.
918
+ running.pendingMessages.push(running.journal.appendPending(text));
919
+ this.flushPendingMessages(running);
920
+ return;
921
+ }
922
+ const settle = () => {
923
+ for (const record of held)
924
+ running.journal.resolvePending(record.id);
925
+ };
926
+ if (running.session) {
927
+ running.session.send(text);
928
+ settle();
929
+ this.reportStatus(running.descriptor.id, 'RUNNING', {});
930
+ return;
931
+ }
932
+ // Parked session: the follow-up message becomes the resume prompt.
933
+ if (!this.ensureCapacity(running.descriptor.id)) {
934
+ this.sendEvent(running, 'system_note', {
935
+ text: this.maxSessions === 1
936
+ ? 'The runner is busy with another session — this one continues as soon as it finishes its turn.'
937
+ : `The runner is busy with ${this.maxSessions} other sessions — this one continues as soon as one of them finishes its turn.`,
938
+ });
939
+ // The message is already in the feed; keep it so the resume actually
940
+ // carries it once a slot frees up, instead of the user's instruction
941
+ // vanishing into a system note. On disk, too — the wait can outlive the
942
+ // daemon (session 9).
943
+ running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
944
+ return;
945
+ }
946
+ if (this.launchAgent(running, text, running.descriptor.providerSessionId)) {
947
+ settle();
948
+ return;
949
+ }
950
+ // The agent did not start (an exhausted budget is the only way here). The
951
+ // instruction stays on disk, so «Продолжить» — which is what raises the
952
+ // budget — carries it to the agent instead of dropping it.
953
+ running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
954
+ }
955
+ /**
956
+ * Deliver messages that raced session start (already journaled).
957
+ *
958
+ * Async since session 10: a held message may carry files, and this is the
959
+ * first point at which the worktree they belong in certainly exists. Callers
960
+ * fire and forget — a failure here is logged, and the records stay on disk.
961
+ */
962
+ flushPendingMessages(running) {
963
+ const pending = running.pendingMessages.splice(0);
964
+ if (pending.length === 0)
965
+ return;
966
+ this.enqueueDelivery(running, async () => {
967
+ const parts = [];
968
+ try {
969
+ for (const record of pending) {
970
+ parts.push(await this.materializeAttachments(running, record.text, record.attachments));
971
+ }
972
+ }
973
+ catch (error) {
974
+ // Back on the queue: the records are still on disk, and the next drain
975
+ // (or the next restart) retries them.
976
+ running.pendingMessages.unshift(...pending);
977
+ throw error;
978
+ }
979
+ if (this.isStale(running)) {
980
+ // Left unresolved in the journal on purpose — a resumed session picks
981
+ // them back up instead of starting an agent for a session that ended
982
+ // while the files were downloading (QA-104 MAJOR-3).
983
+ log.warn('supervisor: session ended before held messages could be delivered', {
984
+ sessionId: running.descriptor.id,
985
+ held: pending.length,
986
+ });
987
+ return;
988
+ }
989
+ this.deliverMessage(running, parts.join('\n\n'), pending);
990
+ });
991
+ }
992
+ /** Stop the current turn without ending the session (VS-Code-style Stop). */
993
+ async interruptSession(sessionId) {
994
+ const running = this.sessions.get(sessionId);
995
+ if (!running?.session)
996
+ return;
997
+ await running.session.interrupt();
998
+ this.sendEvent(running, 'notice', { level: 'info', text: 'Turn interrupted by the user' });
999
+ const next = running.descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
1000
+ this.reportStatus(sessionId, next, {
1001
+ costUsd: running.costUsd,
1002
+ activeMs: Supervisor.spentMs(running),
1003
+ });
1004
+ }
1005
+ /** Live model / interaction-mode switch (persisted for the next relaunch). */
1006
+ async applySettings(sessionId, model, mode, effort) {
1007
+ const running = this.sessions.get(sessionId);
1008
+ if (!running)
1009
+ return;
1010
+ // Remember first: a parked session applies them on its next launch.
1011
+ if (model)
1012
+ running.model = model;
1013
+ if (mode)
1014
+ running.mode = mode;
1015
+ if (effort === null)
1016
+ delete running.effort;
1017
+ else if (effort)
1018
+ running.effort = effort;
1019
+ if (!running.session) {
1020
+ this.sendEvent(running, 'settings', { model, mode, effort });
1021
+ return;
1022
+ }
1023
+ try {
1024
+ // Model first: switching models can invalidate the picked effort, and
1025
+ // the adapter drops it in that case — applying effort after lets an
1026
+ // explicit pick win.
1027
+ if (model)
1028
+ await running.session.setModel(model);
1029
+ if (effort !== undefined)
1030
+ await running.session.setEffort(effort);
1031
+ if (mode)
1032
+ await running.session.setMode(mode);
1033
+ }
1034
+ catch (error) {
1035
+ this.sendEvent(running, 'notice', {
1036
+ level: 'warn',
1037
+ text: `Could not apply the new settings to the running agent: ${maskSecretText(error)}`,
1038
+ });
1039
+ }
1040
+ }
1041
+ stopSession(sessionId) {
1042
+ const running = this.sessions.get(sessionId);
1043
+ if (!running)
1044
+ return;
1045
+ running.stopRequested = true;
1046
+ this.clearBudgetTimers(running);
1047
+ if (running.session) {
1048
+ running.session.stop(); // pumpEvents finishes the cleanup
1049
+ }
1050
+ else {
1051
+ this.reportStatus(sessionId, 'STOPPED', { activeMs: running.activeMs });
1052
+ this.sessions.delete(sessionId);
1053
+ if (this.ws.connected && running.journal.unacked().length === 0) {
1054
+ this.journals.closeAndDelete(sessionId);
1055
+ }
1056
+ }
1057
+ }
1058
+ // ─── Reconciliation (hello_ack) ────────────────────────────────────
1059
+ async reconcile(descriptors) {
1060
+ // Sessions the API no longer considers live (e.g. stopped from the
1061
+ // dashboard while this runner was offline) must not keep an agent process
1062
+ // running and holding the single runner slot (QA-99 MAJOR-2).
1063
+ const known = new Set(descriptors.map((d) => d.id));
1064
+ for (const [sessionId, running] of [...this.sessions]) {
1065
+ if (known.has(sessionId))
1066
+ continue;
1067
+ log.warn('supervisor: session gone server-side — tearing it down', { sessionId });
1068
+ running.stopRequested = true;
1069
+ this.clearBudgetTimers(running);
1070
+ if (running.session) {
1071
+ running.session.stop(); // pumpEvents finishes the cleanup
1072
+ }
1073
+ else {
1074
+ this.sessions.delete(sessionId);
1075
+ }
1076
+ }
1077
+ // Redeliver unacked events for every persisted journal (at-least-once).
1078
+ for (const sessionId of this.journals.persistedSessionIds()) {
1079
+ const journal = this.journals.open(sessionId);
1080
+ for (const event of journal.unacked()) {
1081
+ this.ws.send({
1082
+ type: 'event',
1083
+ sessionId,
1084
+ seq: event.seq,
1085
+ eventType: event.eventType,
1086
+ payload: event.payload,
1087
+ });
1088
+ }
1089
+ }
1090
+ for (const descriptor of descriptors) {
1091
+ // Live local session: statuses are fire-and-forget on the wire, so a
1092
+ // status reached while the WS was down is re-reported here (QA-96 F1).
1093
+ const tracked = this.sessions.get(descriptor.id);
1094
+ if (tracked) {
1095
+ // The session was resumed server-side while this runner was offline, and
1096
+ // the local copy is that same work. Adopt the new epoch BEFORE reporting:
1097
+ // the API drops frames stamped with an older one, so keeping ours would
1098
+ // make every status this session ever sends invisible — it would sit in
1099
+ // "waiting" while the agent worked.
1100
+ if (descriptor.epoch > tracked.epoch) {
1101
+ tracked.epoch = descriptor.epoch;
1102
+ tracked.descriptor = { ...tracked.descriptor, epoch: descriptor.epoch };
1103
+ }
1104
+ this.reportStatus(descriptor.id, statusForReport(tracked), {
1105
+ costUsd: tracked.costUsd,
1106
+ ...(tracked.branch ? { branch: tracked.branch } : {}),
1107
+ ...(tracked.worktreePath ? { worktreePath: tracked.worktreePath } : {}),
1108
+ ...(tracked.descriptor.providerSessionId
1109
+ ? { providerSessionId: tracked.descriptor.providerSessionId }
1110
+ : {}),
1111
+ });
1112
+ continue;
1113
+ }
1114
+ // Session that went terminal while we were offline: the journal keeps
1115
+ // the last reported status — replay it instead of resurrecting the
1116
+ // session as resumable (QA-96 F1).
1117
+ if (this.journals.exists(descriptor.id)) {
1118
+ const journal = this.journals.open(descriptor.id);
1119
+ const last = journal.lastStatus;
1120
+ // Only replay a terminal status from THIS life of the session. A
1121
+ // resumed session carries a higher epoch, and replaying the FAILED it
1122
+ // was resumed from would kill it again the moment the runner reconnects.
1123
+ if (last &&
1124
+ isTerminal(last.status) &&
1125
+ (last.epoch ?? 0) >= descriptor.epoch) {
1126
+ this.ws.send({
1127
+ type: 'session_status',
1128
+ sessionId: descriptor.id,
1129
+ status: last.status,
1130
+ ...(last.extra ?? {}),
1131
+ // Guarded above to be >= the descriptor's epoch, so the API keeps it.
1132
+ ...(last.epoch === undefined ? {} : { epoch: last.epoch }),
1133
+ });
1134
+ if (journal.unacked().length === 0) {
1135
+ this.journals.closeAndDelete(descriptor.id);
1136
+ }
1137
+ continue;
1138
+ }
1139
+ }
1140
+ if (descriptor.status === 'STARTING') {
1141
+ await this.startSession(descriptor);
1142
+ }
1143
+ else if (descriptor.providerSessionId) {
1144
+ // Runner restarted mid-session. The provider session is resumable —
1145
+ // park it until the user sends the next instruction. The map entry is
1146
+ // registered BEFORE the worktree await so a racing message is
1147
+ // buffered instead of dropped (QA-96 F3).
1148
+ const running = {
1149
+ descriptor,
1150
+ journal: this.journals.open(descriptor.id),
1151
+ session: null,
1152
+ lastReported: descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT',
1153
+ costUsd: descriptor.costUsd,
1154
+ costBaseUsd: descriptor.costUsd,
1155
+ stopRequested: false,
1156
+ parkRequested: false,
1157
+ pendingMessages: [],
1158
+ // Seed from the API, not 0: a runner restart used to hand the session
1159
+ // a full fresh budget silently.
1160
+ activeMs: descriptor.activeMsBase,
1161
+ extraBudgetMinutes: descriptor.extraBudgetMinutes,
1162
+ epoch: descriptor.epoch,
1163
+ mode: descriptor.mode,
1164
+ ...(descriptor.model ? { model: descriptor.model } : {}),
1165
+ ...(descriptor.effort ? { effort: descriptor.effort } : {}),
1166
+ lastPrompt: '',
1167
+ };
1168
+ running.journal.ensureSeqAbove(descriptor.lastSeq);
1169
+ // Anything the API handed us before the daemon stopped (session 9).
1170
+ running.pendingMessages.push(...running.journal.pending());
1171
+ this.sessions.set(descriptor.id, running);
1172
+ try {
1173
+ const { branch, worktreePath } = await this.withRepoLockFor(descriptor.workspace.path, () => ensureSessionWorktree(descriptor.workspace.path, descriptor.id, descriptor.branchHint, { requireExistingBranch: hasWorkToResume(descriptor) }));
1174
+ running.branch = branch;
1175
+ running.worktreePath = worktreePath;
1176
+ }
1177
+ catch (error) {
1178
+ this.reportStatus(descriptor.id, 'FAILED', {
1179
+ errorMessage: `Failed to restore session worktree: ${String(error instanceof Error ? error.message : error).slice(0, 500)}`,
1180
+ });
1181
+ this.sessions.delete(descriptor.id);
1182
+ continue;
1183
+ }
1184
+ this.sendEvent(running, 'system_note', {
1185
+ text: 'Runner reconnected. The session was resumed — send a message to continue.',
1186
+ });
1187
+ // REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
1188
+ // mid-turn statuses are downgraded to "waiting for the user".
1189
+ if (descriptor.status !== 'REVIEW') {
1190
+ this.reportStatus(descriptor.id, 'WAITING_INPUT', {});
1191
+ }
1192
+ this.flushPendingMessages(running);
1193
+ }
1194
+ else if (descriptor.status === 'WAITING_INPUT') {
1195
+ // A session that never had a turn (a free session still waiting for
1196
+ // its first message) has nothing to resume — just bring the agent back
1197
+ // up and keep waiting, instead of failing the session.
1198
+ await this.startSession({ ...descriptor, status: 'STARTING', prompt: '' });
1199
+ }
1200
+ else {
1201
+ this.reportStatus(descriptor.id, 'FAILED', {
1202
+ errorMessage: 'Runner restarted and this session cannot be resumed',
1203
+ });
1204
+ }
1205
+ }
1206
+ // Redelivery is done — anything still on disk from long-finished sessions
1207
+ // is dead weight (QA-99 MINOR-6).
1208
+ this.pruneJournals();
1209
+ }
1210
+ /**
1211
+ * Housekeeping for the journal directory. Safe to call any time: live
1212
+ * sessions are skipped and a journal still holding unacked events survives
1213
+ * until the hard expiry.
1214
+ */
1215
+ pruneJournals() {
1216
+ try {
1217
+ const removed = this.journals.prune({
1218
+ maxAgeMs: Supervisor.JOURNAL_TTL_MS,
1219
+ hardMaxAgeMs: Supervisor.JOURNAL_HARD_TTL_MS,
1220
+ skip: new Set(this.sessions.keys()),
1221
+ });
1222
+ if (removed.length) {
1223
+ log.info('supervisor: pruned stale journals', { count: removed.length });
1224
+ }
1225
+ }
1226
+ catch (error) {
1227
+ log.warn('supervisor: journal prune failed', { error: String(error) });
1228
+ }
1229
+ }
1230
+ // ─── Commands ──────────────────────────────────────────────────────
1231
+ async runCommand(frame) {
1232
+ const reply = (result) => this.ws.send({ type: 'command_result', requestId: frame.requestId, ...result });
1233
+ try {
1234
+ switch (frame.name) {
1235
+ case 'validate_path': {
1236
+ const path = typeof frame.args?.['path'] === 'string' ? frame.args['path'] : null;
1237
+ if (!path)
1238
+ return void reply({ ok: false, error: 'path argument is required' });
1239
+ const validation = await validateWorkspacePath(path);
1240
+ return void reply({
1241
+ ok: validation.ok,
1242
+ result: validation,
1243
+ ...(validation.ok ? {} : { error: validation.error }),
1244
+ });
1245
+ }
1246
+ case 'clean': {
1247
+ const sessionId = frame.sessionId;
1248
+ if (!sessionId)
1249
+ return void reply({ ok: false, error: 'sessionId is required' });
1250
+ const running = this.sessions.get(sessionId);
1251
+ if (running)
1252
+ return void reply({ ok: false, error: 'Session is still active — stop it first' });
1253
+ // `worktree remove` also mutates the shared .git registration.
1254
+ await this.withRepoLockFor(sessionWorktreePath(sessionId), () => removeSessionWorktree(sessionId));
1255
+ return void reply({ ok: true, result: { removed: true } });
1256
+ }
1257
+ case 'purge_session': {
1258
+ // The session row is already gone from the API. Everything this
1259
+ // runner still holds for it on disk goes now.
1260
+ const sessionId = frame.sessionId;
1261
+ if (!sessionId)
1262
+ return void reply({ ok: false, error: 'sessionId is required' });
1263
+ if (this.sessions.get(sessionId)) {
1264
+ return void reply({ ok: false, error: 'Session is still active — stop it first' });
1265
+ }
1266
+ const branch = str(frame.args?.['branch']);
1267
+ const workspacePath = str(frame.args?.['workspacePath']);
1268
+ // Only ever true when the API confirmed the work is already applied to
1269
+ // the base branch — an un-applied branch is the user's only copy of
1270
+ // what the agent wrote.
1271
+ const deleteBranch = frame.args?.['deleteBranch'] === true;
1272
+ const result = await this.withRepoLockFor(workspacePath ?? sessionWorktreePath(sessionId), async () => {
1273
+ await removeSessionWorktree(sessionId);
1274
+ if (!deleteBranch || !branch || !workspacePath)
1275
+ return { branchDeleted: false };
1276
+ try {
1277
+ await deleteSessionBranch(workspacePath, branch);
1278
+ return { branchDeleted: true };
1279
+ }
1280
+ catch (error) {
1281
+ // A branch we could not drop is a leftover, not a failed purge.
1282
+ return { branchDeleted: false, branchError: maskSecretText(error) };
1283
+ }
1284
+ });
1285
+ this.journals.closeAndDelete(sessionId);
1286
+ this.orphanMessages.delete(sessionId);
1287
+ return void reply({ ok: true, result: { removed: true, ...result } });
1288
+ }
1289
+ case 'reset_workspace':
1290
+ return void reply({
1291
+ ok: false,
1292
+ error: 'reset_workspace is not supported by this runner version',
1293
+ });
1294
+ case 'git_status': {
1295
+ const paths = gitCommandPaths(frame.args);
1296
+ if (!paths)
1297
+ return void reply({
1298
+ ok: false,
1299
+ error: 'worktreePath/workspacePath/branch are required',
1300
+ });
1301
+ return void reply({
1302
+ ok: true,
1303
+ result: await gitStatus(paths.worktreePath, paths.workspacePath, paths.branch),
1304
+ });
1305
+ }
1306
+ case 'git_diff': {
1307
+ const paths = gitCommandPaths(frame.args);
1308
+ const filePath = str(frame.args?.['path']);
1309
+ if (!paths || !filePath) {
1310
+ return void reply({
1311
+ ok: false,
1312
+ error: 'worktreePath/workspacePath/branch/path are required',
1313
+ });
1314
+ }
1315
+ return void reply({
1316
+ ok: true,
1317
+ result: await gitDiff(paths.worktreePath, paths.workspacePath, paths.branch, filePath),
1318
+ });
1319
+ }
1320
+ // Session 11: history. Read-only, so no repo lock and no busy check —
1321
+ // `git log`/`git show` never touch the index, and refusing to show the
1322
+ // history while the agent happens to be mid-turn would make the panel
1323
+ // useless exactly when it is most interesting.
1324
+ case 'git_log': {
1325
+ const workspacePath = str(frame.args?.['workspacePath']);
1326
+ const branch = str(frame.args?.['branch']);
1327
+ if (!workspacePath || !branch) {
1328
+ return void reply({ ok: false, error: 'workspacePath and branch are required' });
1329
+ }
1330
+ const scope = logScope(frame.args?.['scope']);
1331
+ const limit = num(frame.args?.['limit']);
1332
+ const skip = num(frame.args?.['skip']);
1333
+ return void reply({
1334
+ ok: true,
1335
+ result: await gitLog({
1336
+ workspacePath,
1337
+ branch,
1338
+ ...(scope ? { scope } : {}),
1339
+ ...(limit !== null ? { limit } : {}),
1340
+ ...(skip !== null ? { skip } : {}),
1341
+ }),
1342
+ });
1343
+ }
1344
+ case 'git_show': {
1345
+ const workspacePath = str(frame.args?.['workspacePath']);
1346
+ const sha = str(frame.args?.['sha']);
1347
+ if (!workspacePath || !sha) {
1348
+ return void reply({ ok: false, error: 'workspacePath and sha are required' });
1349
+ }
1350
+ const filePath = str(frame.args?.['path']);
1351
+ const showBranch = str(frame.args?.['branch']);
1352
+ return void reply({
1353
+ ok: true,
1354
+ result: await gitShow(workspacePath, sha, filePath ?? undefined, showBranch ?? undefined),
1355
+ });
1356
+ }
1357
+ case 'git_commit': {
1358
+ const worktreePath = str(frame.args?.['worktreePath']);
1359
+ const message = str(frame.args?.['message']);
1360
+ if (!worktreePath || !message) {
1361
+ return void reply({ ok: false, error: 'worktreePath and message are required' });
1362
+ }
1363
+ if (this.isWorktreeBusy(worktreePath)) {
1364
+ return void reply({
1365
+ ok: false,
1366
+ error: 'The agent is still working — wait for the turn to finish',
1367
+ });
1368
+ }
1369
+ return void reply({
1370
+ ok: true,
1371
+ result: await this.withRepoLockFor(worktreePath, () => gitCommit(worktreePath, message)),
1372
+ });
1373
+ }
1374
+ case 'apply_session': {
1375
+ const paths = gitCommandPaths(frame.args);
1376
+ const message = str(frame.args?.['message']);
1377
+ if (!paths || !message) {
1378
+ return void reply({
1379
+ ok: false,
1380
+ error: 'worktreePath/workspacePath/branch/message are required',
1381
+ });
1382
+ }
1383
+ if (this.isWorktreeBusy(paths.worktreePath)) {
1384
+ return void reply({
1385
+ ok: false,
1386
+ error: 'The agent is still working — wait for the turn to finish',
1387
+ });
1388
+ }
1389
+ const applied = await this.withRepoLockFor(paths.workspacePath, () => applySession(paths.workspacePath, paths.worktreePath, paths.branch, message));
1390
+ return void reply(applied.applied || applied.conflict
1391
+ ? { ok: true, result: applied }
1392
+ : { ok: false, error: applied.error ?? 'Apply failed', result: applied });
1393
+ }
1394
+ case 'revert_apply': {
1395
+ const workspacePath = str(frame.args?.['workspacePath']);
1396
+ const commitSha = str(frame.args?.['commitSha']);
1397
+ if (!workspacePath || !commitSha) {
1398
+ return void reply({ ok: false, error: 'workspacePath and commitSha are required' });
1399
+ }
1400
+ const reverted = await this.withRepoLockFor(workspacePath, () => revertApply(workspacePath, commitSha));
1401
+ return void reply(reverted.reverted || reverted.conflict
1402
+ ? { ok: true, result: reverted }
1403
+ : { ok: false, error: reverted.error ?? 'Revert failed', result: reverted });
1404
+ }
1405
+ case 'fs_view': {
1406
+ const root = str(frame.args?.['root']);
1407
+ if (!root)
1408
+ return void reply({ ok: false, error: 'root argument is required' });
1409
+ return void reply({ ok: true, result: fsView(root, str(frame.args?.['path']) ?? '.') });
1410
+ }
1411
+ case 'self_update': {
1412
+ // Everything that can refuse this lives in self-update.ts; here we
1413
+ // only make sure the answer is on the wire BEFORE the process goes
1414
+ // away, otherwise the dashboard would show a timeout for an update
1415
+ // that in fact succeeded.
1416
+ const tarballUrl = str(frame.args?.['tarballUrl']);
1417
+ if (!tarballUrl)
1418
+ return void reply({ ok: false, error: 'tarballUrl is required' });
1419
+ if (!this.opts.apiUrl) {
1420
+ return void reply({ ok: false, error: 'This runner has no API URL configured' });
1421
+ }
1422
+ // Second line of defence behind the API's per-server lock: two
1423
+ // overlapping `npm install -g` into the same prefix is not something
1424
+ // to leave to chance on someone else's machine.
1425
+ if (this.selfUpdateInFlight) {
1426
+ return void reply({ ok: false, error: 'An update is already running' });
1427
+ }
1428
+ this.selfUpdateInFlight = true;
1429
+ const run = this.opts.selfUpdate ?? selfUpdate;
1430
+ let outcome;
1431
+ try {
1432
+ outcome = await run({ tarballUrl, apiUrl: this.opts.apiUrl });
1433
+ }
1434
+ catch (error) {
1435
+ // A failed update must be retryable without restarting the daemon.
1436
+ this.selfUpdateInFlight = false;
1437
+ throw error;
1438
+ }
1439
+ // Deliberately NOT cleared on success: the process only exits ~1.5s
1440
+ // after this reply (the frame has to leave the socket first), and the
1441
+ // API releases its own lock the moment the reply lands. Clearing here
1442
+ // left a window in which a second press started an `npm install -g`
1443
+ // that systemd then killed mid-flight (QA-103 MINOR-8).
1444
+ this.selfUpdateInFlight = outcome.ok && outcome.restart;
1445
+ reply({
1446
+ ok: outcome.ok,
1447
+ result: outcome,
1448
+ ...(outcome.ok ? {} : { error: outcome.detail ?? 'Update failed' }),
1449
+ });
1450
+ if (outcome.ok && outcome.restart)
1451
+ this.opts.onRestartRequested?.(outcome);
1452
+ return;
1453
+ }
1454
+ case 'auth_status':
1455
+ return void reply({ ok: true, result: await agentAuthStatuses() });
1456
+ case 'login_start': {
1457
+ const agent = relayAgent(frame.args?.['agent']);
1458
+ if (!agent)
1459
+ return void reply({ ok: false, error: 'agent must be claude or codex' });
1460
+ return void reply({ ok: true, result: await this.authRelay.start(agent) });
1461
+ }
1462
+ case 'login_code': {
1463
+ const agent = relayAgent(frame.args?.['agent']);
1464
+ const code = str(frame.args?.['code']);
1465
+ if (!agent || !code)
1466
+ return void reply({ ok: false, error: 'agent and code are required' });
1467
+ const result = await this.authRelay.submitCode(agent, code);
1468
+ return void reply({
1469
+ ok: result.ok,
1470
+ result,
1471
+ ...(result.ok ? {} : { error: result.detail ?? 'Login failed' }),
1472
+ });
1473
+ }
1474
+ default:
1475
+ return void reply({ ok: false, error: `Unknown command` });
1476
+ }
1477
+ }
1478
+ catch (error) {
1479
+ reply({ ok: false, error: maskSecretText(error) });
1480
+ }
1481
+ }
1482
+ /**
1483
+ * Serialise commands that mutate the shared workspace repo. Two sessions of
1484
+ * one workspace can hit «Применить» at the same moment; interleaved
1485
+ * `merge --squash` + `commit` would land both change sets in one commit and
1486
+ * mark the other session failed (QA-99 MAJOR-1).
1487
+ */
1488
+ withRepoLock(repoKey, fn) {
1489
+ const previous = this.repoLocks.get(repoKey) ?? Promise.resolve();
1490
+ const run = previous.catch(() => undefined).then(fn);
1491
+ const tail = run.catch(() => undefined);
1492
+ this.repoLocks.set(repoKey, tail);
1493
+ void tail.then(() => {
1494
+ if (this.repoLocks.get(repoKey) === tail)
1495
+ this.repoLocks.delete(repoKey);
1496
+ });
1497
+ return run;
1498
+ }
1499
+ /**
1500
+ * Same lock, keyed by the shared repository rather than by whichever path the
1501
+ * caller happened to have. A worktree commit and a workspace squash-merge
1502
+ * touch one repo, so they must take one key (see `repoKeyFor`).
1503
+ */
1504
+ async withRepoLockFor(pathInsideRepo, fn) {
1505
+ return this.withRepoLock(await repoKeyFor(pathInsideRepo), fn);
1506
+ }
1507
+ /** A session actively mid-turn in this worktree — git writes must wait. */
1508
+ isWorktreeBusy(worktreePath) {
1509
+ for (const running of this.sessions.values()) {
1510
+ if (running.worktreePath !== worktreePath || !running.session)
1511
+ continue;
1512
+ if (running.lastReported === 'STARTING' ||
1513
+ running.lastReported === 'RUNNING' ||
1514
+ running.lastReported === 'WAITING_PERMISSION') {
1515
+ return true;
1516
+ }
1517
+ }
1518
+ return false;
1519
+ }
1520
+ // ─── Outbound helpers ──────────────────────────────────────────────
1521
+ // Hard cap far below the API's 128KB Zod limit — an oversized payload would
1522
+ // be rejected forever and wedge the journal (QA-96 F2).
1523
+ static EVENT_PAYLOAD_CAP = 100_000;
1524
+ sendEvent(running, eventType, payload) {
1525
+ let compact = Object.fromEntries(Object.entries(maskSecrets(payload)).filter(([, v]) => v !== undefined));
1526
+ if (JSON.stringify(compact).length > Supervisor.EVENT_PAYLOAD_CAP) {
1527
+ log.warn('supervisor: event payload over cap, stubbing', {
1528
+ sessionId: running.descriptor.id,
1529
+ eventType,
1530
+ });
1531
+ compact = { truncated: true, note: 'payload exceeded the event size cap' };
1532
+ }
1533
+ const event = running.journal.append(eventType, compact);
1534
+ this.ws.send({
1535
+ type: 'event',
1536
+ sessionId: running.descriptor.id,
1537
+ seq: event.seq,
1538
+ eventType,
1539
+ payload: event.payload,
1540
+ });
1541
+ if (eventType === 'permission') {
1542
+ // The API infers WAITING_PERMISSION from the event itself, so this never
1543
+ // goes through reportStatus — but the budget clock still has to stop, or
1544
+ // an ask-mode session bills every second the human spends reading the card.
1545
+ running.lastReported = 'WAITING_PERMISSION';
1546
+ this.syncBudgetClock(running);
1547
+ }
1548
+ }
1549
+ reportStatus(sessionId, status, extra) {
1550
+ const running = this.sessions.get(sessionId);
1551
+ if (running) {
1552
+ running.lastReported = status;
1553
+ this.syncBudgetClock(running);
1554
+ }
1555
+ const compact = maskSecrets(Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== undefined)));
1556
+ // Journal the latest status for replay after a reconnect (QA-96 F1) —
1557
+ // only for tracked sessions, so one-shot failure reports don't leave
1558
+ // orphan journal files behind. The epoch stamp keeps a previous life's
1559
+ // terminal status from being replayed over a resumed session.
1560
+ running?.journal.recordStatus(status, compact, running.epoch);
1561
+ // Stamp the life this status belongs to (session 8). The API drops a frame
1562
+ // whose epoch is older than the session's current one, which closes the
1563
+ // «Стоп» ↔ «Продолжить» window: the dying process's STOPPED can no longer
1564
+ // land on the session that was picked back up a moment earlier.
1565
+ this.ws.send({
1566
+ type: 'session_status',
1567
+ sessionId,
1568
+ status,
1569
+ ...compact,
1570
+ ...(running ? { epoch: running.epoch } : {}),
1571
+ });
1572
+ }
1573
+ /** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
1574
+ shutdown() {
1575
+ this.authRelay.cancel();
1576
+ for (const running of this.sessions.values()) {
1577
+ this.clearBudgetTimers(running);
1578
+ running.session?.stop();
1579
+ }
1580
+ this.sessions.clear();
1581
+ }
1582
+ }
1583
+ function str(value) {
1584
+ return typeof value === 'string' && value ? value : null;
1585
+ }
1586
+ /** Error text safe to hand back to the dashboard (QA-99 MINOR-3). */
1587
+ function maskSecretText(error) {
1588
+ return maskString(String(error instanceof Error ? error.message : error)).slice(0, 500);
1589
+ }
1590
+ function relayAgent(value) {
1591
+ return value === 'claude' || value === 'codex' ? value : null;
1592
+ }
1593
+ function num(value) {
1594
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
1595
+ }
1596
+ function logScope(value) {
1597
+ return value === 'session' || value === 'branch' || value === 'all' ? value : null;
1598
+ }
1599
+ function gitCommandPaths(args) {
1600
+ const worktreePath = str(args?.['worktreePath']);
1601
+ const workspacePath = str(args?.['workspacePath']);
1602
+ const branch = str(args?.['branch']);
1603
+ return worktreePath && workspacePath && branch ? { worktreePath, workspacePath, branch } : null;
1604
+ }
1605
+ function statusForReport(running) {
1606
+ return running.lastReported === 'STARTING'
1607
+ ? 'RUNNING'
1608
+ : running.lastReported;
1609
+ }
1610
+ const AGENT_LABELS = { CLAUDE: 'Claude Code', CODEX: 'Codex' };
1611
+ /** Only Claude reports USD — Codex sessions are bounded by time instead. */
1612
+ function reportsCost(agent) {
1613
+ return agent === 'CLAUDE';
1614
+ }
1615
+ /** Adapter error codes that mean "the sign-in did not work". */
1616
+ function isAuthCode(code) {
1617
+ return code === 'auth_expired' || code === 'auth_missing';
1618
+ }
1619
+ /**
1620
+ * Does this descriptor point at work that already exists on a branch?
1621
+ * If so, silently creating a fresh branch off HEAD would hide the agent's
1622
+ * commits behind an empty worktree — fail loudly instead.
1623
+ */
1624
+ function hasWorkToResume(descriptor) {
1625
+ return descriptor.status !== 'STARTING' && Boolean(descriptor.providerSessionId);
1626
+ }
1627
+ function isTerminal(status) {
1628
+ return status === 'DONE' || status === 'FAILED' || status === 'STOPPED';
1629
+ }
1630
+ /** Statuses where "process exited" is expected rather than a crash. */
1631
+ function isSettled(status) {
1632
+ return isTerminal(status) || status === 'REVIEW';
1633
+ }
1634
+ export function composeInitialPrompt(descriptor) {
1635
+ if (descriptor.tickets.length === 0)
1636
+ return descriptor.prompt;
1637
+ const ticketLines = descriptor.tickets.map((t) => `- #${t.number}: ${t.title}`).join('\n');
1638
+ return [
1639
+ `You are assigned the following DevBridge ticket(s) in this project:`,
1640
+ ticketLines,
1641
+ '',
1642
+ 'Use the DevBridge MCP tools (mcp__devbridge__*) to fetch full ticket details before starting, move the ticket to IN_PROGRESS while you work, and to READY_FOR_REVIEW with a summary comment when done.',
1643
+ '',
1644
+ `Task from the user:`,
1645
+ descriptor.prompt,
1646
+ ].join('\n');
1647
+ }
1648
+ //# sourceMappingURL=supervisor.js.map