@bridge4dev/runner 0.13.1 → 0.26.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/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auth-relay.d.ts +33 -3
  11. package/dist/auth-relay.js +199 -16
  12. package/dist/auto-resume.d.ts +18 -0
  13. package/dist/auto-resume.js +104 -0
  14. package/dist/commit-message.d.ts +51 -0
  15. package/dist/commit-message.js +224 -0
  16. package/dist/config.d.ts +29 -6
  17. package/dist/config.js +15 -0
  18. package/dist/crash-note.d.ts +54 -0
  19. package/dist/crash-note.js +105 -0
  20. package/dist/environment.d.ts +171 -0
  21. package/dist/environment.js +409 -0
  22. package/dist/git.d.ts +81 -0
  23. package/dist/git.js +301 -15
  24. package/dist/gitops.d.ts +489 -12
  25. package/dist/gitops.js +1717 -96
  26. package/dist/index.js +715 -8
  27. package/dist/paths.d.ts +35 -0
  28. package/dist/paths.js +45 -0
  29. package/dist/policy.d.ts +63 -0
  30. package/dist/policy.js +412 -10
  31. package/dist/protocol.d.ts +382 -60
  32. package/dist/protocol.js +104 -1
  33. package/dist/recipe-schema.d.ts +310 -0
  34. package/dist/recipe-schema.js +103 -0
  35. package/dist/recipe.d.ts +94 -0
  36. package/dist/recipe.js +238 -0
  37. package/dist/self-update.d.ts +21 -0
  38. package/dist/self-update.js +73 -1
  39. package/dist/service-unit.d.ts +61 -2
  40. package/dist/service-unit.js +150 -14
  41. package/dist/supervisor.d.ts +108 -1
  42. package/dist/supervisor.js +1045 -57
  43. package/dist/verify-queue.d.ts +17 -0
  44. package/dist/verify-queue.js +100 -0
  45. package/dist/verify.d.ts +203 -0
  46. package/dist/verify.js +788 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +1 -1
@@ -1,11 +1,20 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { log } from './log.js';
2
- import { maskSecrets, maskString } from './policy.js';
4
+ import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
5
+ import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
3
6
  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';
7
+ import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
8
+ import { readRecipeProposal } from './recipe.js';
9
+ import { RECIPE_STEP_NAMES, parseProjectRecipe } from './recipe-schema.js';
10
+ import { proposeCommitMessage } from './commit-message.js';
11
+ import { VerifyRunner, runOneOffCommand, } from './verify.js';
12
+ import { VerifyReportQueue } from './verify-queue.js';
13
+ import { applySession, gitBranches, gitCommit, gitDiff, gitLog, gitPush, gitRefs, gitShow, gitStatus, revertApply, gitStage, gitUnstage, gitDiscard, gitPull, gitMergeAbort, updateFromBase, workspaceState, } from './gitops.js';
6
14
  import { fsView } from './fsview.js';
7
- import { agentAuthStatuses, AuthRelay } from './auth-relay.js';
15
+ import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
8
16
  import { selfUpdate } from './self-update.js';
