@bridge4dev/runner 0.13.1 → 0.22.1

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 (45) 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/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +402 -4
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +28 -1
  35. package/dist/service-unit.d.ts +48 -1
  36. package/dist/service-unit.js +109 -4
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
@@ -1,8 +1,16 @@
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
15
  import { agentAuthStatuses, AuthRelay } from './auth-relay.js';
8
16
  import { selfUpdate } from './self-update.js';
@@ -30,14 +38,46 @@ export class Supervisor {
30
38
  repoLocks = new Map();
31
39
  /** An update is installing right now — a second one would fight it. */
32
40
  selfUpdateInFlight = false;
41
+ /** Session 14: one project-recipe run per machine, and its verdict queue. */
42
+ verify;
43
+ verifyReports = new VerifyReportQueue();
33
44
  constructor(ws, opts) {
34
45
  this.ws = ws;
35
46
  this.opts = opts;
36
47
  this.journals = opts.journals ?? new JournalStore();
48
+ this.verify = new VerifyRunner({
49
+ enabled: opts.verifyEnabled !== false,
50
+ onReport: (report) => {
51
+ // Queue FIRST, then try the wire. The other order loses the verdict of
52
+ // every build that finishes while the socket happens to be down — and
53
+ // a build finishing during a reconnect is not a rare case, it is a
54
+ // twenty-minute window.
55
+ this.verifyReports.add(report);
56
+ this.flushVerifyReports();
57
+ },
58
+ });
37
59
  ws.on('frame', (frame) => {
38
60
  void this.onFrame(frame).catch((error) => log.error('supervisor: frame handler failed', { type: frame.type, error: String(error) }));
39
61
  });
40
62
  }
63
+ /**
64
+ * Push every unacked verdict at the API.
65
+ *
66
+ * Called on each reconnect and whenever a run finishes. Sending a report the
67
+ * API already has is harmless — it is keyed by `runId` and stored idempotently
68
+ * — while not sending one is a verdict that never existed.
69
+ */
70
+ flushVerifyReports() {
71
+ for (const report of this.verifyReports.pending()) {
72
+ const sent = this.ws.send({
73
+ type: 'verify_report',
74
+ report: report,
75
+ });
76
+ if (!sent)
77
+ break;
78
+ this.verifyReports.markAttempted(report.runId);
79
+ }
80
+ }
41
81
  get activeSessionIds() {
42
82
  return [...this.sessions.keys()];
43
83
  }
@@ -46,10 +86,42 @@ export class Supervisor {
46
86
  case 'hello_ack':
47
87
  this.setMaxSessions(frame.maxSessions);
48
88
  await this.reconcile(frame.sessions);
89
+ // A build that finished while the socket was down has its verdict
90
+ // sitting on disk. This is the moment it can be delivered.
91
+ this.flushVerifyReports();
92
+ break;
93
+ case 'verify_report_ack':
94
+ this.verifyReports.ack(frame.runId);
49
95
  break;
50
96
  case 'server_settings':
51
97
  this.setMaxSessions(frame.maxSessions);
52
98
  break;
99
+ /**
100
+ * Session 15: the project's trust level or auto-commit switch changed
101
+ * while sessions are running. Applied live — both are read on every tool
102
+ * call — because the direction that matters is the TIGHTENING one: a
103
+ * manager switching a project to STRICT and being told it is STRICT while
104
+ * an agent keeps running under AUTO is the worst version of this control.
105
+ */
106
+ case 'workspace_settings': {
107
+ for (const running of this.sessions.values()) {
108
+ if (running.descriptor.workspace.id !== frame.workspaceId)
109
+ continue;
110
+ if (frame.trustMode !== undefined) {
111
+ running.descriptor.workspace.trustMode = frame.trustMode;
112
+ }
113
+ if (frame.agentAutoCommit !== undefined) {
114
+ running.descriptor.workspace.agentAutoCommit = frame.agentAutoCommit;
115
+ }
116
+ running.session?.setWorkspacePolicy({
117
+ ...(frame.trustMode !== undefined ? { trustMode: frame.trustMode } : {}),
118
+ ...(frame.agentAutoCommit !== undefined
119
+ ? { agentAutoCommit: frame.agentAutoCommit }
120
+ : {}),
121
+ });
122
+ }
123
+ break;
124
+ }
53
125
  case 'session_start':
54
126
  await this.startSession(frame.session);
55
127
  break;
@@ -61,6 +133,9 @@ export class Supervisor {
61
133
  running?.session?.answerPermission(frame.requestId, frame.allow, frame.note);
62
134
  break;
63
135
  }
136
+ case 'question_answer':
137
+ this.onQuestionAnswer(frame);
138
+ break;
64
139
  case 'session_stop':
65
140
  this.stopSession(frame.sessionId);
66
141
  break;
@@ -108,7 +183,7 @@ export class Supervisor {
108
183
  if (descriptor.epoch > existing.epoch) {
109
184
  existing.pendingRestart = descriptor;
110
185
  existing.stopRequested = true;
111
- existing.session?.stop();
186
+ existing.session?.stop('session_stopped');
112
187
  }
113
188
  return;
114
189
  }
@@ -118,9 +193,13 @@ export class Supervisor {
118
193
  if (!this.ensureCapacity(descriptor.id)) {
119
194
  const limit = this.maxSessions;
120
195
  this.reportStatus(descriptor.id, 'FAILED', {
121
- errorMessage: limit === 1
196
+ errorMessage: (limit === 1
122
197
  ? 'Runner already has an active session'
123
- : `Runner is already running ${limit} active sessions — stop one first`,
198
+ : `Runner is already running ${limit} active sessions — stop one first`) +
199
+ // Since session 12 a session with an open question holds its slot on
200
+ // purpose — so "finish the turn" is not something waiting will fix,
201
+ // and the message has to say what actually frees it.
202
+ this.waitingForAnswerSuffix(descriptor.id),
124
203
  });
125
204
  return;
126
205
  }
@@ -143,6 +222,7 @@ export class Supervisor {
143
222
  activeMs: descriptor.activeMsBase,
144
223
  extraBudgetMinutes: descriptor.extraBudgetMinutes,
145
224
  epoch: descriptor.epoch,
225
+ openQuestions: new Set(),
146
226
  mode: descriptor.mode,
147
227
  ...(descriptor.model ? { model: descriptor.model } : {}),
148
228
  ...(descriptor.effort ? { effort: descriptor.effort } : {}),
@@ -166,13 +246,15 @@ export class Supervisor {
166
246
  }
167
247
  }
168
248
  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;
249
+ const prepared = await this.prepareWorkspace(descriptor);
250
+ running.branch = prepared.branch;
251
+ running.worktreePath = prepared.worktreePath;
252
+ // Only when WE created the branch: the fork point is a fact the API can
253
+ // learn nowhere else, and it pins the first answer it gets.
254
+ if (prepared.baseSha)
255
+ running.baseSha = prepared.baseSha;
256
+ if (prepared.baseBranch)
257
+ running.baseBranch = prepared.baseBranch;
176
258
  }
177
259
  catch (error) {
178
260
  this.reportStatus(descriptor.id, 'FAILED', {
@@ -204,6 +286,25 @@ export class Supervisor {
204
286
  }
205
287
  this.flushPendingMessages(running);
206
288
  }
289
+ /**
290
+ * Where this session is going to work — a worktree of its own, or the project
291
+ * folder itself (session 16).
292
+ *
293
+ * The BRANCH path takes the repo lock because `worktree add` writes into the
294
+ * shared `.git` (registration + prune), exactly like commit/apply/revert. The
295
+ * DIRECT path takes none: it only READS which branch a folder is on, and
296
+ * queueing every session start behind whatever merge happens to be running
297
+ * would be a lock bought for nothing.
298
+ */
299
+ async prepareWorkspace(descriptor) {
300
+ if (descriptor.workMode === 'DIRECT') {
301
+ return prepareDirectWorkspace(descriptor.workspace.path);
302
+ }
303
+ return this.withRepoLockFor(descriptor.workspace.path, () => ensureSessionWorktree(descriptor.workspace.path, descriptor.id, descriptor.branchHint, {
304
+ requireExistingBranch: hasWorkToResume(descriptor),
305
+ ...(descriptor.branchPlan ? { plan: descriptor.branchPlan } : {}),
306
+ }));
307
+ }
207
308
  /**
208
309
  * Spin the adapter up — for a fresh session, a resume-on-next-message, or a
209
310
  * free CHAT session with no prompt at all (the agent boots, reports its
@@ -246,16 +347,23 @@ export class Supervisor {
246
347
  if (running.budgetSpent) {
247
348
  this.sendEvent(running, 'notice', {
248
349
  level: 'warn',
249
- text: 'The time budget is used up — press «Продолжить» to give the agent more time.',
350
+ text: 'The time budget is used up — press «Continue» to give the agent more time.',
250
351
  });
251
352
  return false;
252
353
  }
253
354
  running.lastPrompt = prompt;
355
+ // Facts about this session only. The project's own documentation is read by
356
+ // each agent itself — see `composeWorkspaceContext`.
357
+ const workspaceContext = composeWorkspaceContext(descriptor);
254
358
  running.session = adapter.startSession({
255
359
  sessionId: descriptor.id,
256
360
  cwd: running.worktreePath,
257
361
  ...(prompt ? { prompt } : {}),
362
+ ...(workspaceContext ? { workspaceContext } : {}),
258
363
  trustMode: descriptor.workspace.trustMode,
364
+ ...(descriptor.workspace.agentAutoCommit === undefined
365
+ ? {}
366
+ : { agentAutoCommit: descriptor.workspace.agentAutoCommit }),
259
367
  mode: running.mode,
260
368
  ...(running.model ? { model: running.model } : {}),
261
369
  ...(running.effort ? { effort: running.effort } : {}),
@@ -395,16 +503,17 @@ export class Supervisor {
395
503
  void Promise.resolve(running.session?.interrupt()).catch(() => undefined);
396
504
  this.sendEvent(running, 'notice', {
397
505
  level: 'warn',
398
- text: `The agent worked for its full ${minutes} minutes and was paused. Press «Продолжить» to give it more time.`,
506
+ text: `The agent worked for its full ${minutes} minutes and was paused. Press «Continue» to give it more time.`,
399
507
  });
400
508
  running.stopRequested = true;
509
+ this.withdrawOpenQuestions(running, 'budget_spent');
401
510
  this.reportStatus(descriptor.id, 'STOPPED', {
402
511
  costUsd: running.costUsd,
403
512
  endReason: 'TIME_BUDGET',
404
513
  activeMs: running.activeMs,
405
514
  errorMessage: `Time budget (${minutes} min of agent work) reached`,
406
515
  });
407
- running.session?.stop();
516
+ running.session?.stop('budget_spent');
408
517
  }
409
518
  async pumpEvents(running) {
410
519
  const session = running.session;
@@ -425,6 +534,21 @@ export class Supervisor {
425
534
  // Stream ended — the agent process is gone.
426
535
  if (running.session !== session)
427
536
  return; // superseded (shouldn't happen in v0)
537
+ // Backstop for a process that died without going through `stop()` (a crash,
538
+ // an SDK error, an agent that exited mid-question): the adapter never got
539
+ // to withdraw its cards, and a card nobody can answer must not stay live.
540
+ // Guarded like the event loop above — a journal that cannot be written (a
541
+ // full disk, a daemon already shutting down) must not take the exit path
542
+ // with it, because everything below it is cleanup.
543
+ try {
544
+ this.withdrawOpenQuestions(running, 'session_stopped');
545
+ }
546
+ catch (error) {
547
+ log.warn('supervisor: could not withdraw open questions', {
548
+ sessionId: descriptor.id,
549
+ error: String(error),
550
+ });
551
+ }
428
552
  // The process is down, so nothing is billable any more regardless of the
429
553
  // last status we reported.
430
554
  if (running.activeSince !== undefined) {
@@ -515,6 +639,46 @@ export class Supervisor {
515
639
  // A slot just came free — hand it to whoever was told to wait for one.
516
640
  this.drainSessionsWaitingForCapacity();
517
641
  }
642
+ /**
643
+ * "…and N of them are waiting for an answer from you."
644
+ *
645
+ * An open question pins its slot deliberately (`isParkable`), so a message
646
+ * that blames a running turn sends the user to wait for something that will
647
+ * never happen. Empty when nothing is waiting.
648
+ */
649
+ waitingForAnswerSuffix(exceptSessionId) {
650
+ let waiting = 0;
651
+ for (const running of this.sessions.values()) {
652
+ if (running.descriptor.id === exceptSessionId)
653
+ continue;
654
+ if (running.openQuestions.size > 0)
655
+ waiting++;
656
+ }
657
+ if (waiting === 0)
658
+ return '';
659
+ return waiting === 1
660
+ ? ' — one of them is waiting for your answer to a question'
661
+ : ` — ${waiting} of them are waiting for your answer to a question`;
662
+ }
663
+ /**
664
+ * Close out every ask this session still has open, with a stated cause.
665
+ *
666
+ * Idempotent: the adapter reports its own `question_resolved` when it can, and
667
+ * `forwardEvent` clears the id — so by the time this runs the set is usually
668
+ * already empty. What it catches is the path where the adapter never got the
669
+ * chance, and the alternative there is a card that stays clickable forever.
670
+ */
671
+ withdrawOpenQuestions(running, reason) {
672
+ for (const askId of [...running.openQuestions]) {
673
+ running.openQuestions.delete(askId);
674
+ this.sendEvent(running, 'question_resolved', {
675
+ askId,
676
+ outcome: 'invalidated',
677
+ source: 'runner',
678
+ reason,
679
+ });
680
+ }
681
+ }
518
682
  /**
519
683
  * Deliver messages that were held because every slot was taken.
520
684
  *
@@ -612,9 +776,18 @@ export class Supervisor {
612
776
  }
613
777
  return this.liveSessionCount(exceptSessionId) < limit;
614
778
  }
615
- /** Idle after a finished turn — safe to kill the process and resume later. */
779
+ /**
780
+ * Idle after a finished turn — safe to kill the process and resume later.
781
+ *
782
+ * An open question is the exception (session 12): WAITING_INPUT there does
783
+ * NOT mean "the turn is over", it means the agent's tool call is parked on a
784
+ * human. Parking such a session killed a live turn — and, worse, killed the
785
+ * card the user was about to answer — the moment another session wanted a
786
+ * slot.
787
+ */
616
788
  isParkable(running) {
617
789
  return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
790
+ running.openQuestions.size === 0 &&
618
791
  Boolean(running.descriptor.providerSessionId));
619
792
  }
620
793
  park(running) {
@@ -624,7 +797,7 @@ export class Supervisor {
624
797
  this.sendEvent(running, 'system_note', {
625
798
  text: 'Session parked — the runner switched to another session. Send a message to resume.',
626
799
  });
627
- running.session.stop();
800
+ running.session.stop('session_parked');
628
801
  }
629
802
  forwardEvent(running, event) {
630
803
  const { descriptor } = running;
@@ -740,20 +913,66 @@ export class Supervisor {
740
913
  return;
741
914
  case 'question':
742
915
  // 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 });
916
+ // clock stops with it: since session 12 the agent's tool call is PARKED
917
+ // on this question, so the agent is genuinely idle — billing the wait
918
+ // is the bug session 7 removed (five of the first twelve prod sessions
919
+ // died having spent their budget waiting for a human).
920
+ running.openQuestions.add(event.askId);
921
+ this.sendEvent(running, 'question', {
922
+ askId: event.askId,
923
+ questions: event.questions,
924
+ // Mirror fields — an API/dashboard mid-deploy still draws a card.
925
+ text: event.text,
926
+ options: event.options,
927
+ });
747
928
  running.lastReported = 'WAITING_INPUT';
748
929
  this.syncBudgetClock(running);
749
930
  return;
750
- case 'capabilities':
931
+ case 'question_resolved':
932
+ running.openQuestions.delete(event.askId);
933
+ this.sendEvent(running, 'question_resolved', {
934
+ askId: event.askId,
935
+ outcome: event.outcome,
936
+ source: event.source,
937
+ reason: event.reason,
938
+ answers: event.answers,
939
+ summary: event.summary,
940
+ });
941
+ // The human answered — the parked tool call is released and the agent
942
+ // is working again, so the clock starts. Same shape as the permission
943
+ // path above, and guarded the same way so a terminal status wins.
944
+ // `openQuestions.size` matters: both agents can park several asks at
945
+ // once, and reporting RUNNING while another card is still waiting would
946
+ // bill a human's thinking time all over again (QA-106 M4).
947
+ if (event.source === 'user' &&
948
+ running.openQuestions.size === 0 &&
949
+ running.lastReported === 'WAITING_INPUT') {
950
+ running.lastReported = 'RUNNING';
951
+ this.reportStatus(descriptor.id, 'RUNNING', {});
952
+ }
953
+ return;
954
+ case 'capabilities': {
955
+ // The ADAPTER is the authority on which model is selected — it has the
956
+ // live catalogue and has already resolved the wire id the agent reports
957
+ // back to the row it belongs to (ticket #111). `running.model` is only
958
+ // what was asked for at launch, and it is routinely a name no row in
959
+ // that catalogue carries: sessions whose column was written from an
960
+ // older capabilities event hold a resolved id like `claude-opus-5[1m]`,
961
+ // which the dashboard can match to nothing — so it drew the raw id in
962
+ // the model picker and, because effort levels hang off the selected
963
+ // row, no effort control at all.
964
+ const currentModel = event.capabilities.currentModel ?? running.model;
965
+ // Adopt it, so the next descriptor and the session column carry a name
966
+ // the picker can find instead of re-poisoning them from the old value.
967
+ if (currentModel)
968
+ running.model = currentModel;
751
969
  this.sendEvent(running, 'capabilities', {
752
970
  ...event.capabilities,
753
971
  currentMode: running.mode,
754
- ...(running.model ? { currentModel: running.model } : {}),
972
+ ...(currentModel ? { currentModel } : {}),
755
973
  });
756
974
  return;
975
+ }
757
976
  case 'settings':
758
977
  if (event.mode)
759
978
  running.mode = event.mode;
@@ -777,6 +996,16 @@ export class Supervisor {
777
996
  maxTokens: event.maxTokens,
778
997
  });
779
998
  return;
999
+ case 'agent_tasks':
1000
+ // Ticket #113. A LEVEL signal: every frame carries the whole live set,
1001
+ // so the dashboard replaces rather than reconciles and a dropped frame
1002
+ // cannot leave a finished subagent spinning in the tray forever.
1003
+ this.sendEvent(running, 'agent_tasks', {
1004
+ tasks: event.tasks,
1005
+ done: event.done,
1006
+ total: event.total,
1007
+ });
1008
+ return;
780
1009
  case 'notice': {
781
1010
  // Only adapter notices are de-duplicated here. The supervisor's own
782
1011
  // notices (turn interrupted, session parked, budget warnings) go
@@ -802,6 +1031,59 @@ export class Supervisor {
802
1031
  return;
803
1032
  }
804
1033
  }
1034
+ /**
1035
+ * The dashboard's answer to a parked question (session 12).
1036
+ *
1037
+ * A miss is reported in the feed rather than swallowed: the three cases that
1038
+ * get here — a card from a previous life of the session, a second click, an
1039
+ * ask the runner already withdrew — all look identical to the user unless
1040
+ * somebody says so.
1041
+ */
1042
+ onQuestionAnswer(frame) {
1043
+ const running = this.sessions.get(frame.sessionId);
1044
+ if (!running) {
1045
+ log.warn('supervisor: question answer for unknown session', { sessionId: frame.sessionId });
1046
+ this.ws.send({ type: 'session_unknown', sessionId: frame.sessionId });
1047
+ return;
1048
+ }
1049
+ const typed = frame.text?.trim();
1050
+ // Asked first, echoed second: the adapter resolves synchronously but its
1051
+ // own events reach the feed a microtask later, so the user's words still
1052
+ // land before the agent's reaction — and on a miss we can hand the text to
1053
+ // the ordinary delivery path, which does the echo itself.
1054
+ const accepted = running.session?.answerQuestion({
1055
+ askId: frame.askId,
1056
+ action: frame.action,
1057
+ ...(frame.answers ? { answers: frame.answers } : {}),
1058
+ ...(frame.text !== undefined ? { text: frame.text } : {}),
1059
+ });
1060
+ if (accepted) {
1061
+ // What the user typed belongs in the conversation, whichever exit they took.
1062
+ if (typed)
1063
+ this.sendEvent(running, 'message', { role: 'user', text: typed });
1064
+ return;
1065
+ }
1066
+ running.openQuestions.delete(frame.askId);
1067
+ this.sendEvent(running, 'system_note', {
1068
+ text: typed
1069
+ ? 'That question is no longer open — sending your reply as an ordinary message instead.'
1070
+ : 'That question is no longer open — the agent has already moved on.',
1071
+ });
1072
+ // The words must not be eaten. This is the ordinary case after a runner
1073
+ // restart: the card in the browser outlived the process that asked, and
1074
+ // showing the user's own message in the feed while nothing receives it is
1075
+ // the exact failure `onUserMessage` was hardened against (QA-106 M2).
1076
+ if (typed) {
1077
+ void this.onUserMessage(frame.sessionId, typed).catch((error) => log.error('supervisor: could not deliver a reply to a closed question', {
1078
+ sessionId: frame.sessionId,
1079
+ error: String(error),
1080
+ }));
1081
+ return;
1082
+ }
1083
+ // Nothing to deliver — but the API optimistically flipped the session to
1084
+ // RUNNING when it relayed the answer, so put the real status back.
1085
+ this.reportStatus(frame.sessionId, statusForReport(running), {});
1086
+ }
805
1087
  async onUserMessage(sessionId, text, attachments) {
806
1088
  const running = this.sessions.get(sessionId);
807
1089
  if (!running) {
@@ -932,9 +1214,10 @@ export class Supervisor {
932
1214
  // Parked session: the follow-up message becomes the resume prompt.
933
1215
  if (!this.ensureCapacity(running.descriptor.id)) {
934
1216
  this.sendEvent(running, 'system_note', {
935
- text: this.maxSessions === 1
1217
+ text: (this.maxSessions === 1
936
1218
  ? '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.`,
1219
+ : `The runner is busy with ${this.maxSessions} other sessions — this one continues as soon as one of them finishes its turn.`) +
1220
+ this.waitingForAnswerSuffix(running.descriptor.id),
938
1221
  });
939
1222
  // The message is already in the feed; keep it so the resume actually
940
1223
  // carries it once a slot frees up, instead of the user's instruction
@@ -943,6 +1226,9 @@ export class Supervisor {
943
1226
  running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
944
1227
  return;
945
1228
  }
1229
+ // A person typing into the session is the clearest signal that the work is
1230
+ // back on track, so the automatic-continuation allowance starts over.
1231
+ clearAutoResume(running.descriptor.id);
946
1232
  if (this.launchAgent(running, text, running.descriptor.providerSessionId)) {
947
1233
  settle();
948
1234
  return;
@@ -994,6 +1280,12 @@ export class Supervisor {
994
1280
  const running = this.sessions.get(sessionId);
995
1281
  if (!running?.session)
996
1282
  return;
1283
+ // The turn being interrupted is the turn the question belongs to — leaving
1284
+ // the ask parked would make the user's next message be swallowed as its
1285
+ // answer (QA-106 m7). The adapter reports each withdrawal itself; the local
1286
+ // set is cleared so the session can be parked again.
1287
+ running.session.cancelQuestions('turn_aborted');
1288
+ running.openQuestions.clear();
997
1289
  await running.session.interrupt();
998
1290
  this.sendEvent(running, 'notice', { level: 'info', text: 'Turn interrupted by the user' });
999
1291
  const next = running.descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
@@ -1045,9 +1337,10 @@ export class Supervisor {
1045
1337
  running.stopRequested = true;
1046
1338
  this.clearBudgetTimers(running);
1047
1339
  if (running.session) {
1048
- running.session.stop(); // pumpEvents finishes the cleanup
1340
+ running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
1049
1341
  }
1050
1342
  else {
1343
+ this.withdrawOpenQuestions(running, 'session_stopped');
1051
1344
  this.reportStatus(sessionId, 'STOPPED', { activeMs: running.activeMs });
1052
1345
  this.sessions.delete(sessionId);
1053
1346
  if (this.ws.connected && running.journal.unacked().length === 0) {
@@ -1068,9 +1361,10 @@ export class Supervisor {
1068
1361
  running.stopRequested = true;
1069
1362
  this.clearBudgetTimers(running);
1070
1363
  if (running.session) {
1071
- running.session.stop(); // pumpEvents finishes the cleanup
1364
+ running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
1072
1365
  }
1073
1366
  else {
1367
+ this.withdrawOpenQuestions(running, 'session_stopped');
1074
1368
  this.sessions.delete(sessionId);
1075
1369
  }
1076
1370
  }
@@ -1120,8 +1414,13 @@ export class Supervisor {
1120
1414
  // Only replay a terminal status from THIS life of the session. A
1121
1415
  // resumed session carries a higher epoch, and replaying the FAILED it
1122
1416
  // was resumed from would kill it again the moment the runner reconnects.
1417
+ // A journal written by a runner from before session 13 could hold a
1418
+ // `DONE` — it is no longer a status this runner may report, and
1419
+ // replaying one would close the session for the user. Drop it and let
1420
+ // the session be picked back up like any other.
1123
1421
  if (last &&
1124
1422
  isTerminal(last.status) &&
1423
+ last.status !== 'DONE' &&
1125
1424
  (last.epoch ?? 0) >= descriptor.epoch) {
1126
1425
  this.ws.send({
1127
1426
  type: 'session_status',
@@ -1160,6 +1459,7 @@ export class Supervisor {
1160
1459
  activeMs: descriptor.activeMsBase,
1161
1460
  extraBudgetMinutes: descriptor.extraBudgetMinutes,
1162
1461
  epoch: descriptor.epoch,
1462
+ openQuestions: new Set(),
1163
1463
  mode: descriptor.mode,
1164
1464
  ...(descriptor.model ? { model: descriptor.model } : {}),
1165
1465
  ...(descriptor.effort ? { effort: descriptor.effort } : {}),
@@ -1170,9 +1470,16 @@ export class Supervisor {
1170
1470
  running.pendingMessages.push(...running.journal.pending());
1171
1471
  this.sessions.set(descriptor.id, running);
1172
1472
  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;
1473
+ // A session being restored after a runner restart already has its
1474
+ // branch, so the API sends `CONTINUE` — the NEW guard inside would
1475
+ // otherwise fire on the runner's own previous work.
1476
+ const prepared = await this.prepareWorkspace(descriptor);
1477
+ running.branch = prepared.branch;
1478
+ running.worktreePath = prepared.worktreePath;
1479
+ if (prepared.baseSha)
1480
+ running.baseSha = prepared.baseSha;
1481
+ if (prepared.baseBranch)
1482
+ running.baseBranch = prepared.baseBranch;
1176
1483
  }
1177
1484
  catch (error) {
1178
1485
  this.reportStatus(descriptor.id, 'FAILED', {
@@ -1181,9 +1488,43 @@ export class Supervisor {
1181
1488
  this.sessions.delete(descriptor.id);
1182
1489
  continue;
1183
1490
  }
1491
+ /**
1492
+ * Was a turn actually in flight when the process died?
1493
+ *
1494
+ * `RUNNING` and `WAITING_PERMISSION` are the mid-turn statuses; the
1495
+ * others mean the agent was already waiting for a human, and there is
1496
+ * nothing to continue. REVIEW is deliberately excluded — the work is
1497
+ * finished and waiting to be looked at.
1498
+ */
1499
+ const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
1500
+ const resumeId = descriptor.providerSessionId;
1501
+ const willContinue = wasMidTurn && claimAutoResume(descriptor.id);
1502
+ // The note stays either way (owner's call): an interruption is a fact
1503
+ // about the session and must not disappear just because we recovered
1504
+ // from it. Only the instruction at the end changes — telling someone to
1505
+ // send a message while the agent is already working again would be a lie.
1184
1506
  this.sendEvent(running, 'system_note', {
1185
- text: 'Runner reconnected. The session was resumed — send a message to continue.',
1507
+ text: willContinue
1508
+ ? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
1509
+ : 'Runner reconnected. The session was resumed — send a message to continue.',
1186
1510
  });
1511
+ if (willContinue) {
1512
+ // Resumed through the PROVIDER session, so the agent keeps its whole
1513
+ // conversation; the prompt is only the nudge a human would otherwise
1514
+ // have to type. Exactly what «продолжай» did by hand — no new class of
1515
+ // risk, and the same ceiling protects against a crash loop doing it
1516
+ // forever (see `auto-resume.ts`).
1517
+ if (this.launchAgent(running, AUTO_RESUME_PROMPT, resumeId)) {
1518
+ this.reportStatus(descriptor.id, 'RUNNING', {});
1519
+ this.flushPendingMessages(running);
1520
+ continue;
1521
+ }
1522
+ // Could not start (an exhausted budget is the only way here). Fall
1523
+ // through to the old behaviour and say so honestly.
1524
+ this.sendEvent(running, 'system_note', {
1525
+ text: 'Could not continue automatically — send a message to pick the work back up.',
1526
+ });
1527
+ }
1187
1528
  // REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
1188
1529
  // mid-turn statuses are downgraded to "waiting for the user".
1189
1530
  if (descriptor.status !== 'REVIEW') {
@@ -1214,6 +1555,7 @@ export class Supervisor {
1214
1555
  */
1215
1556
  pruneJournals() {
1216
1557
  try {
1558
+ pruneAutoResume(new Set(this.sessions.keys()));
1217
1559
  const removed = this.journals.prune({
1218
1560
  maxAgeMs: Supervisor.JOURNAL_TTL_MS,
1219
1561
  hardMaxAgeMs: Supervisor.JOURNAL_HARD_TTL_MS,
@@ -1300,7 +1642,17 @@ export class Supervisor {
1300
1642
  });
1301
1643
  return void reply({
1302
1644
  ok: true,
1303
- result: await gitStatus(paths.worktreePath, paths.workspacePath, paths.branch),
1645
+ result: await gitStatus({
1646
+ worktreePath: paths.worktreePath,
1647
+ workspacePath: paths.workspacePath,
1648
+ sessionBranch: paths.branch,
1649
+ ...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
1650
+ ...optionalSha(frame.args?.['appliedBranchSha'], 'appliedBranchSha'),
1651
+ ...optionalSha(frame.args?.['pushedSha'], 'pushedSha'),
1652
+ // Session 16: a DIRECT session has no base to be measured
1653
+ // against — the folder's own branch is where the work is.
1654
+ ...(frame.args?.['direct'] === true ? { direct: true } : {}),
1655
+ }),
1304
1656
  });
1305
1657
  }
1306
1658
  case 'git_diff': {
@@ -1314,7 +1666,10 @@ export class Supervisor {
1314
1666
  }
1315
1667
  return void reply({
1316
1668
  ok: true,
1317
- result: await gitDiff(paths.worktreePath, paths.workspacePath, paths.branch, filePath),
1669
+ result: await gitDiff(paths.worktreePath, paths.workspacePath, paths.branch, filePath, str(frame.args?.['baseBranch']) ?? undefined,
1670
+ // Session 16: the Source Control panel asks for one side of the
1671
+ // index at a time. Anything else keeps the historical meaning.
1672
+ diffMode(frame.args?.['mode'])),
1318
1673
  });
1319
1674
  }
1320
1675
  // Session 11: history. Read-only, so no repo lock and no busy check —
@@ -1324,20 +1679,32 @@ export class Supervisor {
1324
1679
  case 'git_log': {
1325
1680
  const workspacePath = str(frame.args?.['workspacePath']);
1326
1681
  const branch = str(frame.args?.['branch']);
1327
- if (!workspacePath || !branch) {
1328
- return void reply({ ok: false, error: 'workspacePath and branch are required' });
1682
+ const selector = refSelector(frame.args);
1683
+ // Session 14: the repository graph is reached from the WORKSPACE, so
1684
+ // a branch is no longer required — but then a ref selector is, or the
1685
+ // call would have nothing to walk and git would quietly default to
1686
+ // HEAD.
1687
+ if (!workspacePath || (!branch && Object.keys(selector).length === 0)) {
1688
+ return void reply({
1689
+ ok: false,
1690
+ error: 'workspacePath and either branch or a ref selection are required',
1691
+ });
1329
1692
  }
1330
1693
  const scope = logScope(frame.args?.['scope']);
1331
1694
  const limit = num(frame.args?.['limit']);
1332
1695
  const skip = num(frame.args?.['skip']);
1696
+ const cursor = str(frame.args?.['cursor']);
1333
1697
  return void reply({
1334
1698
  ok: true,
1335
1699
  result: await gitLog({
1336
1700
  workspacePath,
1337
- branch,
1701
+ ...(branch ? { branch } : {}),
1338
1702
  ...(scope ? { scope } : {}),
1339
1703
  ...(limit !== null ? { limit } : {}),
1340
1704
  ...(skip !== null ? { skip } : {}),
1705
+ ...(cursor ? { cursor } : {}),
1706
+ ...selector,
1707
+ ...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
1341
1708
  }),
1342
1709
  });
1343
1710
  }
@@ -1349,11 +1716,45 @@ export class Supervisor {
1349
1716
  }
1350
1717
  const filePath = str(frame.args?.['path']);
1351
1718
  const showBranch = str(frame.args?.['branch']);
1719
+ const selector = refSelector(frame.args);
1352
1720
  return void reply({
1353
1721
  ok: true,
1354
- result: await gitShow(workspacePath, sha, filePath ?? undefined, showBranch ?? undefined),
1722
+ result: await gitShow(workspacePath, sha, filePath ?? undefined, showBranch ?? undefined, str(frame.args?.['baseBranch']) ?? undefined,
1723
+ // The visibility set is widened by exactly the refs the LOG was
1724
+ // allowed to walk. Passing it only when the caller sent one keeps
1725
+ // every session read on its original two refs.
1726
+ Object.keys(selector).length > 0 ? selector : undefined),
1355
1727
  });
1356
1728
  }
1729
+ // Session 14: the ref list behind the graph's branch picker. Read-only,
1730
+ // same call as git_log — `for-each-ref` cannot move anything.
1731
+ case 'git_refs': {
1732
+ const workspacePath = str(frame.args?.['workspacePath']);
1733
+ if (!workspacePath) {
1734
+ return void reply({ ok: false, error: 'workspacePath is required' });
1735
+ }
1736
+ return void reply({ ok: true, result: await gitRefs(workspacePath) });
1737
+ }
1738
+ // Session 14: what the project folder is ACTUALLY on. The product used
1739
+ // to collect this during path validation and throw it away, which is
1740
+ // why it could never answer «is the running build yours».
1741
+ case 'workspace_state': {
1742
+ const workspacePath = str(frame.args?.['workspacePath']);
1743
+ if (!workspacePath) {
1744
+ return void reply({ ok: false, error: 'workspacePath is required' });
1745
+ }
1746
+ return void reply({ ok: true, result: await workspaceState(workspacePath) });
1747
+ }
1748
+ // Session 13: the branch list the «continue an existing branch» picker
1749
+ // reads. Read-only — `for-each-ref` and `worktree list` cannot move a
1750
+ // ref — so no lock and no busy check, same call as git_log above.
1751
+ case 'git_branches': {
1752
+ const workspacePath = str(frame.args?.['workspacePath']);
1753
+ if (!workspacePath) {
1754
+ return void reply({ ok: false, error: 'workspacePath is required' });
1755
+ }
1756
+ return void reply({ ok: true, result: await gitBranches(workspacePath) });
1757
+ }
1357
1758
  case 'git_commit': {
1358
1759
  const worktreePath = str(frame.args?.['worktreePath']);
1359
1760
  const message = str(frame.args?.['message']);
@@ -1366,9 +1767,14 @@ export class Supervisor {
1366
1767
  error: 'The agent is still working — wait for the turn to finish',
1367
1768
  });
1368
1769
  }
1770
+ // Session 16: `all: false` commits the INDEX — what the person
1771
+ // staged, and nothing else. Absent stays `git add -A` + commit, which
1772
+ // is what the agent's own auto-commit has always meant and what every
1773
+ // API older than this release will keep asking for.
1774
+ const all = frame.args?.['all'] !== false;
1369
1775
  return void reply({
1370
1776
  ok: true,
1371
- result: await this.withRepoLockFor(worktreePath, () => gitCommit(worktreePath, message)),
1777
+ result: await this.withRepoLockFor(worktreePath, () => gitCommit(worktreePath, message, { all })),
1372
1778
  });
1373
1779
  }
1374
1780
  case 'apply_session': {
@@ -1386,22 +1792,181 @@ export class Supervisor {
1386
1792
  error: 'The agent is still working — wait for the turn to finish',
1387
1793
  });
1388
1794
  }
1389
- const applied = await this.withRepoLockFor(paths.workspacePath, () => applySession(paths.workspacePath, paths.worktreePath, paths.branch, message));
1390
- return void reply(applied.applied || applied.conflict
1795
+ const applied = await this.withRepoLockFor(paths.workspacePath, () => applySession({
1796
+ workspacePath: paths.workspacePath,
1797
+ worktreePath: paths.worktreePath,
1798
+ sessionBranch: paths.branch,
1799
+ message,
1800
+ ...optionalBranch(frame.args?.['expectedBase'], 'expectedBase'),
1801
+ ...optionalSha(frame.args?.['sinceSha'], 'sinceSha'),
1802
+ }));
1803
+ // «Nothing new to apply», «the folder is on another branch» and «the
1804
+ // folder has uncommitted changes» are answers, not failures: the API
1805
+ // turns each into a sentence with a way forward, and an `ok: false`
1806
+ // here would surface as a raw error toast instead.
1807
+ //
1808
+ // `workspaceDirty` joining that list is safe for an API older than
1809
+ // session 15: it does not know the flag, so it falls through to the
1810
+ // same `AppError.conflict(result.error)` it raises today, with the
1811
+ // same wording.
1812
+ return void reply(applied.applied ||
1813
+ applied.conflict ||
1814
+ applied.noChanges ||
1815
+ applied.drifted ||
1816
+ applied.workspaceDirty
1391
1817
  ? { ok: true, result: applied }
1392
1818
  : { ok: false, error: applied.error ?? 'Apply failed', result: applied });
1393
1819
  }
1820
+ // Session 13: merge the base branch INTO the session branch. Merge and
1821
+ // never rebase — rebase rewrites commits the History tab already showed
1822
+ // a human. Takes the worktree busy check and the repo lock, because it
1823
+ // writes into the session's index just like a commit does.
1824
+ case 'update_from_base': {
1825
+ const paths = gitCommandPaths(frame.args);
1826
+ if (!paths) {
1827
+ return void reply({
1828
+ ok: false,
1829
+ error: 'worktreePath/workspacePath/branch are required',
1830
+ });
1831
+ }
1832
+ if (this.isWorktreeBusy(paths.worktreePath)) {
1833
+ return void reply({
1834
+ ok: false,
1835
+ error: 'The agent is still working — wait for the turn to finish',
1836
+ });
1837
+ }
1838
+ const updated = await this.withRepoLockFor(paths.worktreePath, () => updateFromBase({
1839
+ workspacePath: paths.workspacePath,
1840
+ worktreePath: paths.worktreePath,
1841
+ sessionBranch: paths.branch,
1842
+ ...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
1843
+ }));
1844
+ return void reply(updated.updated || updated.conflict
1845
+ ? { ok: true, result: updated }
1846
+ : { ok: false, error: updated.error ?? 'Update failed', result: updated });
1847
+ }
1848
+ // Session 13: the one outgoing write DevBridge performs. Never reached
1849
+ // by an agent — layer 1 denies `git push` outright — only by a human
1850
+ // pressing the button, and only for this session's own branch.
1851
+ case 'git_push': {
1852
+ const workspacePath = str(frame.args?.['workspacePath']);
1853
+ const branch = str(frame.args?.['branch']);
1854
+ const remote = str(frame.args?.['remote']);
1855
+ if (!workspacePath || !branch || !remote) {
1856
+ return void reply({
1857
+ ok: false,
1858
+ error: 'workspacePath/branch/remote are required',
1859
+ });
1860
+ }
1861
+ const pushed = await this.withRepoLockFor(workspacePath, () => gitPush({ workspacePath, branch, remote }));
1862
+ return void reply(pushed.pushed
1863
+ ? { ok: true, result: pushed }
1864
+ : { ok: false, error: pushed.error ?? 'Push failed', result: pushed });
1865
+ }
1394
1866
  case 'revert_apply': {
1395
1867
  const workspacePath = str(frame.args?.['workspacePath']);
1396
1868
  const commitSha = str(frame.args?.['commitSha']);
1397
1869
  if (!workspacePath || !commitSha) {
1398
1870
  return void reply({ ok: false, error: 'workspacePath and commitSha are required' });
1399
1871
  }
1400
- const reverted = await this.withRepoLockFor(workspacePath, () => revertApply(workspacePath, commitSha));
1401
- return void reply(reverted.reverted || reverted.conflict
1872
+ const expected = optionalBranch(frame.args?.['expectedBase'], 'expectedBase');
1873
+ const reverted = await this.withRepoLockFor(workspacePath, () => revertApply(workspacePath, commitSha, expected.expectedBase));
1874
+ // Same reasoning as `apply_session`: a dirty project folder is a
1875
+ // state the API can explain and offer a way out of, not an error.
1876
+ return void reply(reverted.reverted || reverted.conflict || reverted.workspaceDirty
1402
1877
  ? { ok: true, result: reverted }
1403
1878
  : { ok: false, error: reverted.error ?? 'Revert failed', result: reverted });
1404
1879
  }
1880
+ /**
1881
+ * Session 16 — the Source Control panel's four verbs.
1882
+ *
1883
+ * All of them WRITE into the index or the working tree of the session's
1884
+ * folder, so all of them take the repo lock and the worktree busy check:
1885
+ * staging a file underneath an agent that is mid-edit produces a commit
1886
+ * of half a thought, and «the agent is still working» is a sentence a
1887
+ * person can act on where a git error is not.
1888
+ *
1889
+ * They are deliberately four commands and not one `git` passthrough. A
1890
+ * passthrough would be a shell on somebody's server behind a web button;
1891
+ * these take a list of paths, and every one of those paths is checked
1892
+ * before it reaches an argv slot.
1893
+ */
1894
+ case 'git_stage':
1895
+ case 'git_unstage':
1896
+ case 'git_discard': {
1897
+ const worktreePath = str(frame.args?.['worktreePath']);
1898
+ if (!worktreePath) {
1899
+ return void reply({ ok: false, error: 'worktreePath is required' });
1900
+ }
1901
+ const all = frame.args?.['all'] === true;
1902
+ const paths = Array.isArray(frame.args?.['paths']) ? frame.args?.['paths'] : null;
1903
+ if (!all && !paths) {
1904
+ return void reply({ ok: false, error: 'paths or all is required' });
1905
+ }
1906
+ if (this.isWorktreeBusy(worktreePath)) {
1907
+ return void reply({
1908
+ ok: false,
1909
+ error: 'The agent is still working — wait for the turn to finish',
1910
+ });
1911
+ }
1912
+ const request = { worktreePath, ...(all ? { all } : { paths: paths }) };
1913
+ const run = frame.name === 'git_stage'
1914
+ ? () => gitStage(request)
1915
+ : frame.name === 'git_unstage'
1916
+ ? () => gitUnstage(request)
1917
+ : () => gitDiscard(request);
1918
+ try {
1919
+ const result = await this.withRepoLockFor(worktreePath, run);
1920
+ return void reply({ ok: true, result });
1921
+ }
1922
+ catch (error) {
1923
+ // A refused path is a sentence the panel shows verbatim («Path must
1924
+ // be relative to the repository: …»), not a stack trace.
1925
+ return void reply({ ok: false, error: maskSecretText(error) });
1926
+ }
1927
+ }
1928
+ /**
1929
+ * Session 16: `git pull`, and the way back out of one that conflicted.
1930
+ *
1931
+ * The only command in this group that reaches the network. It can leave
1932
+ * a conflicted tree behind on purpose — that is what git does, and the
1933
+ * panel draws it as «Merge Changes» rather than pretending the pull did
1934
+ * not happen.
1935
+ */
1936
+ case 'git_pull': {
1937
+ const worktreePath = str(frame.args?.['worktreePath']);
1938
+ if (!worktreePath) {
1939
+ return void reply({ ok: false, error: 'worktreePath is required' });
1940
+ }
1941
+ if (this.isWorktreeBusy(worktreePath)) {
1942
+ return void reply({
1943
+ ok: false,
1944
+ error: 'The agent is still working — wait for the turn to finish',
1945
+ });
1946
+ }
1947
+ const pulled = await this.withRepoLockFor(worktreePath, () => gitPull({ worktreePath }));
1948
+ // A conflict is an outcome with a state attached, not a failure: the
1949
+ // API turns it into a sentence and the panel into a file group.
1950
+ return void reply(pulled.pulled || pulled.conflict
1951
+ ? { ok: true, result: pulled }
1952
+ : { ok: false, error: pulled.error ?? 'Pull failed', result: pulled });
1953
+ }
1954
+ case 'git_merge_abort': {
1955
+ const worktreePath = str(frame.args?.['worktreePath']);
1956
+ if (!worktreePath) {
1957
+ return void reply({ ok: false, error: 'worktreePath is required' });
1958
+ }
1959
+ if (this.isWorktreeBusy(worktreePath)) {
1960
+ return void reply({
1961
+ ok: false,
1962
+ error: 'The agent is still working — wait for the turn to finish',
1963
+ });
1964
+ }
1965
+ const aborted = await this.withRepoLockFor(worktreePath, () => gitMergeAbort(worktreePath));
1966
+ return void reply(aborted.aborted
1967
+ ? { ok: true, result: aborted }
1968
+ : { ok: false, error: aborted.reason ?? 'Nothing to abort', result: aborted });
1969
+ }
1405
1970
  case 'fs_view': {
1406
1971
  const root = str(frame.args?.['root']);
1407
1972
  if (!root)
@@ -1451,6 +2016,185 @@ export class Supervisor {
1451
2016
  this.opts.onRestartRequested?.(outcome);
1452
2017
  return;
1453
2018
  }
2019
+ // ─── Session 14: the project recipe ──────────────────────────
2020
+ //
2021
+ // Read-only, always available even when verification is switched off:
2022
+ // «this machine will not run it» is a different answer from «this
2023
+ // project has no recipe», and the card has to be able to say both.
2024
+ case 'recipe_state': {
2025
+ const root = str(frame.args?.['root']) ?? str(frame.args?.['workspacePath']);
2026
+ if (!root)
2027
+ return void reply({ ok: false, error: 'workspacePath is required' });
2028
+ return void reply({
2029
+ ok: true,
2030
+ result: { ...readRecipeProposal(root), verifyEnabled: this.verify.enabled },
2031
+ });
2032
+ }
2033
+ case 'verify_start': {
2034
+ const parsed = parseVerifyStart(frame.args);
2035
+ if ('error' in parsed)
2036
+ return void reply({ ok: false, error: parsed.error });
2037
+ const started = this.verify.start(parsed.input);
2038
+ return void reply(started.started
2039
+ ? { ok: true, result: { started: true, runId: parsed.input.runId } }
2040
+ : { ok: false, error: started.error ?? 'Could not start the verification' });
2041
+ }
2042
+ case 'verify_status': {
2043
+ const runId = str(frame.args?.['runId']);
2044
+ if (!runId)
2045
+ return void reply({ ok: false, error: 'runId is required' });
2046
+ const status = this.verify.status(runId, num(frame.args?.['offset']) ?? 0);
2047
+ return void reply(status
2048
+ ? { ok: true, result: status }
2049
+ : { ok: false, error: 'This runner does not know that verification run' });
2050
+ }
2051
+ case 'verify_cancel': {
2052
+ const runId = str(frame.args?.['runId']);
2053
+ if (!runId)
2054
+ return void reply({ ok: false, error: 'runId is required' });
2055
+ const cancelled = this.verify.cancel(runId);
2056
+ return void reply(cancelled.cancelled
2057
+ ? { ok: true, result: cancelled }
2058
+ : { ok: false, error: cancelled.error ?? 'Could not cancel' });
2059
+ }
2060
+ /**
2061
+ * Session 14: show a branch WITHOUT touching the project folder.
2062
+ *
2063
+ * A second, detached worktree plus the recipe's own `preview` command —
2064
+ * which has its own docker project and its own ports. Without that
2065
+ * command there is nothing honest to do: rebuilding in the project
2066
+ * folder would replace the one copy the machine is running.
2067
+ */
2068
+ case 'preview_checkout': {
2069
+ const workspacePath = str(frame.args?.['workspacePath']);
2070
+ const branch = str(frame.args?.['branch']);
2071
+ const parsed = parseVerifyStart(frame.args);
2072
+ if (!workspacePath || !branch) {
2073
+ return void reply({ ok: false, error: 'workspacePath and branch are required' });
2074
+ }
2075
+ if ('error' in parsed)
2076
+ return void reply({ ok: false, error: parsed.error });
2077
+ if (!parsed.input.recipe.preview?.run) {
2078
+ return void reply({
2079
+ ok: false,
2080
+ 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.',
2081
+ });
2082
+ }
2083
+ const checkout = await this.withRepoLockFor(workspacePath, () => ensurePreviewWorktree({
2084
+ workspacePath,
2085
+ workspaceKey: frame.workspaceId ?? workspacePath,
2086
+ branch,
2087
+ }));
2088
+ const started = this.verify.start({
2089
+ ...parsed.input,
2090
+ target: 'PREVIEW',
2091
+ preview: true,
2092
+ cwd: checkout.worktreePath,
2093
+ branch: checkout.branch,
2094
+ commitSha: checkout.sha,
2095
+ dirty: false,
2096
+ });
2097
+ return void reply(started.started
2098
+ ? {
2099
+ ok: true,
2100
+ result: {
2101
+ started: true,
2102
+ runId: parsed.input.runId,
2103
+ worktreePath: checkout.worktreePath,
2104
+ branch: checkout.branch,
2105
+ sha: checkout.sha,
2106
+ url: parsed.input.recipe.preview.url ?? null,
2107
+ },
2108
+ }
2109
+ : { ok: false, error: started.error ?? 'Could not start the preview' });
2110
+ }
2111
+ case 'preview_stop': {
2112
+ const workspacePath = str(frame.args?.['workspacePath']);
2113
+ if (!workspacePath)
2114
+ return void reply({ ok: false, error: 'workspacePath is required' });
2115
+ const key = frame.workspaceId ?? workspacePath;
2116
+ const stopCommand = str(frame.args?.['stop']);
2117
+ const dockerProject = str(frame.args?.['project']);
2118
+ // Cancel the preview's own run FIRST (session 15).
2119
+ //
2120
+ // «Stop preview» used to run the stop command and delete the
2121
+ // worktree while `preview.run` was still executing inside it — so a
2122
+ // half-built `docker compose up --build` lost its directory mid-flight
2123
+ // and went on holding the machine's single verification slot until it
2124
+ // timed out, with the dashboard already showing the preview as
2125
+ // stopped. Only a PREVIEW run is ours to cancel: a build somebody
2126
+ // started for a session is a different thing entirely.
2127
+ if (this.verify.activeTarget === 'PREVIEW') {
2128
+ const runId = this.verify.activeRunId;
2129
+ if (runId)
2130
+ this.verify.cancel(runId);
2131
+ }
2132
+ if (stopCommand) {
2133
+ // The one place layer 1 lifts `docker … down`, and only for the
2134
+ // compose project the recipe named as its own.
2135
+ const decision = evaluateRecipeCommand(stopCommand, {
2136
+ isPreviewStop: true,
2137
+ ...(dockerProject ? { dockerProject } : {}),
2138
+ });
2139
+ if (!decision.allowed) {
2140
+ return void reply({
2141
+ ok: false,
2142
+ error: `The stop command is refused: ${decision.reason}`,
2143
+ });
2144
+ }
2145
+ await runPreviewStop(stopCommand, previewWorktreePath(key));
2146
+ }
2147
+ const removed = await this.withRepoLockFor(workspacePath, () => removePreviewWorktree(key));
2148
+ return void reply({ ok: true, result: { stopped: true, removed } });
2149
+ }
2150
+ /**
2151
+ * Session 14: a commit message written by an agent, in a one-shot run.
2152
+ *
2153
+ * Never `send()` into the live session — that would fill the feed with a
2154
+ * request nobody made, spend the session's context on the whole diff and
2155
+ * fight the worktree lock the agent holds mid-turn.
2156
+ */
2157
+ case 'propose_commit_message': {
2158
+ const paths = gitCommandPaths(frame.args);
2159
+ if (!paths) {
2160
+ return void reply({
2161
+ ok: false,
2162
+ error: 'worktreePath/workspacePath/branch are required',
2163
+ });
2164
+ }
2165
+ const ticketsRaw = frame.args?.['tickets'];
2166
+ const subjectsRaw = frame.args?.['commitSubjects'];
2167
+ const propose = this.opts.proposeCommitMessage ?? proposeCommitMessage;
2168
+ const result = await propose({
2169
+ worktreePath: paths.worktreePath,
2170
+ workspacePath: paths.workspacePath,
2171
+ branch: paths.branch,
2172
+ ...optionalBranch(frame.args?.['baseBranch'], 'baseBranch'),
2173
+ ...(Array.isArray(ticketsRaw)
2174
+ ? {
2175
+ tickets: ticketsRaw
2176
+ .filter((n) => typeof n === 'number')
2177
+ .slice(0, 20),
2178
+ }
2179
+ : {}),
2180
+ ...(Array.isArray(subjectsRaw)
2181
+ ? {
2182
+ commitSubjects: subjectsRaw
2183
+ .filter((s) => typeof s === 'string')
2184
+ .slice(0, 20),
2185
+ }
2186
+ : {}),
2187
+ ...(str(frame.args?.['language'])
2188
+ ? { language: str(frame.args?.['language']) }
2189
+ : {}),
2190
+ ...(str(frame.args?.['convention'])
2191
+ ? { convention: str(frame.args?.['convention']) }
2192
+ : {}),
2193
+ });
2194
+ return void reply(result.ok
2195
+ ? { ok: true, result }
2196
+ : { ok: false, error: result.error ?? 'Could not write a commit message', result });
2197
+ }
1454
2198
  case 'auth_status':
1455
2199
  return void reply({ ok: true, result: await agentAuthStatuses() });
1456
2200
  case 'login_start': {
@@ -1552,7 +2296,19 @@ export class Supervisor {
1552
2296
  running.lastReported = status;
1553
2297
  this.syncBudgetClock(running);
1554
2298
  }
1555
- const compact = maskSecrets(Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== undefined)));
2299
+ // The fork point rides along on the first frame after the branch was
2300
+ // created — once, because it never changes and the API pins the first
2301
+ // answer it hears. Sending it on every frame would invite a later, wrong
2302
+ // value to overwrite the right one if the pinning rule were ever relaxed.
2303
+ const base = running && !running.baseReported && (running.baseBranch || running.baseSha)
2304
+ ? {
2305
+ ...(running.baseBranch ? { baseBranch: running.baseBranch } : {}),
2306
+ ...(running.baseSha ? { baseSha: running.baseSha } : {}),
2307
+ }
2308
+ : {};
2309
+ if (running && Object.keys(base).length > 0)
2310
+ running.baseReported = true;
2311
+ const compact = maskSecrets(Object.fromEntries(Object.entries({ ...extra, ...base }).filter(([, v]) => v !== undefined)));
1556
2312
  // Journal the latest status for replay after a reconnect (QA-96 F1) —
1557
2313
  // only for tracked sessions, so one-shot failure reports don't leave
1558
2314
  // orphan journal files behind. The epoch stamp keeps a previous life's
@@ -1575,9 +2331,31 @@ export class Supervisor {
1575
2331
  this.authRelay.cancel();
1576
2332
  for (const running of this.sessions.values()) {
1577
2333
  this.clearBudgetTimers(running);
1578
- running.session?.stop();
2334
+ // BEFORE stop(), and from here rather than from the adapter: the adapter
2335
+ // pushes its `question_resolved` into an async queue that `pumpEvents`
2336
+ // drains a microtask later, and every caller of shutdown() (SIGTERM,
2337
+ // «Update runner», `revoked`) calls `process.exit` in the same tick — so
2338
+ // that event would never be journaled and never reach the wire. This
2339
+ // path writes the journal synchronously, and an unacked event is
2340
+ // redelivered on the next connection (QA-106 M1).
2341
+ try {
2342
+ this.withdrawOpenQuestions(running, 'runner_restarted');
2343
+ }
2344
+ catch (error) {
2345
+ log.warn('supervisor: could not withdraw open questions on shutdown', {
2346
+ sessionId: running.descriptor.id,
2347
+ error: String(error),
2348
+ });
2349
+ }
2350
+ // The reason matters here: this path also runs for «Update runner», and a
2351
+ // card that vanishes during an update must say why (session 12).
2352
+ running.session?.stop('runner_restarted');
1579
2353
  }
1580
2354
  this.sessions.clear();
2355
+ // A build that was mid-flight is abandoned, not judged: its row stays
2356
+ // RUNNING until the API's sweep turns it into LOST. A verdict nobody
2357
+ // observed must never become PASSED.
2358
+ this.verify.shutdown();
1581
2359
  }
1582
2360
  }
1583
2361
  function str(value) {
@@ -1596,6 +2374,105 @@ function num(value) {
1596
2374
  function logScope(value) {
1597
2375
  return value === 'session' || value === 'branch' || value === 'all' ? value : null;
1598
2376
  }
2377
+ /** Which side of the index a diff request means (session 16). */
2378
+ function diffMode(value) {
2379
+ return value === 'staged' || value === 'worktree' ? value : 'base';
2380
+ }
2381
+ /**
2382
+ * Optional command arguments that become git refs.
2383
+ *
2384
+ * They arrive from the API as `unknown` and end up in an argv slot, so they are
2385
+ * shaped here rather than passed through: a value that is not a ref name (or
2386
+ * not a hash) is dropped, and the callee falls back to what it did before the
2387
+ * argument existed. `exactOptionalPropertyTypes` is why these return a spread
2388
+ * object instead of `T | undefined`.
2389
+ */
2390
+ function optionalBranch(value, key) {
2391
+ const raw = str(value);
2392
+ if (!raw || raw.length > 200 || !/^[A-Za-z0-9][A-Za-z0-9._\-/]*$/.test(raw))
2393
+ return {};
2394
+ return { [key]: raw };
2395
+ }
2396
+ function optionalSha(value, key) {
2397
+ const raw = str(value);
2398
+ if (!raw || !/^[0-9a-f]{7,64}$/i.test(raw))
2399
+ return {};
2400
+ return { [key]: raw };
2401
+ }
2402
+ /**
2403
+ * Which refs a history request may walk (session 14).
2404
+ *
2405
+ * Present only when the caller actually asked for something: an empty object
2406
+ * means «no selector», which keeps every session-level call on exactly the
2407
+ * two-ref behaviour it had before this release. Names are re-validated inside
2408
+ * `gitLog` too — this is the cheap first gate, not the only one.
2409
+ */
2410
+ function refSelector(args) {
2411
+ const raw = args?.['refs'];
2412
+ const refs = Array.isArray(raw)
2413
+ ? raw.filter((value) => typeof value === 'string').slice(0, 64)
2414
+ : null;
2415
+ return {
2416
+ ...(refs && refs.length > 0 ? { refs } : {}),
2417
+ ...(args?.['includeRemotes'] === true ? { includeRemotes: true } : {}),
2418
+ ...(args?.['includeTags'] === true ? { includeTags: true } : {}),
2419
+ ...(args?.['all'] === true ? { all: true } : {}),
2420
+ };
2421
+ }
2422
+ /**
2423
+ * Everything `verify_start` / `preview_checkout` need, validated as one unit.
2424
+ *
2425
+ * The recipe travels on the frame rather than being read off disk here, and
2426
+ * that is the whole point of the design: the executable copy is the one a human
2427
+ * approved on the DevBridge side, not whatever the working tree says today. The
2428
+ * fingerprint is re-checked inside `VerifyRunner.start`, so a frame that lies
2429
+ * about which recipe it carries gets nowhere.
2430
+ */
2431
+ function parseVerifyStart(args) {
2432
+ const runId = str(args?.['runId']);
2433
+ if (!runId || !/^[A-Za-z0-9_-]{8,64}$/.test(runId))
2434
+ return { error: 'A valid runId is required' };
2435
+ const recipeSha = str(args?.['recipeSha']);
2436
+ if (!recipeSha)
2437
+ return { error: 'recipeSha is required' };
2438
+ const cwd = str(args?.['cwd']);
2439
+ if (!cwd || !path.isAbsolute(cwd) || cwd.split('/').includes('..')) {
2440
+ return { error: 'cwd must be an absolute path without ".." segments' };
2441
+ }
2442
+ const parsed = parseProjectRecipe(args?.['recipe']);
2443
+ if (!parsed.ok)
2444
+ return { error: `The approved recipe is not valid: ${parsed.error}` };
2445
+ const targetRaw = args?.['target'];
2446
+ const target = targetRaw === 'BASE' || targetRaw === 'SESSION' || targetRaw === 'PREVIEW'
2447
+ ? targetRaw
2448
+ : 'SESSION';
2449
+ const stepsRaw = args?.['steps'];
2450
+ const steps = Array.isArray(stepsRaw)
2451
+ ? RECIPE_STEP_NAMES.filter((name) => stepsRaw.includes(name))
2452
+ : RECIPE_STEP_NAMES.filter((name) => name !== 'deploy');
2453
+ return {
2454
+ input: {
2455
+ runId,
2456
+ target,
2457
+ recipe: parsed.recipe,
2458
+ recipeSha,
2459
+ cwd,
2460
+ steps: [...steps],
2461
+ branch: str(args?.['branch']),
2462
+ commitSha: str(args?.['commitSha']),
2463
+ dirty: args?.['dirty'] === true,
2464
+ },
2465
+ };
2466
+ }
2467
+ /** Stop a preview's own stack, inside its own worktree. */
2468
+ async function runPreviewStop(command, cwd) {
2469
+ if (!fs.existsSync(cwd))
2470
+ return;
2471
+ const result = await runOneOffCommand(command, cwd);
2472
+ if (!result.ok) {
2473
+ log.warn('preview: the stop command failed', { exitCode: result.exitCode });
2474
+ }
2475
+ }
1599
2476
  function gitCommandPaths(args) {
1600
2477
  const worktreePath = str(args?.['worktreePath']);
1601
2478
  const workspacePath = str(args?.['workspacePath']);
@@ -1607,6 +2484,15 @@ function statusForReport(running) {
1607
2484
  ? 'RUNNING'
1608
2485
  : running.lastReported;
1609
2486
  }
2487
+ /**
2488
+ * What we say to an agent whose turn a runner restart cut off (session 18).
2489
+ *
2490
+ * Deliberately the same thing a human would type — the provider session is
2491
+ * resumed, so the agent still has the whole conversation and only needs to be
2492
+ * told to carry on. Spelling out WHY matters: without it the agent tends to
2493
+ * summarise what it had done instead of finishing it.
2494
+ */
2495
+ 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
2496
  const AGENT_LABELS = { CLAUDE: 'Claude Code', CODEX: 'Codex' };
1611
2497
  /** Only Claude reports USD — Codex sessions are bounded by time instead. */
1612
2498
  function reportsCost(agent) {
@@ -1631,18 +2517,86 @@ function isTerminal(status) {
1631
2517
  function isSettled(status) {
1632
2518
  return isTerminal(status) || status === 'REVIEW';
1633
2519
  }
2520
+ /**
2521
+ * The first message the agent gets.
2522
+ *
2523
+ * Two shapes, and the difference is whether tickets were handed over
2524
+ * (session 16, owner's decision):
2525
+ *
2526
+ * - **No tickets** — exactly what the human typed, and an empty prompt stays
2527
+ * empty. That is how you get a session that boots and waits for you to talk
2528
+ * first; it is a feature, not an oversight.
2529
+ * - **Tickets handed to a TICKET session** — the assignment is always sent and
2530
+ * cannot be removed: «Implement ticket #28.» Handing a ticket over IS the
2531
+ * instruction, so making the human retype it was ceremony. Anything they
2532
+ * typed follows underneath.
2533
+ *
2534
+ * What is NOT here any more: eight lines about which MCP tools to call and
2535
+ * which statuses to move through. That is a standing convention of the project,
2536
+ * true on the twentieth turn as much as the first, so it moved to the system
2537
+ * prompt beside `CLAUDE.md` — which is read last and therefore overrides ours.
2538
+ */
1634
2539
  export function composeInitialPrompt(descriptor) {
1635
- if (descriptor.tickets.length === 0)
2540
+ // A free chat may carry tickets as CONTEXT. Assigning them would put an agent
2541
+ // nobody asked onto somebody else's ticket (session 13).
2542
+ if (descriptor.kind === 'CHAT' || descriptor.tickets.length === 0)
1636
2543
  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');
2544
+ const numbers = descriptor.tickets.map((t) => `#${t.number}`).join(', ');
2545
+ const assignment = descriptor.tickets.length === 1
2546
+ ? `Implement ticket ${numbers}.`
2547
+ : `Implement tickets ${numbers}.`;
2548
+ return descriptor.prompt ? `${assignment}\n\n${descriptor.prompt}` : assignment;
2549
+ }
2550
+ /**
2551
+ * Extra system-prompt material: where this session is in git, and how tickets
2552
+ * are meant to move (session 13).
2553
+ *
2554
+ * Facts about THIS SESSION, and nothing else. They exist in no file on disk —
2555
+ * which branch the agent is on, that it must not push, where a plan belongs,
2556
+ * what to do with the tickets it was given — so somebody has to say them, and
2557
+ * that somebody is us.
2558
+ *
2559
+ * The project's own documentation is deliberately NOT here. Both agents read
2560
+ * their own file natively, verified live: Claude picks up `CLAUDE.md` through
2561
+ * its memory mechanism (since `settingSources` includes `'project'`), and Codex
2562
+ * picks up `AGENTS.md` even under the runner's isolated `CODEX_HOME`. Pasting a
2563
+ * copy on top of that was work we were doing for no one — and when it was
2564
+ * switched off for Claude alone it briefly left repositories that carry only
2565
+ * `AGENTS.md` with nothing at all, which is precisely the kind of hole a
2566
+ * half-measure digs.
2567
+ *
2568
+ * A repository that wants both agents equipped ships both files, or symlinks
2569
+ * one to the other. That is a repository convention and not something a runner
2570
+ * should paper over.
2571
+ */
2572
+ export function composeWorkspaceContext(descriptor) {
2573
+ const sections = [];
2574
+ const plan = descriptor.branchPlan;
2575
+ if (plan) {
2576
+ const forked = plan.baseBranch ? ` It was branched from \`${plan.baseBranch}\`.` : '';
2577
+ sections.push([
2578
+ 'Git in this session:',
2579
+ `- You are in a dedicated worktree on branch \`${plan.branch}\`.${forked}`,
2580
+ '- 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.',
2581
+ '- You cannot push: `git push` is refused. A human presses «Push» when they want the branch on the remote.',
2582
+ `- 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.`,
2583
+ ].join('\n'));
2584
+ }
2585
+ // The ticket convention lives HERE, not in the first chat message (session
2586
+ // 16). It is a standing rule of the project — true on the twentieth turn as
2587
+ // much as the first — and putting it in the visible prompt only trained the
2588
+ // reader to skip past it. `CLAUDE.md` is appended after this section, so a
2589
+ // repository that states its own rule overrides ours by being read last.
2590
+ if (descriptor.kind !== 'CHAT' && descriptor.tickets.length > 0) {
2591
+ sections.push([
2592
+ 'DevBridge tickets in this session:',
2593
+ '- Read the full ticket with the DevBridge MCP tools (`mcp__devbridge__*`) before starting — the title alone is never the whole task.',
2594
+ '- Move it to IN_PROGRESS while you work and to READY_FOR_REVIEW with a short summary comment when you are done.',
2595
+ ].join('\n'));
2596
+ }
2597
+ else if (descriptor.tickets.length > 0) {
2598
+ 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.');
2599
+ }
2600
+ return sections.join('\n\n');
1647
2601
  }
1648
2602
  //# sourceMappingURL=supervisor.js.map