17
+ import { rememberWorkspacePath } from './environment.js';
9
18
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
10
19
  export class Supervisor {
11
20
  ws;
@@ -30,14 +39,46 @@ export class Supervisor {
30
39
  repoLocks = new Map();
31
40
  /** An update is installing right now — a second one would fight it. */
32
41
  selfUpdateInFlight = false;
42
+ /** Session 14: one project-recipe run per machine, and its verdict queue. */
43
+ verify;
44
+ verifyReports = new VerifyReportQueue();
33
45
  constructor(ws, opts) {
34
46
  this.ws = ws;
35
47
  this.opts = opts;
36
48
  this.journals = opts.journals ?? new JournalStore();
49
+ this.verify = new VerifyRunner({
50
+ enabled: opts.verifyEnabled !== false,
51
+ onReport: (report) => {
52
+ // Queue FIRST, then try the wire. The other order loses the verdict of
53
+ // every build that finishes while the socket happens to be down — and
54
+ // a build finishing during a reconnect is not a rare case, it is a
55
+ // twenty-minute window.
56
+ this.verifyReports.add(report);
57
+ this.flushVerifyReports();
58
+ },
59
+ });
37
60
  ws.on('frame', (frame) => {
38
61
  void this.onFrame(frame).catch((error) => log.error('supervisor: frame handler failed', { type: frame.type, error: String(error) }));
39
62
  });
40
63
  }
64
+ /**
65
+ * Push every unacked verdict at the API.
66
+ *
67
+ * Called on each reconnect and whenever a run finishes. Sending a report the
68
+ * API already has is harmless — it is keyed by `runId` and stored idempotently
69
+ * — while not sending one is a verdict that never existed.
70
+ */
71
+ flushVerifyReports() {
72
+ for (const report of this.verifyReports.pending()) {
73
+ const sent = this.ws.send({
74
+ type: 'verify_report',
75
+ report: report,
76
+ });
77
+ if (!sent)
78
+ break;
79
+ this.verifyReports.markAttempted(report.runId);
80
+ }
81
+ }
41
82
  get activeSessionIds() {
42
83
  return [...this.sessions.keys()];
43
84
  }
@@ -46,10 +87,42 @@ export class Supervisor {
46
87
  case 'hello_ack':
47
88
  this.setMaxSessions(frame.maxSessions);
48
89
  await this.reconcile(frame.sessions);
90
+ // A build that finished while the socket was down has its verdict
91
+ // sitting on disk. This is the moment it can be delivered.
92
+ this.flushVerifyReports();
93
+ break;
94
+ case 'verify_report_ack':
95
+ this.verifyReports.ack(frame.runId);
49
96
  break;
50
97
  case 'server_settings':
51
98
  this.setMaxSessions(frame.maxSessions);
52
99
  break;
100
+ /**
101
+ * Session 15: the project's trust level or auto-commit switch changed
102
+ * while sessions are running. Applied live — both are read on every tool
103
+ * call — because the direction that matters is the TIGHTENING one: a
104
+ * manager switching a project to STRICT and being told it is STRICT while
105
+ * an agent keeps running under AUTO is the worst version of this control.
106
+ */
107
+ case 'workspace_settings': {
108
+ for (const running of this.sessions.values()) {
109
+ if (running.descriptor.workspace.id !== frame.workspaceId)
110
+ continue;
111
+ if (frame.trustMode !== undefined) {
112
+ running.descriptor.workspace.trustMode = frame.trustMode;
113
+ }
114
+ if (frame.agentAutoCommit !== undefined) {
115
+ running.descriptor.workspace.agentAutoCommit = frame.agentAutoCommit;
116
+ }
117
+ running.session?.setWorkspacePolicy({
118
+ ...(frame.trustMode !== undefined ? { trustMode: frame.trustMode } : {}),
119
+ ...(frame.agentAutoCommit !== undefined
120
+ ? { agentAutoCommit: frame.agentAutoCommit }
121
+ : {}),
122
+ });
123
+ }
124
+ break;
125
+ }
53
126
  case 'session_start':
54
127
  await this.startSession(frame.session);
55
128
  break;
@@ -61,6 +134,9 @@ export class Supervisor {
61
134
  running?.session?.answerPermission(frame.requestId, frame.allow, frame.note);
62
135
  break;
63
136
  }
137
+ case 'question_answer':
138
+ this.onQuestionAnswer(frame);
139
+ break;
64
140
  case 'session_stop':
65
141
  this.stopSession(frame.sessionId);
66
142
  break;
@@ -108,7 +184,7 @@ export class Supervisor {
108
184
  if (descriptor.epoch > existing.epoch) {
109
185
  existing.pendingRestart = descriptor;
110
186
  existing.stopRequested = true;
111
- existing.session?.stop();
187
+ existing.session?.stop('session_stopped');
112
188
  }
113
189
  return;
114
190
  }
@@ -118,9 +194,13 @@ export class Supervisor {
118
194
  if (!this.ensureCapacity(descriptor.id)) {
119
195
  const limit = this.maxSessions;
120
196
  this.reportStatus(descriptor.id, 'FAILED', {
121
- errorMessage: limit === 1
197
+ errorMessage: (limit === 1
122
198
  ? 'Runner already has an active session'
123
- : `Runner is already running ${limit} active sessions — stop one first`,
199
+ : `Runner is already running ${limit} active sessions — stop one first`) +
200
+ // Since session 12 a session with an open question holds its slot on
201
+ // purpose — so "finish the turn" is not something waiting will fix,
202
+ // and the message has to say what actually frees it.
203
+ this.waitingForAnswerSuffix(descriptor.id),
124
204
  });
125
205
  return;
126
206
  }
@@ -143,6 +223,7 @@ export class Supervisor {
143
223
  activeMs: descriptor.activeMsBase,
144
224
  extraBudgetMinutes: descriptor.extraBudgetMinutes,
145
225
  epoch: descriptor.epoch,
226
+ openQuestions: new Set(),
146
227
  mode: descriptor.mode,
147
228
  ...(descriptor.model ? { model: descriptor.model } : {}),
148
229
  ...(descriptor.effort ? { effort: descriptor.effort } : {}),
@@ -166,13 +247,15 @@ export class Supervisor {
166
247
  }
167
248
  }
168
249
  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;
250
+ const prepared = await this.prepareWorkspace(descriptor);
251
+ running.branch = prepared.branch;
252
+ running.worktreePath = prepared.worktreePath;
253
+ // Only when WE created the branch: the fork point is a fact the API can
254
+ // learn nowhere else, and it pins the first answer it gets.
255
+ if (prepared.baseSha)
256
+ running.baseSha = prepared.baseSha;
257
+ if (prepared.baseBranch)
258
+ running.baseBranch = prepared.baseBranch;
176
259
  }
177
260
  catch (error) {
178
261
  this.reportStatus(descriptor.id, 'FAILED', {
@@ -204,6 +287,29 @@ export class Supervisor {
204
287
  }
205
288
  this.flushPendingMessages(running);
206
289
  }
290
+ /**
291
+ * Where this session is going to work — a worktree of its own, or the project
292
+ * folder itself (session 16).
293
+ *
294
+ * The BRANCH path takes the repo lock because `worktree add` writes into the
295
+ * shared `.git` (registration + prune), exactly like commit/apply/revert. The
296
+ * DIRECT path takes none: it only READS which branch a folder is on, and
297
+ * queueing every session start behind whatever merge happens to be running
298
+ * would be a lock bought for nothing.
299
+ */
300
+ async prepareWorkspace(descriptor) {
301
+ // Also here, not only at bind time: a server paired before this existed has
302
+ // never sent a `validate_path`, and its projects would be invisible to
303
+ // `doctor` until somebody re-bound them.
304
+ rememberWorkspacePath(descriptor.workspace.path);
305
+ if (descriptor.workMode === 'DIRECT') {
306
+ return prepareDirectWorkspace(descriptor.workspace.path);
307
+ }
308
+ return this.withRepoLockFor(descriptor.workspace.path, () => ensureSessionWorktree(descriptor.workspace.path, descriptor.id, descriptor.branchHint, {
309
+ requireExistingBranch: hasWorkToResume(descriptor),
310
+ ...(descriptor.branchPlan ? { plan: descriptor.branchPlan } : {}),
311
+ }));
312
+ }
207
313
  /**
208
314
  * Spin the adapter up — for a fresh session, a resume-on-next-message, or a
209
315
  * free CHAT session with no prompt at all (the agent boots, reports its
@@ -246,16 +352,23 @@ export class Supervisor {
246
352
  if (running.budgetSpent) {
247
353
  this.sendEvent(running, 'notice', {
248
354
  level: 'warn',
249
- text: 'The time budget is used up — press «Продолжить» to give the agent more time.',
355
+ text: 'The time budget is used up — press «Continue» to give the agent more time.',
250
356
  });
251
357
  return false;
252
358
  }
253
359
  running.lastPrompt = prompt;
360
+ // Facts about this session only. The project's own documentation is read by
361
+ // each agent itself — see `composeWorkspaceContext`.
362
+ const workspaceContext = composeWorkspaceContext(descriptor);
254
363
  running.session = adapter.startSession({
255
364
  sessionId: descriptor.id,
256
365
  cwd: running.worktreePath,
257
366
  ...(prompt ? { prompt } : {}),
367
+ ...(workspaceContext ? { workspaceContext } : {}),
258
368
  trustMode: descriptor.workspace.trustMode,
369
+ ...(descriptor.workspace.agentAutoCommit === undefined
370
+ ? {}
371
+ : { agentAutoCommit: descriptor.workspace.agentAutoCommit }),
259
372
  mode: running.mode,
260
373
  ...(running.model ? { model: running.model } : {}),
261
374
  ...(running.effort ? { effort: running.effort } : {}),
@@ -395,16 +508,17 @@ export class Supervisor {
395
508
  void Promise.resolve(running.session?.interrupt()).catch(() => undefined);
396
509
  this.sendEvent(running, 'notice', {
397
510
  level: 'warn',
398
- text: `The agent worked for its full ${minutes} minutes and was paused. Press «Продолжить» to give it more time.`,
511
+ text: `The agent worked for its full ${minutes} minutes and was paused. Press «Continue» to give it more time.`,
399
512
  });
400
513
  running.stopRequested = true;
514
+ this.withdrawOpenQuestions(running, 'budget_spent');
401
515
  this.reportStatus(descriptor.id, 'STOPPED', {
402
516
  costUsd: running.costUsd,
403
517
  endReason: 'TIME_BUDGET',
404
518
  activeMs: running.activeMs,
405
519
  errorMessage: `Time budget (${minutes} min of agent work) reached`,
406
520
  });
407
- running.session?.stop();
521
+ running.session?.stop('budget_spent');
408
522
  }
409
523
  async pumpEvents(running) {
410
524
  const session = running.session;
@@ -425,6 +539,21 @@ export class Supervisor {
425
539
  // Stream ended — the agent process is gone.
426
540
  if (running.session !== session)
427
541
  return; // superseded (shouldn't happen in v0)
542
+ // Backstop for a process that died without going through `stop()` (a crash,
543
+ // an SDK error, an agent that exited mid-question): the adapter never got
544
+ // to withdraw its cards, and a card nobody can answer must not stay live.
545
+ // Guarded like the event loop above — a journal that cannot be written (a
546
+ // full disk, a daemon already shutting down) must not take the exit path
547
+ // with it, because everything below it is cleanup.
548
+ try {
549
+ this.withdrawOpenQuestions(running, 'session_stopped');
550
+ }
551
+ catch (error) {
552
+ log.warn('supervisor: could not withdraw open questions', {
553
+ sessionId: descriptor.id,
554
+ error: String(error),
555
+ });
556
+ }
428
557
  // The process is down, so nothing is billable any more regardless of the
429
558
  // last status we reported.
430
559
  if (running.activeSince !== undefined) {
@@ -515,6 +644,46 @@ export class Supervisor {
515
644
  // A slot just came free — hand it to whoever was told to wait for one.
516
645
  this.drainSessionsWaitingForCapacity();
517
646
  }
647
+ /**
648
+ * "…and N of them are waiting for an answer from you."
649
+ *
650
+ * An open question pins its slot deliberately (`isParkable`), so a message
651
+ * that blames a running turn sends the user to wait for something that will
652
+ * never happen. Empty when nothing is waiting.
653
+ */
654
+ waitingForAnswerSuffix(exceptSessionId) {
655
+ let waiting = 0;
656
+ for (const running of this.sessions.values()) {
657
+ if (running.descriptor.id === exceptSessionId)
658
+ continue;
659
+ if (running.openQuestions.size > 0)
660
+ waiting++;
661
+ }
662
+ if (waiting === 0)
663
+ return '';
664
+ return waiting === 1
665
+ ? ' — one of them is waiting for your answer to a question'
666
+ : ` — ${waiting} of them are waiting for your answer to a question`;
667
+ }
668
+ /**
669
+ * Close out every ask this session still has open, with a stated cause.
670
+ *
671
+ * Idempotent: the adapter reports its own `question_resolved` when it can, and
672
+ * `forwardEvent` clears the id — so by the time this runs the set is usually
673
+ * already empty. What it catches is the path where the adapter never got the
674
+ * chance, and the alternative there is a card that stays clickable forever.
675
+ */
676
+ withdrawOpenQuestions(running, reason) {
677
+ for (const askId of [...running.openQuestions]) {
678
+ running.openQuestions.delete(askId);
679
+ this.sendEvent(running, 'question_resolved', {
680
+ askId,
681
+ outcome: 'invalidated',
682
+ source: 'runner',
683
+ reason,
684
+ });
685
+ }
686
+ }
518
687
  /**
519
688
  * Deliver messages that were held because every slot was taken.
520
689
  *
@@ -612,19 +781,33 @@ export class Supervisor {
612
781
  }
613
782
  return this.liveSessionCount(exceptSessionId) < limit;
614
783
  }
615
- /** Idle after a finished turn — safe to kill the process and resume later. */
784
+ /**
785
+ * Idle after a finished turn — safe to kill the process and resume later.
786
+ *
787
+ * An open question is the exception (session 12): WAITING_INPUT there does
788
+ * NOT mean "the turn is over", it means the agent's tool call is parked on a
789
+ * human. Parking such a session killed a live turn — and, worse, killed the
790
+ * card the user was about to answer — the moment another session wanted a
791
+ * slot.
792
+ */
616
793
  isParkable(running) {
617
794
  return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
795
+ running.openQuestions.size === 0 &&
618
796
  Boolean(running.descriptor.providerSessionId));
619
797
  }
620
798
  park(running) {
621
799
  if (!running.session)
622
800
  return;
623
801
  running.parkRequested = true;
802
+ // `code` is what the dashboard reads to tell «parked» from «your turn»
803
+ // (#124). The prose stays for runners older than 0.23.0, which the
804
+ // dashboard still matches on; delete that fallback once the fleet has
805
+ // moved, not before.
624
806
  this.sendEvent(running, 'system_note', {
807
+ code: 'session_parked',
625
808
  text: 'Session parked — the runner switched to another session. Send a message to resume.',
626
809
  });
627
- running.session.stop();
810
+ running.session.stop('session_parked');
628
811
  }
629
812
  forwardEvent(running, event) {
630
813
  const { descriptor } = running;
@@ -694,6 +877,15 @@ export class Supervisor {
694
877
  });
695
878
  return;
696
879
  }
880
+ // The retry is spent and the agent is still refused — this is the only
881
+ // authority on a login the credentials file cannot see through (a
882
+ // provider-side revocation leaves the file looking perfectly healthy).
883
+ // The panel is told from here, not from a guess (#121).
884
+ if (isAuthCode(event.code)) {
885
+ const refused = relayAgent(String(descriptor.agent).toLowerCase());
886
+ if (refused)
887
+ noteAgentAuthFailure(refused);
888
+ }
697
889
  // Forward the code: the API stores the payload as-is, so the dashboard
698
890
  // can offer "Sign in" instead of a dead error card.
699
891
  this.sendEvent(running, 'error', {
@@ -733,6 +925,13 @@ export class Supervisor {
733
925
  }
734
926
  return;
735
927
  case 'message':
928
+ // The provider answered, so this sign-in works — drop any refusal we
929
+ // are still holding against it (#121).
930
+ if (event.role === 'assistant') {
931
+ const working = relayAgent(String(descriptor.agent).toLowerCase());
932
+ if (working)
933
+ clearAgentAuthFailure(working);
934
+ }
736
935
  this.sendEvent(running, 'message', { role: event.role, text: event.text });
737
936
  return;
738
937
  case 'thinking':
@@ -740,20 +939,66 @@ export class Supervisor {
740
939
  return;
741
940
  case 'question':
742
941
  // 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 });
942
+ // clock stops with it: since session 12 the agent's tool call is PARKED
943
+ // on this question, so the agent is genuinely idle — billing the wait
944
+ // is the bug session 7 removed (five of the first twelve prod sessions
945
+ // died having spent their budget waiting for a human).
946
+ running.openQuestions.add(event.askId);
947
+ this.sendEvent(running, 'question', {
948
+ askId: event.askId,
949
+ questions: event.questions,
950
+ // Mirror fields — an API/dashboard mid-deploy still draws a card.
951
+ text: event.text,
952
+ options: event.options,
953
+ });
747
954
  running.lastReported = 'WAITING_INPUT';
748
955
  this.syncBudgetClock(running);
749
956
  return;
750
- case 'capabilities':
957
+ case 'question_resolved':
958
+ running.openQuestions.delete(event.askId);
959
+ this.sendEvent(running, 'question_resolved', {
960
+ askId: event.askId,
961
+ outcome: event.outcome,
962
+ source: event.source,
963
+ reason: event.reason,
964
+ answers: event.answers,
965
+ summary: event.summary,
966
+ });
967
+ // The human answered — the parked tool call is released and the agent
968
+ // is working again, so the clock starts. Same shape as the permission
969
+ // path above, and guarded the same way so a terminal status wins.
970
+ // `openQuestions.size` matters: both agents can park several asks at
971
+ // once, and reporting RUNNING while another card is still waiting would
972
+ // bill a human's thinking time all over again (QA-106 M4).
973
+ if (event.source === 'user' &&
974
+ running.openQuestions.size === 0 &&
975
+ running.lastReported === 'WAITING_INPUT') {
976
+ running.lastReported = 'RUNNING';
977
+ this.reportStatus(descriptor.id, 'RUNNING', {});
978
+ }
979
+ return;
980
+ case 'capabilities': {
981
+ // The ADAPTER is the authority on which model is selected — it has the
982
+ // live catalogue and has already resolved the wire id the agent reports
983
+ // back to the row it belongs to (ticket #111). `running.model` is only
984
+ // what was asked for at launch, and it is routinely a name no row in
985
+ // that catalogue carries: sessions whose column was written from an
986
+ // older capabilities event hold a resolved id like `claude-opus-5[1m]`,
987
+ // which the dashboard can match to nothing — so it drew the raw id in
988
+ // the model picker and, because effort levels hang off the selected
989
+ // row, no effort control at all.
990
+ const currentModel = event.capabilities.currentModel ?? running.model;
991
+ // Adopt it, so the next descriptor and the session column carry a name
992
+ // the picker can find instead of re-poisoning them from the old value.
993
+ if (currentModel)
994
+ running.model = currentModel;
751
995
  this.sendEvent(running, 'capabilities', {
752
996
  ...event.capabilities,
753
997
  currentMode: running.mode,
754
- ...(running.model ? { currentModel: running.model } : {}),
998
+ ...(currentModel ? { currentModel } : {}),
755
999
  });
756
1000
  return;
1001
+ }
757
1002
  case 'settings':
758
1003
  if (event.mode)
759
1004
  running.mode = event.mode;
@@ -777,6 +1022,16 @@ export class Supervisor {
777
1022
  maxTokens: event.maxTokens,
778
1023
  });
779
1024
  return;
1025
+ case 'agent_tasks':
1026
+ // Ticket #113. A LEVEL signal: every frame carries the whole live set,
1027
+ // so the dashboard replaces rather than reconciles and a dropped frame
1028
+ // cannot leave a finished subagent spinning in the tray forever.
1029
+ this.sendEvent(running, 'agent_tasks', {
1030
+ tasks: event.tasks,
1031
+ done: event.done,
1032
+ total: event.total,
1033
+ });
1034
+ return;
780
1035
  case 'notice': {
781
1036
  // Only adapter notices are de-duplicated here. The supervisor's own
782
1037
  // notices (turn interrupted, session parked, budget warnings) go
@@ -802,6 +1057,59 @@ export class Supervisor {
802
1057
  return;
803
1058
  }
804
1059
  }
1060
+ /**
1061
+ * The dashboard's answer to a parked question (session 12).
1062
+ *
1063
+ * A miss is reported in the feed rather than swallowed: the three cases that
1064
+ * get here — a card from a previous life of the session, a second click, an
1065
+ * ask the runner already withdrew — all look identical to the user unless
1066
+ * somebody says so.
1067
+ */
1068
+ onQuestionAnswer(frame) {
1069
+ const running = this.sessions.get(frame.sessionId);
1070
+ if (!running) {
1071
+ log.warn('supervisor: question answer for unknown session', { sessionId: frame.sessionId });
1072
+ this.ws.send({ type: 'session_unknown', sessionId: frame.sessionId });
1073
+ return;
1074
+ }
1075
+ const typed = frame.text?.trim();
1076
+ // Asked first, echoed second: the adapter resolves synchronously but its
1077
+ // own events reach the feed a microtask later, so the user's words still
1078
+ // land before the agent's reaction — and on a miss we can hand the text to
1079
+ // the ordinary delivery path, which does the echo itself.
1080
+ const accepted = running.session?.answerQuestion({
1081
+ askId: frame.askId,
1082
+ action: frame.action,
1083
+ ...(frame.answers ? { answers: frame.answers } : {}),
1084
+ ...(frame.text !== undefined ? { text: frame.text } : {}),
1085
+ });
1086
+ if (accepted) {
1087
+ // What the user typed belongs in the conversation, whichever exit they took.
1088
+ if (typed)
1089
+ this.sendEvent(running, 'message', { role: 'user', text: typed });
1090
+ return;
1091
+ }
1092
+ running.openQuestions.delete(frame.askId);
1093
+ this.sendEvent(running, 'system_note', {
1094
+ text: typed
1095
+ ? 'That question is no longer open — sending your reply as an ordinary message instead.'
1096
+ : 'That question is no longer open — the agent has already moved on.',
1097
+ });
1098
+ // The words must not be eaten. This is the ordinary case after a runner
1099
+ // restart: the card in the browser outlived the process that asked, and
1100
+ // showing the user's own message in the feed while nothing receives it is
1101
+ // the exact failure `onUserMessage` was hardened against (QA-106 M2).
1102
+ if (typed) {
1103
+ void this.onUserMessage(frame.sessionId, typed).catch((error) => log.error('supervisor: could not deliver a reply to a closed question', {
1104
+ sessionId: frame.sessionId,
1105
+ error: String(error),
1106
+ }));
1107
+ return;
1108
+ }
1109
+ // Nothing to deliver — but the API optimistically flipped the session to
1110
+ // RUNNING when it relayed the answer, so put the real status back.
1111
+ this.reportStatus(frame.sessionId, statusForReport(running), {});
1112
+ }
805
1113
  async onUserMessage(sessionId, text, attachments) {
806
1114
  const running = this.sessions.get(sessionId);
807
1115
  if (!running) {
@@ -932,9 +1240,11 @@ export class Supervisor {
932
1240
  // Parked session: the follow-up message becomes the resume prompt.
933
1241
  if (!this.ensureCapacity(running.descriptor.id)) {
934
1242
  this.sendEvent(running, 'system_note', {
935
- text: this.maxSessions === 1
1243
+ code: 'runner_busy',
1244
+ text: (this.maxSessions === 1
936
1245
  ? '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.`,
1246
+ : `The runner is busy with ${this.maxSessions} other sessions — this one continues as soon as one of them finishes its turn.`) +
1247
+ this.waitingForAnswerSuffix(running.descriptor.id),
938
1248
  });
939
1249
  // The message is already in the feed; keep it so the resume actually
940
1250
  // carries it once a slot frees up, instead of the user's instruction
@@ -943,6 +1253,9 @@ export class Supervisor {
943
1253
  running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
944
1254
  return;
945
1255
  }
1256
+ // A person typing into the session is the clearest signal that the work is
1257
+ // back on track, so the automatic-continuation allowance starts over.
1258
+ clearAutoResume(running.descriptor.id);
946
1259
  if (this.launchAgent(running, text, running.descriptor.providerSessionId)) {
947
1260
  settle();
948
1261
  return;
@@ -994,6 +1307,12 @@ export class Supervisor {
994
1307
  const running = this.sessions.get(sessionId);
995
1308
  if (!running?.session)
996
1309
  return;
1310
+ // The turn being interrupted is the turn the question belongs to — leaving
1311
+ // the ask parked would make the user's next message be swallowed as its
1312
+ // answer (QA-106 m7). The adapter reports each withdrawal itself; the local
1313
+ // set is cleared so the session can be parked again.
1314
+ running.session.cancelQuestions('turn_aborted');
1315
+ running.openQuestions.clear();
997
1316
  await running.session.interrupt();
998
1317
  this.sendEvent(running, 'notice', { level: 'info', text: 'Turn interrupted by the user' });
999
1318
  const next = running.descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
@@ -1045,9 +1364,10 @@ export class Supervisor {
1045
1364
  running.stopRequested = true;
1046
1365
  this.clearBudgetTimers(running);
1047
1366
  if (running.session) {
1048
- running.session.stop(); // pumpEvents finishes the cleanup
1367
+ running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
1049
1368
  }
1050
1369
  else {
1370
+ this.withdrawOpenQuestions(running, 'session_stopped');
1051
1371
  this.reportStatus(sessionId, 'STOPPED', { activeMs: running.activeMs });
1052
1372
  this.sessions.delete(sessionId);
1053
1373
  if (this.ws.connected && running.journal.unacked().length === 0) {
@@ -1068,9 +1388,10 @@ export class Supervisor {
1068
1388
  running.stopRequested = true;
1069
1389
  this.clearBudgetTimers(running);
1070
1390
  if (running.session) {
1071
- running.session.stop(); // pumpEvents finishes the cleanup
1391
+ running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
1072
1392
  }
1073
1393
  else {
1394
+ this.withdrawOpenQuestions(running, 'session_stopped');
1074
1395
  this.sessions.delete(sessionId);
1075
1396
  }
1076
1397
  }
@@ -1120,8 +1441,13 @@ export class Supervisor {
1120
1441
  // Only replay a terminal status from THIS life of the session. A
1121
1442
  // resumed session carries a higher epoch, and replaying the FAILED it
1122
1443
  // was resumed from would kill it again the moment the runner reconnects.
1444
+ // A journal written by a runner from before session 13 could hold a
1445
+ // `DONE` — it is no longer a status this runner may report, and
1446
+ // replaying one would close the session for the user. Drop it and let
1447
+ // the session be picked back up like any other.
1123
1448
  if (last &&
1124
1449
  isTerminal(last.status) &&
1450
+ last.status !== 'DONE' &&
1125
1451
  (last.epoch ?? 0) >= descriptor.epoch) {
1126
1452
  this.ws.send({
1127
1453
  type: 'session_status',
@@ -1160,6 +1486,7 @@ export class Supervisor {
1160
1486
  activeMs: descriptor.activeMsBase,
1161
1487
  extraBudgetMinutes: descriptor.extraBudgetMinutes,
1162
1488
  epoch: descriptor.epoch,
1489
+ openQuestions: new Set(),
1163
1490
  mode: descriptor.mode,
1164
1491
  ...(descriptor.model ? { model: descriptor.model } : {}),
1165
1492
  ...(descriptor.effort ? { effort: descriptor.effort } : {}),
@@ -1170,9 +1497,16 @@ export class Supervisor {
1170
1497
  running.pendingMessages.push(...running.journal.pending());
1171
1498
  this.sessions.set(descriptor.id, running);
1172
1499
  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;
1500
+ // A session being restored after a runner restart already has its
1501
+ // branch, so the API sends `CONTINUE` — the NEW guard inside would
1502
+ // otherwise fire on the runner's own previous work.
1503
+ const prepared = await this.prepareWorkspace(descriptor);
1504
+ running.branch = prepared.branch;
1505
+ running.worktreePath = prepared.worktreePath;
1506
+ if (prepared.baseSha)
1507
+ running.baseSha = prepared.baseSha;
1508
+ if (prepared.baseBranch)
1509
+ running.baseBranch = prepared.baseBranch;
1176
1510
  }
1177
1511
  catch (error) {
1178
1512
  this.reportStatus(descriptor.id, 'FAILED', {
@@ -1181,9 +1515,43 @@ export class Supervisor {
1181
1515
  this.sessions.delete(descriptor.id);
1182
1516
  continue;
1183
1517
  }
1518
+ /**
1519
+ * Was a turn actually in flight when the process died?
1520
+ *
1521
+ * `RUNNING` and `WAITING_PERMISSION` are the mid-turn statuses; the
1522
+ * others mean the agent was already waiting for a human, and there is
1523
+ * nothing to continue. REVIEW is deliberately excluded — the work is
1524
+ * finished and waiting to be looked at.
1525
+ */
1526
+ const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
1527
+ const resumeId = descriptor.providerSessionId;
1528
+ const willContinue = wasMidTurn && claimAutoResume(descriptor.id);
1529
+ // The note stays either way (owner's call): an interruption is a fact
1530
+ // about the session and must not disappear just because we recovered
1531
+ // from it. Only the instruction at the end changes — telling someone to
1532
+ // send a message while the agent is already working again would be a lie.
1184
1533
  this.sendEvent(running, 'system_note', {
1185
- text: 'Runner reconnected. The session was resumed — send a message to continue.',
1534
+ text: willContinue
1535
+ ? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
1536
+ : 'Runner reconnected. The session was resumed — send a message to continue.',
1186
1537
  });
1538
+ if (willContinue) {
1539
+ // Resumed through the PROVIDER session, so the agent keeps its whole
1540
+ // conversation; the prompt is only the nudge a human would otherwise
1541
+ // have to type. Exactly what «продолжай» did by hand — no new class of
1542
+ // risk, and the same ceiling protects against a crash loop doing it
1543
+ // forever (see `auto-resume.ts`).
1544
+ if (this.launchAgent(running, AUTO_RESUME_PROMPT, resumeId)) {
1545
+ this.reportStatus(descriptor.id, 'RUNNING', {});
1546
+ this.flushPendingMessages(running);
1547
+ continue;
1548
+ }
1549
+ // Could not start (an exhausted budget is the only way here). Fall
1550
+ // through to the old behaviour and say so honestly.
1551
+ this.sendEvent(running, 'system_note', {
1552
+ text: 'Could not continue automatically — send a message to pick the work back up.',
1553
+ });
1554
+ }
1187
1555
  // REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
1188
1556
  // mid-turn statuses are downgraded to "waiting for the user".
1189
1557
  if (descriptor.status !== 'REVIEW') {
@@ -1214,6 +1582,7 @@ export class Supervisor {
1214
1582
  */
1215
1583
  pruneJournals() {
1216
1584
  try {
1585
+ pruneAutoResume(new Set(this.sessions.keys()));
1217
1586
  const removed = this.journals.prune({
1218
1587
  maxAgeMs: Supervisor.JOURNAL_TTL_MS,
1219
1588
  hardMaxAgeMs: Supervisor.JOURNAL_HARD_TTL_MS,
@@ -1237,6 +1606,10 @@ export class Supervisor {
1237
1606
  if (!path)
1238
1607
  return void reply({ ok: false, error: 'path argument is required' });
1239
1608
  const validation = await validateWorkspacePath(path);
1609
+ // Remembered so `devbridge-runner doctor` can check the permissions
1610
+ // of the real projects without being told which they are.
1611
+ if (validation.ok)
1612
+ rememberWorkspacePath(path);
1240
1613
  return void reply({
1241
1614
  ok: validation.ok,
1242
1615
  result: validation,
@@ -1300,7 +1673,17 @@ export class Supervisor {
1300
1673
  });
1301
1674
  return void reply({
1302
1675
  ok: true,
1303
- result: await gitStatus(paths.worktreePath, paths.workspacePath, paths.branch),
1676
+ result: await gitStatus({
1677
+ worktreePath: paths.worktreePath,
1678
+ workspacePath: paths.workspacePath,
1679
+ sessionBranch: paths.branch,
1680
+ ...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
1681
+ ...optionalSha(frame.args?.['appliedBranchSha'], 'appliedBranchSha'),
1682
+ ...optionalSha(frame.args?.['pushedSha'], 'pushedSha'),
1683
+ // Session 16: a DIRECT session has no base to be measured
1684
+ // against — the folder's own branch is where the work is.
1685
+ ...(frame.args?.['direct'] === true ? { direct: true } : {}),
1686
+ }),
1304
1687
  });
1305
1688
  }
1306
1689
  case 'git_diff': {
@@ -1314,7 +1697,10 @@ export class Supervisor {
1314
1697
  }
1315
1698
  return void reply({
1316
1699
  ok: true,
1317
- result: await gitDiff(paths.worktreePath, paths.workspacePath, paths.branch, filePath),
1700
+ result: await gitDiff(paths.worktreePath, paths.workspacePath, paths.branch, filePath, str(frame.args?.['baseBranch']) ?? undefined,
1701
+ // Session 16: the Source Control panel asks for one side of the
1702
+ // index at a time. Anything else keeps the historical meaning.
1703
+ diffMode(frame.args?.['mode'])),
1318
1704
  });
1319
1705
  }
1320
1706
  // Session 11: history. Read-only, so no repo lock and no busy check —
@@ -1324,20 +1710,32 @@ export class Supervisor {
1324
1710
  case 'git_log': {
1325
1711
  const workspacePath = str(frame.args?.['workspacePath']);
1326
1712
  const branch = str(frame.args?.['branch']);
1327
- if (!workspacePath || !branch) {
1328
- return void reply({ ok: false, error: 'workspacePath and branch are required' });
1713
+ const selector = refSelector(frame.args);
1714
+ // Session 14: the repository graph is reached from the WORKSPACE, so
1715
+ // a branch is no longer required — but then a ref selector is, or the
1716
+ // call would have nothing to walk and git would quietly default to
1717
+ // HEAD.
1718
+ if (!workspacePath || (!branch && Object.keys(selector).length === 0)) {
1719
+ return void reply({
1720
+ ok: false,
1721
+ error: 'workspacePath and either branch or a ref selection are required',
1722
+ });
1329
1723
  }
1330
1724
  const scope = logScope(frame.args?.['scope']);
1331
1725
  const limit = num(frame.args?.['limit']);
1332
1726
  const skip = num(frame.args?.['skip']);
1727
+ const cursor = str(frame.args?.['cursor']);
1333
1728
  return void reply({
1334
1729
  ok: true,
1335
1730
  result: await gitLog({
1336
1731
  workspacePath,
1337
- branch,
1732
+ ...(branch ? { branch } : {}),
1338
1733
  ...(scope ? { scope } : {}),
1339
1734
  ...(limit !== null ? { limit } : {}),
1340
1735
  ...(skip !== null ? { skip } : {}),
1736
+ ...(cursor ? { cursor } : {}),
1737
+ ...selector,
1738
+ ...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
1341
1739
  }),
1342
1740
  });
1343
1741
  }
@@ -1349,11 +1747,45 @@ export class Supervisor {
1349
1747
  }
1350
1748
  const filePath = str(frame.args?.['path']);
1351
1749
  const showBranch = str(frame.args?.['branch']);
1750
+ const selector = refSelector(frame.args);
1352
1751
  return void reply({
1353
1752
  ok: true,
1354
- result: await gitShow(workspacePath, sha, filePath ?? undefined, showBranch ?? undefined),
1753
+ result: await gitShow(workspacePath, sha, filePath ?? undefined, showBranch ?? undefined, str(frame.args?.['baseBranch']) ?? undefined,
1754
+ // The visibility set is widened by exactly the refs the LOG was
1755
+ // allowed to walk. Passing it only when the caller sent one keeps
1756
+ // every session read on its original two refs.
1757
+ Object.keys(selector).length > 0 ? selector : undefined),
1355
1758
  });
1356
1759
  }
1760
+ // Session 14: the ref list behind the graph's branch picker. Read-only,
1761
+ // same call as git_log — `for-each-ref` cannot move anything.
1762
+ case 'git_refs': {
1763
+ const workspacePath = str(frame.args?.['workspacePath']);
1764
+ if (!workspacePath) {
1765
+ return void reply({ ok: false, error: 'workspacePath is required' });
1766
+ }
1767
+ return void reply({ ok: true, result: await gitRefs(workspacePath) });
1768
+ }
1769
+ // Session 14: what the project folder is ACTUALLY on. The product used
1770
+ // to collect this during path validation and throw it away, which is
1771
+ // why it could never answer «is the running build yours».
1772
+ case 'workspace_state': {
1773
+ const workspacePath = str(frame.args?.['workspacePath']);
1774
+ if (!workspacePath) {
1775
+ return void reply({ ok: false, error: 'workspacePath is required' });
1776
+ }
1777
+ return void reply({ ok: true, result: await workspaceState(workspacePath) });
1778
+ }
1779
+ // Session 13: the branch list the «continue an existing branch» picker
1780
+ // reads. Read-only — `for-each-ref` and `worktree list` cannot move a
1781
+ // ref — so no lock and no busy check, same call as git_log above.
1782
+ case 'git_branches': {
1783
+ const workspacePath = str(frame.args?.['workspacePath']);
1784
+ if (!workspacePath) {
1785
+ return void reply({ ok: false, error: 'workspacePath is required' });
1786
+ }
1787
+ return void reply({ ok: true, result: await gitBranches(workspacePath) });
1788
+ }
1357
1789
  case 'git_commit': {
1358
1790
  const worktreePath = str(frame.args?.['worktreePath']);
1359
1791
  const message = str(frame.args?.['message']);
@@ -1366,9 +1798,14 @@ export class Supervisor {
1366
1798
  error: 'The agent is still working — wait for the turn to finish',
1367
1799
  });
1368
1800
  }
1801
+ // Session 16: `all: false` commits the INDEX — what the person
1802
+ // staged, and nothing else. Absent stays `git add -A` + commit, which
1803
+ // is what the agent's own auto-commit has always meant and what every
1804
+ // API older than this release will keep asking for.
1805
+ const all = frame.args?.['all'] !== false;
1369
1806
  return void reply({
1370
1807
  ok: true,
1371
- result: await this.withRepoLockFor(worktreePath, () => gitCommit(worktreePath, message)),
1808
+ result: await this.withRepoLockFor(worktreePath, () => gitCommit(worktreePath, message, { all })),
1372
1809
  });
1373
1810
  }
1374
1811
  case 'apply_session': {
@@ -1386,22 +1823,181 @@ export class Supervisor {
1386
1823
  error: 'The agent is still working — wait for the turn to finish',
1387
1824
  });
1388
1825
  }
1389
- const applied = await this.withRepoLockFor(paths.workspacePath, () => applySession(paths.workspacePath, paths.worktreePath, paths.branch, message));
1390
- return void reply(applied.applied || applied.conflict
1826
+ const applied = await this.withRepoLockFor(paths.workspacePath, () => applySession({
1827
+ workspacePath: paths.workspacePath,
1828
+ worktreePath: paths.worktreePath,
1829
+ sessionBranch: paths.branch,
1830
+ message,
1831
+ ...optionalBranch(frame.args?.['expectedBase'], 'expectedBase'),
1832
+ ...optionalSha(frame.args?.['sinceSha'], 'sinceSha'),
1833
+ }));
1834
+ // «Nothing new to apply», «the folder is on another branch» and «the
1835
+ // folder has uncommitted changes» are answers, not failures: the API
1836
+ // turns each into a sentence with a way forward, and an `ok: false`
1837
+ // here would surface as a raw error toast instead.
1838
+ //
1839
+ // `workspaceDirty` joining that list is safe for an API older than
1840
+ // session 15: it does not know the flag, so it falls through to the
1841
+ // same `AppError.conflict(result.error)` it raises today, with the
1842
+ // same wording.
1843
+ return void reply(applied.applied ||
1844
+ applied.conflict ||
1845
+ applied.noChanges ||
1846
+ applied.drifted ||
1847
+ applied.workspaceDirty
1391
1848
  ? { ok: true, result: applied }
1392
1849
  : { ok: false, error: applied.error ?? 'Apply failed', result: applied });
1393
1850
  }
1851
+ // Session 13: merge the base branch INTO the session branch. Merge and
1852
+ // never rebase — rebase rewrites commits the History tab already showed
1853
+ // a human. Takes the worktree busy check and the repo lock, because it
1854
+ // writes into the session's index just like a commit does.
1855
+ case 'update_from_base': {
1856
+ const paths = gitCommandPaths(frame.args);
1857
+ if (!paths) {
1858
+ return void reply({
1859
+ ok: false,
1860
+ error: 'worktreePath/workspacePath/branch are required',
1861
+ });
1862
+ }
1863
+ if (this.isWorktreeBusy(paths.worktreePath)) {
1864
+ return void reply({
1865
+ ok: false,
1866
+ error: 'The agent is still working — wait for the turn to finish',
1867
+ });
1868
+ }
1869
+ const updated = await this.withRepoLockFor(paths.worktreePath, () => updateFromBase({
1870
+ workspacePath: paths.workspacePath,
1871
+ worktreePath: paths.worktreePath,
1872
+ sessionBranch: paths.branch,
1873
+ ...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
1874
+ }));
1875
+ return void reply(updated.updated || updated.conflict
1876
+ ? { ok: true, result: updated }
1877
+ : { ok: false, error: updated.error ?? 'Update failed', result: updated });
1878
+ }
1879
+ // Session 13: the one outgoing write DevBridge performs. Never reached
1880
+ // by an agent — layer 1 denies `git push` outright — only by a human
1881
+ // pressing the button, and only for this session's own branch.
1882
+ case 'git_push': {
1883
+ const workspacePath = str(frame.args?.['workspacePath']);
1884
+ const branch = str(frame.args?.['branch']);
1885
+ const remote = str(frame.args?.['remote']);
1886
+ if (!workspacePath || !branch || !remote) {
1887
+ return void reply({
1888
+ ok: false,
1889
+ error: 'workspacePath/branch/remote are required',
1890
+ });
1891
+ }
1892
+ const pushed = await this.withRepoLockFor(workspacePath, () => gitPush({ workspacePath, branch, remote }));
1893
+ return void reply(pushed.pushed
1894
+ ? { ok: true, result: pushed }
1895
+ : { ok: false, error: pushed.error ?? 'Push failed', result: pushed });
1896
+ }
1394
1897
  case 'revert_apply': {
1395
1898
  const workspacePath = str(frame.args?.['workspacePath']);
1396
1899
  const commitSha = str(frame.args?.['commitSha']);
1397
1900
  if (!workspacePath || !commitSha) {
1398
1901
  return void reply({ ok: false, error: 'workspacePath and commitSha are required' });
1399
1902
  }
1400
- const reverted = await this.withRepoLockFor(workspacePath, () => revertApply(workspacePath, commitSha));
1401
- return void reply(reverted.reverted || reverted.conflict
1903
+ const expected = optionalBranch(frame.args?.['expectedBase'], 'expectedBase');
1904
+ const reverted = await this.withRepoLockFor(workspacePath, () => revertApply(workspacePath, commitSha, expected.expectedBase));
1905
+ // Same reasoning as `apply_session`: a dirty project folder is a
1906
+ // state the API can explain and offer a way out of, not an error.
1907
+ return void reply(reverted.reverted || reverted.conflict || reverted.workspaceDirty
1402
1908
  ? { ok: true, result: reverted }
1403
1909
  : { ok: false, error: reverted.error ?? 'Revert failed', result: reverted });
1404
1910
  }
1911
+ /**
1912
+ * Session 16 — the Source Control panel's four verbs.
1913
+ *
1914
+ * All of them WRITE into the index or the working tree of the session's
1915
+ * folder, so all of them take the repo lock and the worktree busy check:
1916
+ * staging a file underneath an agent that is mid-edit produces a commit
1917
+ * of half a thought, and «the agent is still working» is a sentence a
1918
+ * person can act on where a git error is not.
1919
+ *
1920
+ * They are deliberately four commands and not one `git` passthrough. A
1921
+ * passthrough would be a shell on somebody's server behind a web button;
1922
+ * these take a list of paths, and every one of those paths is checked
1923
+ * before it reaches an argv slot.
1924
+ */
1925
+ case 'git_stage':
1926
+ case 'git_unstage':
1927
+ case 'git_discard': {
1928
+ const worktreePath = str(frame.args?.['worktreePath']);
1929
+ if (!worktreePath) {
1930
+ return void reply({ ok: false, error: 'worktreePath is required' });
1931
+ }
1932
+ const all = frame.args?.['all'] === true;
1933
+ const paths = Array.isArray(frame.args?.['paths']) ? frame.args?.['paths'] : null;
1934
+ if (!all && !paths) {
1935
+ return void reply({ ok: false, error: 'paths or all is required' });
1936
+ }
1937
+ if (this.isWorktreeBusy(worktreePath)) {
1938
+ return void reply({
1939
+ ok: false,
1940
+ error: 'The agent is still working — wait for the turn to finish',
1941
+ });
1942
+ }
1943
+ const request = { worktreePath, ...(all ? { all } : { paths: paths }) };
1944
+ const run = frame.name === 'git_stage'
1945
+ ? () => gitStage(request)
1946
+ : frame.name === 'git_unstage'
1947
+ ? () => gitUnstage(request)
1948
+ : () => gitDiscard(request);
1949
+ try {
1950
+ const result = await this.withRepoLockFor(worktreePath, run);
1951
+ return void reply({ ok: true, result });
1952
+ }
1953
+ catch (error) {
1954
+ // A refused path is a sentence the panel shows verbatim («Path must
1955
+ // be relative to the repository: …»), not a stack trace.
1956
+ return void reply({ ok: false, error: maskSecretText(error) });
1957
+ }
1958
+ }
1959
+ /**
1960
+ * Session 16: `git pull`, and the way back out of one that conflicted.
1961
+ *
1962
+ * The only command in this group that reaches the network. It can leave
1963
+ * a conflicted tree behind on purpose — that is what git does, and the
1964
+ * panel draws it as «Merge Changes» rather than pretending the pull did
1965
+ * not happen.
1966
+ */
1967
+ case 'git_pull': {
1968
+ const worktreePath = str(frame.args?.['worktreePath']);
1969
+ if (!worktreePath) {
1970
+ return void reply({ ok: false, error: 'worktreePath is required' });
1971
+ }
1972
+ if (this.isWorktreeBusy(worktreePath)) {
1973
+ return void reply({
1974
+ ok: false,
1975
+ error: 'The agent is still working — wait for the turn to finish',
1976
+ });
1977
+ }
1978
+ const pulled = await this.withRepoLockFor(worktreePath, () => gitPull({ worktreePath }));
1979
+ // A conflict is an outcome with a state attached, not a failure: the
1980
+ // API turns it into a sentence and the panel into a file group.
1981
+ return void reply(pulled.pulled || pulled.conflict
1982
+ ? { ok: true, result: pulled }
1983
+ : { ok: false, error: pulled.error ?? 'Pull failed', result: pulled });
1984
+ }
1985
+ case 'git_merge_abort': {
1986
+ const worktreePath = str(frame.args?.['worktreePath']);
1987
+ if (!worktreePath) {
1988
+ return void reply({ ok: false, error: 'worktreePath is required' });
1989
+ }
1990
+ if (this.isWorktreeBusy(worktreePath)) {
1991
+ return void reply({
1992
+ ok: false,
1993
+ error: 'The agent is still working — wait for the turn to finish',
1994
+ });
1995
+ }
1996
+ const aborted = await this.withRepoLockFor(worktreePath, () => gitMergeAbort(worktreePath));
1997
+ return void reply(aborted.aborted
1998
+ ? { ok: true, result: aborted }
1999
+ : { ok: false, error: aborted.reason ?? 'Nothing to abort', result: aborted });
2000
+ }
1405
2001
  case 'fs_view': {
1406
2002
  const root = str(frame.args?.['root']);
1407
2003
  if (!root)
@@ -1451,6 +2047,185 @@ export class Supervisor {
1451
2047
  this.opts.onRestartRequested?.(outcome);
1452
2048
  return;
1453
2049
  }
2050
+ // ─── Session 14: the project recipe ──────────────────────────
2051
+ //
2052
+ // Read-only, always available even when verification is switched off:
2053
+ // «this machine will not run it» is a different answer from «this
2054
+ // project has no recipe», and the card has to be able to say both.
2055
+ case 'recipe_state': {
2056
+ const root = str(frame.args?.['root']) ?? str(frame.args?.['workspacePath']);
2057
+ if (!root)
2058
+ return void reply({ ok: false, error: 'workspacePath is required' });
2059
+ return void reply({
2060
+ ok: true,
2061
+ result: { ...readRecipeProposal(root), verifyEnabled: this.verify.enabled },
2062
+ });
2063
+ }
2064
+ case 'verify_start': {
2065
+ const parsed = parseVerifyStart(frame.args);
2066
+ if ('error' in parsed)
2067
+ return void reply({ ok: false, error: parsed.error });
2068
+ const started = this.verify.start(parsed.input);
2069
+ return void reply(started.started
2070
+ ? { ok: true, result: { started: true, runId: parsed.input.runId } }
2071
+ : { ok: false, error: started.error ?? 'Could not start the verification' });
2072
+ }
2073
+ case 'verify_status': {
2074
+ const runId = str(frame.args?.['runId']);
2075
+ if (!runId)
2076
+ return void reply({ ok: false, error: 'runId is required' });
2077
+ const status = this.verify.status(runId, num(frame.args?.['offset']) ?? 0);
2078
+ return void reply(status
2079
+ ? { ok: true, result: status }
2080
+ : { ok: false, error: 'This runner does not know that verification run' });
2081
+ }
2082
+ case 'verify_cancel': {
2083
+ const runId = str(frame.args?.['runId']);
2084
+ if (!runId)
2085
+ return void reply({ ok: false, error: 'runId is required' });
2086
+ const cancelled = this.verify.cancel(runId);
2087
+ return void reply(cancelled.cancelled
2088
+ ? { ok: true, result: cancelled }
2089
+ : { ok: false, error: cancelled.error ?? 'Could not cancel' });
2090
+ }
2091
+ /**
2092
+ * Session 14: show a branch WITHOUT touching the project folder.
2093
+ *
2094
+ * A second, detached worktree plus the recipe's own `preview` command —
2095
+ * which has its own docker project and its own ports. Without that
2096
+ * command there is nothing honest to do: rebuilding in the project
2097
+ * folder would replace the one copy the machine is running.
2098
+ */
2099
+ case 'preview_checkout': {
2100
+ const workspacePath = str(frame.args?.['workspacePath']);
2101
+ const branch = str(frame.args?.['branch']);
2102
+ const parsed = parseVerifyStart(frame.args);
2103
+ if (!workspacePath || !branch) {
2104
+ return void reply({ ok: false, error: 'workspacePath and branch are required' });
2105
+ }
2106
+ if ('error' in parsed)
2107
+ return void reply({ ok: false, error: parsed.error });
2108
+ if (!parsed.input.recipe.preview?.run) {
2109
+ return void reply({
2110
+ ok: false,
2111
+ error: 'Previewing this branch would rebuild the only copy this machine is running. Add a `preview` command to the recipe — with its own docker project name and its own ports — and the preview gets its own stack.',
2112
+ });
2113
+ }
2114
+ const checkout = await this.withRepoLockFor(workspacePath, () => ensurePreviewWorktree({
2115
+ workspacePath,
2116
+ workspaceKey: frame.workspaceId ?? workspacePath,
2117
+ branch,
2118
+ }));
2119
+ const started = this.verify.start({
2120
+ ...parsed.input,
2121
+ target: 'PREVIEW',
2122
+ preview: true,
2123
+ cwd: checkout.worktreePath,
2124
+ branch: checkout.branch,
2125
+ commitSha: checkout.sha,
2126
+ dirty: false,
2127
+ });
2128
+ return void reply(started.started
2129
+ ? {
2130
+ ok: true,
2131
+ result: {
2132
+ started: true,
2133
+ runId: parsed.input.runId,
2134
+ worktreePath: checkout.worktreePath,
2135
+ branch: checkout.branch,
2136
+ sha: checkout.sha,
2137
+ url: parsed.input.recipe.preview.url ?? null,
2138
+ },
2139
+ }
2140
+ : { ok: false, error: started.error ?? 'Could not start the preview' });
2141
+ }
2142
+ case 'preview_stop': {
2143
+ const workspacePath = str(frame.args?.['workspacePath']);
2144
+ if (!workspacePath)
2145
+ return void reply({ ok: false, error: 'workspacePath is required' });
2146
+ const key = frame.workspaceId ?? workspacePath;
2147
+ const stopCommand = str(frame.args?.['stop']);
2148
+ const dockerProject = str(frame.args?.['project']);
2149
+ // Cancel the preview's own run FIRST (session 15).
2150
+ //
2151
+ // «Stop preview» used to run the stop command and delete the
2152
+ // worktree while `preview.run` was still executing inside it — so a
2153
+ // half-built `docker compose up --build` lost its directory mid-flight
2154
+ // and went on holding the machine's single verification slot until it
2155
+ // timed out, with the dashboard already showing the preview as
2156
+ // stopped. Only a PREVIEW run is ours to cancel: a build somebody
2157
+ // started for a session is a different thing entirely.
2158
+ if (this.verify.activeTarget === 'PREVIEW') {
2159
+ const runId = this.verify.activeRunId;
2160
+ if (runId)
2161
+ this.verify.cancel(runId);
2162
+ }
2163
+ if (stopCommand) {
2164
+ // The one place layer 1 lifts `docker … down`, and only for the
2165
+ // compose project the recipe named as its own.
2166
+ const decision = evaluateRecipeCommand(stopCommand, {
2167
+ isPreviewStop: true,
2168
+ ...(dockerProject ? { dockerProject } : {}),
2169
+ });
2170
+ if (!decision.allowed) {
2171
+ return void reply({
2172
+ ok: false,
2173
+ error: `The stop command is refused: ${decision.reason}`,
2174
+ });
2175
+ }
2176
+ await runPreviewStop(stopCommand, previewWorktreePath(key));
2177
+ }
2178
+ const removed = await this.withRepoLockFor(workspacePath, () => removePreviewWorktree(key));
2179
+ return void reply({ ok: true, result: { stopped: true, removed } });
2180
+ }
2181
+ /**
2182
+ * Session 14: a commit message written by an agent, in a one-shot run.
2183
+ *
2184
+ * Never `send()` into the live session — that would fill the feed with a
2185
+ * request nobody made, spend the session's context on the whole diff and
2186
+ * fight the worktree lock the agent holds mid-turn.
2187
+ */
2188
+ case 'propose_commit_message': {
2189
+ const paths = gitCommandPaths(frame.args);
2190
+ if (!paths) {
2191
+ return void reply({
2192
+ ok: false,
2193
+ error: 'worktreePath/workspacePath/branch are required',
2194
+ });
2195
+ }
2196
+ const ticketsRaw = frame.args?.['tickets'];
2197
+ const subjectsRaw = frame.args?.['commitSubjects'];
2198
+ const propose = this.opts.proposeCommitMessage ?? proposeCommitMessage;
2199
+ const result = await propose({
2200
+ worktreePath: paths.worktreePath,
2201
+ workspacePath: paths.workspacePath,
2202
+ branch: paths.branch,
2203
+ ...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
2204
+ ...(Array.isArray(ticketsRaw)
2205
+ ? {
2206
+ tickets: ticketsRaw
2207
+ .filter((n) => typeof n === 'number')
2208
+ .slice(0, 20),
2209
+ }
2210
+ : {}),
2211
+ ...(Array.isArray(subjectsRaw)
2212
+ ? {
2213
+ commitSubjects: subjectsRaw
2214
+ .filter((s) => typeof s === 'string')
2215
+ .slice(0, 20),
2216
+ }
2217
+ : {}),
2218
+ ...(str(frame.args?.['language'])
2219
+ ? { language: str(frame.args?.['language']) }
2220
+ : {}),
2221
+ ...(str(frame.args?.['convention'])
2222
+ ? { convention: str(frame.args?.['convention']) }
2223
+ : {}),
2224
+ });
2225
+ return void reply(result.ok
2226
+ ? { ok: true, result }
2227
+ : { ok: false, error: result.error ?? 'Could not write a commit message', result });
2228
+ }
1454
2229
  case 'auth_status':
1455
2230
  return void reply({ ok: true, result: await agentAuthStatuses() });
1456
2231
  case 'login_start': {
@@ -1465,6 +2240,9 @@ export class Supervisor {
1465
2240
  if (!agent || !code)
1466
2241
  return void reply({ ok: false, error: 'agent and code are required' });
1467
2242
  const result = await this.authRelay.submitCode(agent, code);
2243
+ // A fresh credential outranks anything we remember about the old one.
2244
+ if (result.ok)
2245
+ clearAgentAuthFailure(agent);
1468
2246
  return void reply({
1469
2247
  ok: result.ok,
1470
2248
  result,
@@ -1552,7 +2330,19 @@ export class Supervisor {
1552
2330
  running.lastReported = status;
1553
2331
  this.syncBudgetClock(running);
1554
2332
  }
1555
- const compact = maskSecrets(Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== undefined)));
2333
+ // The fork point rides along on the first frame after the branch was
2334
+ // created — once, because it never changes and the API pins the first
2335
+ // answer it hears. Sending it on every frame would invite a later, wrong
2336
+ // value to overwrite the right one if the pinning rule were ever relaxed.
2337
+ const base = running && !running.baseReported && (running.baseBranch || running.baseSha)
2338
+ ? {
2339
+ ...(running.baseBranch ? { baseBranch: running.baseBranch } : {}),
2340
+ ...(running.baseSha ? { baseSha: running.baseSha } : {}),
2341
+ }
2342
+ : {};
2343
+ if (running && Object.keys(base).length > 0)
2344
+ running.baseReported = true;
2345
+ const compact = maskSecrets(Object.fromEntries(Object.entries({ ...extra, ...base }).filter(([, v]) => v !== undefined)));
1556
2346
  // Journal the latest status for replay after a reconnect (QA-96 F1) —
1557
2347
  // only for tracked sessions, so one-shot failure reports don't leave
1558
2348
  // orphan journal files behind. The epoch stamp keeps a previous life's
@@ -1575,9 +2365,31 @@ export class Supervisor {
1575
2365
  this.authRelay.cancel();
1576
2366
  for (const running of this.sessions.values()) {
1577
2367
  this.clearBudgetTimers(running);
1578
- running.session?.stop();
2368
+ // BEFORE stop(), and from here rather than from the adapter: the adapter
2369
+ // pushes its `question_resolved` into an async queue that `pumpEvents`
2370
+ // drains a microtask later, and every caller of shutdown() (SIGTERM,
2371
+ // «Update runner», `revoked`) calls `process.exit` in the same tick — so
2372
+ // that event would never be journaled and never reach the wire. This
2373
+ // path writes the journal synchronously, and an unacked event is
2374
+ // redelivered on the next connection (QA-106 M1).
2375
+ try {
2376
+ this.withdrawOpenQuestions(running, 'runner_restarted');
2377
+ }
2378
+ catch (error) {
2379
+ log.warn('supervisor: could not withdraw open questions on shutdown', {
2380
+ sessionId: running.descriptor.id,
2381
+ error: String(error),
2382
+ });
2383
+ }
2384
+ // The reason matters here: this path also runs for «Update runner», and a
2385
+ // card that vanishes during an update must say why (session 12).
2386
+ running.session?.stop('runner_restarted');
1579
2387
  }
1580
2388
  this.sessions.clear();
2389
+ // A build that was mid-flight is abandoned, not judged: its row stays
2390
+ // RUNNING until the API's sweep turns it into LOST. A verdict nobody
2391
+ // observed must never become PASSED.
2392
+ this.verify.shutdown();
1581
2393
  }
1582
2394
  }
1583
2395
  function str(value) {
@@ -1596,6 +2408,105 @@ function num(value) {
1596
2408
  function logScope(value) {
1597
2409
  return value === 'session' || value === 'branch' || value === 'all' ? value : null;
1598
2410
  }
2411
+ /** Which side of the index a diff request means (session 16). */
2412
+ function diffMode(value) {
2413
+ return value === 'staged' || value === 'worktree' ? value : 'base';
2414
+ }
2415
+ /**
2416
+ * Optional command arguments that become git refs.
2417
+ *
2418
+ * They arrive from the API as `unknown` and end up in an argv slot, so they are
2419
+ * shaped here rather than passed through: a value that is not a ref name (or
2420
+ * not a hash) is dropped, and the callee falls back to what it did before the
2421
+ * argument existed. `exactOptionalPropertyTypes` is why these return a spread
2422
+ * object instead of `T | undefined`.
2423
+ */
2424
+ function optionalBranch(value, key) {
2425
+ const raw = str(value);
2426
+ if (!raw || raw.length > 200 || !/^[A-Za-z0-9][A-Za-z0-9._\-/]*$/.test(raw))
2427
+ return {};
2428
+ return { [key]: raw };
2429
+ }
2430
+ function optionalSha(value, key) {
2431
+ const raw = str(value);
2432
+ if (!raw || !/^[0-9a-f]{7,64}$/i.test(raw))
2433
+ return {};
2434
+ return { [key]: raw };
2435
+ }
2436
+ /**
2437
+ * Which refs a history request may walk (session 14).
2438
+ *
2439
+ * Present only when the caller actually asked for something: an empty object
2440
+ * means «no selector», which keeps every session-level call on exactly the
2441
+ * two-ref behaviour it had before this release. Names are re-validated inside
2442
+ * `gitLog` too — this is the cheap first gate, not the only one.
2443
+ */
2444
+ function refSelector(args) {
2445
+ const raw = args?.['refs'];
2446
+ const refs = Array.isArray(raw)
2447
+ ? raw.filter((value) => typeof value === 'string').slice(0, 64)
2448
+ : null;
2449
+ return {
2450
+ ...(refs && refs.length > 0 ? { refs } : {}),
2451
+ ...(args?.['includeRemotes'] === true ? { includeRemotes: true } : {}),
2452
+ ...(args?.['includeTags'] === true ? { includeTags: true } : {}),
2453
+ ...(args?.['all'] === true ? { all: true } : {}),
2454
+ };
2455
+ }
2456
+ /**
2457
+ * Everything `verify_start` / `preview_checkout` need, validated as one unit.
2458
+ *
2459
+ * The recipe travels on the frame rather than being read off disk here, and
2460
+ * that is the whole point of the design: the executable copy is the one a human
2461
+ * approved on the DevBridge side, not whatever the working tree says today. The
2462
+ * fingerprint is re-checked inside `VerifyRunner.start`, so a frame that lies
2463
+ * about which recipe it carries gets nowhere.
2464
+ */
2465
+ function parseVerifyStart(args) {
2466
+ const runId = str(args?.['runId']);
2467
+ if (!runId || !/^[A-Za-z0-9_-]{8,64}$/.test(runId))
2468
+ return { error: 'A valid runId is required' };
2469
+ const recipeSha = str(args?.['recipeSha']);
2470
+ if (!recipeSha)
2471
+ return { error: 'recipeSha is required' };
2472
+ const cwd = str(args?.['cwd']);
2473
+ if (!cwd || !path.isAbsolute(cwd) || cwd.split('/').includes('..')) {
2474
+ return { error: 'cwd must be an absolute path without ".." segments' };
2475
+ }
2476
+ const parsed = parseProjectRecipe(args?.['recipe']);
2477
+ if (!parsed.ok)
2478
+ return { error: `The approved recipe is not valid: ${parsed.error}` };
2479
+ const targetRaw = args?.['target'];
2480
+ const target = targetRaw === 'BASE' || targetRaw === 'SESSION' || targetRaw === 'PREVIEW'
2481
+ ? targetRaw
2482
+ : 'SESSION';
2483
+ const stepsRaw = args?.['steps'];
2484
+ const steps = Array.isArray(stepsRaw)
2485
+ ? RECIPE_STEP_NAMES.filter((name) => stepsRaw.includes(name))
2486
+ : RECIPE_STEP_NAMES.filter((name) => name !== 'deploy');
2487
+ return {
2488
+ input: {
2489
+ runId,
2490
+ target,
2491
+ recipe: parsed.recipe,
2492
+ recipeSha,
2493
+ cwd,
2494
+ steps: [...steps],
2495
+ branch: str(args?.['branch']),
2496
+ commitSha: str(args?.['commitSha']),
2497
+ dirty: args?.['dirty'] === true,
2498
+ },
2499
+ };
2500
+ }
2501
+ /** Stop a preview's own stack, inside its own worktree. */
2502
+ async function runPreviewStop(command, cwd) {
2503
+ if (!fs.existsSync(cwd))
2504
+ return;
2505
+ const result = await runOneOffCommand(command, cwd);
2506
+ if (!result.ok) {
2507
+ log.warn('preview: the stop command failed', { exitCode: result.exitCode });
2508
+ }
2509
+ }
1599
2510
  function gitCommandPaths(args) {
1600
2511
  const worktreePath = str(args?.['worktreePath']);
1601
2512
  const workspacePath = str(args?.['workspacePath']);
@@ -1607,6 +2518,15 @@ function statusForReport(running) {
1607
2518
  ? 'RUNNING'
1608
2519
  : running.lastReported;
1609
2520
  }
2521
+ /**
2522
+ * What we say to an agent whose turn a runner restart cut off (session 18).
2523
+ *
2524
+ * Deliberately the same thing a human would type — the provider session is
2525
+ * resumed, so the agent still has the whole conversation and only needs to be
2526
+ * told to carry on. Spelling out WHY matters: without it the agent tends to
2527
+ * summarise what it had done instead of finishing it.
2528
+ */
2529
+ const AUTO_RESUME_PROMPT = 'Your previous turn was cut off because the runner process restarted — not by me, and not because anything was wrong with the work. Continue from where you stopped. Re-check the state of anything you were in the middle of before redoing it.';
1610
2530
  const AGENT_LABELS = { CLAUDE: 'Claude Code', CODEX: 'Codex' };
1611
2531
  /** Only Claude reports USD — Codex sessions are bounded by time instead. */
1612
2532
  function reportsCost(agent) {
@@ -1631,18 +2551,86 @@ function isTerminal(status) {
1631
2551
  function isSettled(status) {
1632
2552
  return isTerminal(status) || status === 'REVIEW';
1633
2553
  }
2554
+ /**
2555
+ * The first message the agent gets.
2556
+ *
2557
+ * Two shapes, and the difference is whether tickets were handed over
2558
+ * (session 16, owner's decision):
2559
+ *
2560
+ * - **No tickets** — exactly what the human typed, and an empty prompt stays
2561
+ * empty. That is how you get a session that boots and waits for you to talk
2562
+ * first; it is a feature, not an oversight.
2563
+ * - **Tickets handed to a TICKET session** — the assignment is always sent and
2564
+ * cannot be removed: «Implement ticket #28.» Handing a ticket over IS the
2565
+ * instruction, so making the human retype it was ceremony. Anything they
2566
+ * typed follows underneath.
2567
+ *
2568
+ * What is NOT here any more: eight lines about which MCP tools to call and
2569
+ * which statuses to move through. That is a standing convention of the project,
2570
+ * true on the twentieth turn as much as the first, so it moved to the system
2571
+ * prompt beside `CLAUDE.md` — which is read last and therefore overrides ours.
2572
+ */
1634
2573
  export function composeInitialPrompt(descriptor) {
1635
- if (descriptor.tickets.length === 0)
2574
+ // A free chat may carry tickets as CONTEXT. Assigning them would put an agent
2575
+ // nobody asked onto somebody else's ticket (session 13).
2576
+ if (descriptor.kind === 'CHAT' || descriptor.tickets.length === 0)
1636
2577
  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');
2578
+ const numbers = descriptor.tickets.map((t) => `#${t.number}`).join(', ');
2579
+ const assignment = descriptor.tickets.length === 1
2580
+ ? `Implement ticket ${numbers}.`
2581
+ : `Implement tickets ${numbers}.`;
2582
+ return descriptor.prompt ? `${assignment}\n\n${descriptor.prompt}` : assignment;
2583
+ }
2584
+ /**
2585
+ * Extra system-prompt material: where this session is in git, and how tickets
2586
+ * are meant to move (session 13).
2587
+ *
2588
+ * Facts about THIS SESSION, and nothing else. They exist in no file on disk —
2589
+ * which branch the agent is on, that it must not push, where a plan belongs,
2590
+ * what to do with the tickets it was given — so somebody has to say them, and
2591
+ * that somebody is us.
2592
+ *
2593
+ * The project's own documentation is deliberately NOT here. Both agents read
2594
+ * their own file natively, verified live: Claude picks up `CLAUDE.md` through
2595
+ * its memory mechanism (since `settingSources` includes `'project'`), and Codex
2596
+ * picks up `AGENTS.md` even under the runner's isolated `CODEX_HOME`. Pasting a
2597
+ * copy on top of that was work we were doing for no one — and when it was
2598
+ * switched off for Claude alone it briefly left repositories that carry only
2599
+ * `AGENTS.md` with nothing at all, which is precisely the kind of hole a
2600
+ * half-measure digs.
2601
+ *
2602
+ * A repository that wants both agents equipped ships both files, or symlinks
2603
+ * one to the other. That is a repository convention and not something a runner
2604
+ * should paper over.
2605
+ */
2606
+ export function composeWorkspaceContext(descriptor) {
2607
+ const sections = [];
2608
+ const plan = descriptor.branchPlan;
2609
+ if (plan) {
2610
+ const forked = plan.baseBranch ? ` It was branched from \`${plan.baseBranch}\`.` : '';
2611
+ sections.push([
2612
+ 'Git in this session:',
2613
+ `- You are in a dedicated worktree on branch \`${plan.branch}\`.${forked}`,
2614
+ '- Commit to this branch. Do not switch branches and do not merge into the base branch yourself — a human presses «Apply» in DevBridge, which squash-merges your branch for them.',
2615
+ '- You cannot push: `git push` is refused. A human presses «Push» when they want the branch on the remote.',
2616
+ `- Put a plan or working notes in \`docs/devbridge/${plan.branch.replace(/\//g, '-')}.md\`, unless this repository already has its own convention for where such documents live — if it does, follow that.`,
2617
+ ].join('\n'));
2618
+ }
2619
+ // The ticket convention lives HERE, not in the first chat message (session
2620
+ // 16). It is a standing rule of the project — true on the twentieth turn as
2621
+ // much as the first — and putting it in the visible prompt only trained the
2622
+ // reader to skip past it. `CLAUDE.md` is appended after this section, so a
2623
+ // repository that states its own rule overrides ours by being read last.
2624
+ if (descriptor.kind !== 'CHAT' && descriptor.tickets.length > 0) {
2625
+ sections.push([
2626
+ 'DevBridge tickets in this session:',
2627
+ '- Read the full ticket with the DevBridge MCP tools (`mcp__devbridge__*`) before starting — the title alone is never the whole task.',
2628
+ '- Move it to IN_PROGRESS while you work and to READY_FOR_REVIEW with a short summary comment when you are done.',
2629
+ ].join('\n'));
2630
+ }
2631
+ else if (descriptor.tickets.length > 0) {
2632
+ sections.push('DevBridge tickets are attached to this chat for CONTEXT only. Read them with the DevBridge MCP tools; do not change their status — this session is not assigned to them.');
2633
+ }
2634
+ return sections.join('\n\n');
1647
2635
  }
1648
2636
  //# sourceMappingURL=supervisor.js.map