@bridge4dev/runner 0.33.0 → 0.35.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.
@@ -1800,11 +1800,39 @@ class ClaudeSession {
1800
1800
  break;
1801
1801
  }
1802
1802
  case 'assistant': {
1803
+ // Ticket #186. A subagent's own prose is NOT this session's answer.
1804
+ //
1805
+ // The SDK's own note — «by default, only tool_use/tool_result blocks
1806
+ // from subagents are emitted» — covers SYNCHRONOUS Task subagents. A
1807
+ // BACKGROUND agent's closing report is delivered regardless, as an
1808
+ // ordinary top-level assistant message that merely carries
1809
+ // `parent_tool_use_id` (probe against 0.3.226, 2026-08-08):
1810
+ //
1811
+ // {type:'assistant', parent_tool_use_id:'toolu_…',
1812
+ // subagent_type:'general-purpose', task_description:'…',
1813
+ // message:{content:[{type:'text', text:'…'}]}}
1814
+ //
1815
+ // Session 50eb77b9 put two of those — 46 655 and 16 368 characters of
1816
+ // internal survey — into the feed four seconds after an
1817
+ // AskUserQuestion card, while the main agent was blocked on the very
1818
+ // tool call that card belongs to and could not have written a word.
1819
+ //
1820
+ // Only text and thinking are dropped. `tool_use` stays, and so does
1821
+ // the paired `tool_result` in `case 'user'` below: the dashboard
1822
+ // resolves a result by toolUseId and falls back to the last unfinished
1823
+ // row, so filtering one arm without the other sticks a subagent's
1824
+ // result onto a stranger's row (that fallback has misfired before).
1825
+ //
1826
+ // The test is «is a string», not «is not null»: an emitter that
1827
+ // omits the field must read as the main loop. Treating a missing
1828
+ // field as a subagent would blank the transcript of every session on
1829
+ // that CLI, which is a far worse failure than one leaked report.
1830
+ const fromSubagent = typeof msg.parent_tool_use_id === 'string';
1803
1831
  for (const block of msg.message.content) {
1804
- if (block.type === 'text' && block.text.trim()) {
1832
+ if (block.type === 'text' && !fromSubagent && block.text.trim()) {
1805
1833
  this.emit({ type: 'message', role: 'assistant', text: truncate(block.text) });
1806
1834
  }
1807
- else if (block.type === 'thinking' && block.thinking.trim()) {
1835
+ else if (block.type === 'thinking' && !fromSubagent && block.thinking.trim()) {
1808
1836
  this.emit({ type: 'thinking', text: truncate(block.thinking, 8_000) });
1809
1837
  }
1810
1838
  else if (block.type === 'tool_use') {
@@ -483,21 +483,41 @@ class CodexSession {
483
483
  // settings/update in this protocol version).
484
484
  ...(this.effort ? { effort: this.effort } : {}),
485
485
  };
486
- // Collaboration mode is turn-scoped but sticky, and its `settings` REPLACE
487
- // the thread's developer instructions — so only send it on a real change.
486
+ // Collaboration mode is turn-scoped but sticky, so it is only sent on a real
487
+ // change. What it must NOT carry is our own instructions (ticket #179).
488
488
  //
489
- // Session 13: they are sent for plan mode too. Passing `null` there did not
490
- // mean «keep what the thread had», it meant «no developer instructions at
491
- // all» — so the one mode a user picks when they want the agent to think
492
- // before acting was the one mode where DevBridge's rules, the branch it is
493
- // on and the project's own CLAUDE.md never reached it.
489
+ // The line that used to sit here said `settings` REPLACE the thread's
490
+ // developer instructions, and that passing `null` meant «no developer
491
+ // instructions at all». Both halves are false, and the first one cost the
492
+ // product a duplicated system prompt in every Codex session. Measured on the
493
+ // wire — a fake Responses API behind `codex app-server`, reading the developer
494
+ // message verbatim — on codex-cli 0.135.0 AND 0.147.0, which behave alike:
495
+ //
496
+ // thread developerInstructions + collaborationMode carrying the same text
497
+ // → the text appears TWICE in one developer message: once at the top (on
498
+ // 0.135.0, right after `<permissions instructions>`) and again inside
499
+ // `<collaboration_mode>…</collaboration_mode>`. That is exactly what the
500
+ // ticket's screenshots show, and with a real project prompt it is ~27 KB
501
+ // sent twice on every single request.
502
+ // thread developerInstructions + `developer_instructions: null`
503
+ // → the text appears ONCE, and `<collaboration_mode>` carries Codex's own
504
+ // preset for the mode («# Plan Mode (Conversational)…»).
505
+ //
506
+ // So `null` is not a loss, it is the fix — and it repairs a second, quieter
507
+ // defect at the same time: our text was OVERWRITING Codex's built-in plan
508
+ // instructions, i.e. the one mode a user picks to make the agent think before
509
+ // acting was the one mode that never received the instructions for thinking
510
+ // before acting. The thread-level channel (`threadParams`) stays the single
511
+ // source of DevBridge's rules, exactly like `systemPrompt.append` on Claude.
494
512
  if (this.lastCollabMode !== wantCollab) {
495
513
  params['collaborationMode'] = {
496
514
  mode: wantCollab,
497
515
  settings: {
498
516
  model: this.model ?? this.threadModel ?? 'gpt-5.5',
499
517
  reasoning_effort: null,
500
- developer_instructions: composeSystemAppend(this.spec),
518
+ // «Use the built-in instructions for the selected mode» — the app-server
519
+ // schema's own words. Ours already arrived with the thread.
520
+ developer_instructions: null,
501
521
  },
502
522
  };
503
523
  this.lastCollabMode = wantCollab;
package/dist/index.js CHANGED
@@ -35,6 +35,18 @@ const LAST_EXIT = takeLastExit();
35
35
  function print(line) {
36
36
  process.stdout.write(line + '\n');
37
37
  }
38
+ /**
39
+ * Дата для оператора, который читает вывод у себя в консоли.
40
+ *
41
+ * Раньше здесь стояло `iso.slice(0, 10)` — это UTC-день, а не день того, кто
42
+ * смотрит: к востоку от Гринвича поздним вечером он показывает вчерашнее число.
43
+ * Часы машины оператора — единственный разумный ответ: у раннера нет ни
44
+ * человека с настройкой, ни организации.
45
+ */
46
+ function formatLocalDay(iso) {
47
+ const date = new Date(iso);
48
+ return Number.isNaN(date.getTime()) ? iso.slice(0, 10) : date.toLocaleDateString();
49
+ }
38
50
  function fail(message) {
39
51
  process.stderr.write(`error: ${message}\n`);
40
52
  process.exit(1);
@@ -908,7 +920,7 @@ async function agentChecks() {
908
920
  ok: signedIn,
909
921
  name: `${agent} login`,
910
922
  detail: (info.detail ?? info.status) +
911
- (info.expiresAt ? ` · until ${info.expiresAt.slice(0, 10)}` : ''),
923
+ (info.expiresAt ? ` · until ${formatLocalDay(info.expiresAt)}` : ''),
912
924
  ...(signedIn
913
925
  ? {}
914
926
  : {
@@ -136,6 +136,20 @@ export declare class Supervisor {
136
136
  * refuse writes to it: in `workMode: DIRECT` the project folder is the
137
137
  * agent's own working directory, so without that rule a session could rewrite
138
138
  * the prompt it will itself be started with next time (QA-130 MAJOR-3).
139
+ *
140
+ * @param resuming This launch continues an existing agent conversation. It
141
+ * changes only the WORDS, never the behaviour — and the words matter,
142
+ * because a person who has just watched their session crash reads a second
143
+ * «Project prompt loaded» as «my context was thrown away» (ticket #177).
144
+ * Measured, so the line can say it plainly: a system prompt lives in the
145
+ * agent PROCESS, not in the saved conversation. Resuming Claude Code
146
+ * without re-supplying `--append-system-prompt` restores every message and
147
+ * ZERO bytes of the appended prompt; re-supplying it restores the same
148
+ * messages and exactly ONE copy of the prompt, never two. Codex is the same
149
+ * fact by a different route — it rebuilds the developer message from the
150
+ * current parameters on every request. So re-reading the file here is not
151
+ * waste, and skipping it would silently strip the project's rules (and
152
+ * DevBridge's own) from every session that ever resumed.
139
153
  */
140
154
  private resolveAgentPrompt;
141
155
  /** Warn the user when this share of the budget is gone. */
@@ -246,6 +260,30 @@ export declare class Supervisor {
246
260
  * rewound (ticket #126).
247
261
  */
248
262
  private park;
263
+ /**
264
+ * The agent is producing output while this runner still says a human is
265
+ * expected — put `lastReported` back to RUNNING (ticket #185).
266
+ *
267
+ * This is not cosmetics on top of the API's own repair. While `lastReported`
268
+ * lies, two things break here and only here:
269
+ *
270
+ * - `isParkable()` reads WAITING_INPUT with no open question as «idle», so
271
+ * `ensureCapacity` may KILL a live agent process to hand its slot to
272
+ * another session;
273
+ * - `isBillable()` counts only RUNNING/STARTING, so `agentActiveMs` stops
274
+ * growing and the time budget never fires for work that is really happening.
275
+ *
276
+ * It happens for real: a background subagent's report wakes a new turn inside
277
+ * the agent process (SDK 0.3.226 emits a second `system:init` and a `result`
278
+ * carrying `origin: {kind:'task-notification'}`), and nothing here reports a
279
+ * turn the runner did not start. Compaction does the same, and so does a
280
+ * question this runner withdraws by itself.
281
+ *
282
+ * An open question is the one thing that must survive: there, WAITING_INPUT
283
+ * means a tool call is parked on a person, and the agent narrating around its
284
+ * own question must not take the card off the screen.
285
+ */
286
+ private noteAgentIsWorking;
249
287
  private forwardEvent;
250
288
  /**
251
289
  * The dashboard's answer to a parked question (session 12).
@@ -22,6 +22,12 @@ import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
22
22
  /** Refusals shared by every checkpoint command (ticket #126). */
23
23
  const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
24
24
  const AGENT_BUSY = 'The agent is still working — stop the turn first';
25
+ /**
26
+ * Adapter events that exist only because the agent is producing output right
27
+ * now (ticket #185). `message` is deliberately absent — a user echo carries the
28
+ * same type and proves nothing about the agent — and is handled at the call site.
29
+ */
30
+ const AGENT_OUTPUT_EVENTS = new Set(['tool', 'thinking']);
25
31
  /**
26
32
  * The project's git policy, lifted out of a session descriptor (session 18).
27
33
  *
@@ -352,8 +358,19 @@ export class Supervisor {
352
358
  if (descriptor.epoch > 0) {
353
359
  // The API owns the resume transition; the feed marker has to come from
354
360
  // here because the runner is the only writer of the event seq.
361
+ //
362
+ // Ticket #177: which of the two sentences is true depends on whether
363
+ // there is a conversation to go back to. `providerSessionId` is the
364
+ // agent's own name for it, and it is what the next launch hands to
365
+ // `--resume` / `thread/resume`. Without it the next process starts the
366
+ // conversation over — which is a real loss, and promising «continue
367
+ // where the agent left off» there is the one thing the feed must not do.
368
+ // It happens for real: a process that dies before it reports its session
369
+ // id (the SIGABRT this ticket came from) leaves the row with none.
355
370
  this.sendEvent(running, 'system_note', {
356
- text: 'Session resumed — send a message to continue where the agent left off.',
371
+ text: descriptor.providerSessionId
372
+ ? 'Session resumed — the agent still has this conversation. Send a message to continue where it left off.'
373
+ : 'Session resumed, but the agent never got as far as naming its conversation, so it starts this one over. Your files, your branch and everything above are untouched.',
357
374
  });
358
375
  }
359
376
  running.lastReported = descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT';
@@ -438,7 +455,14 @@ export class Supervisor {
438
455
  // Facts about this session only, plus the one file the project named. The
439
456
  // rest of the project's documentation is read by each agent itself — see
440
457
  // `composeWorkspaceContext`.
441
- const agentPrompt = this.resolveAgentPrompt(running);
458
+ // Does this launch CONTINUE the agent's conversation or begin a new one?
459
+ // Read before `rewindAnchor` is consumed below, and without consuming it —
460
+ // the prompt notice says different things about the two cases (#177).
461
+ // A rewind resumes a conversation too, but a CUT one — so it gets neither
462
+ // «loaded» nor «unchanged».
463
+ const rewinding = Boolean(running.rewindAnchor?.agentSession);
464
+ const resuming = rewinding || Boolean(resumeId);
465
+ const agentPrompt = this.resolveAgentPrompt(running, resuming && !rewinding);
442
466
  const workspaceContext = composeWorkspaceContext(descriptor, agentPrompt?.text);
443
467
  const rewind = running.rewindAnchor;
444
468
  delete running.rewindAnchor;
@@ -509,8 +533,22 @@ export class Supervisor {
509
533
  * refuse writes to it: in `workMode: DIRECT` the project folder is the
510
534
  * agent's own working directory, so without that rule a session could rewrite
511
535
  * the prompt it will itself be started with next time (QA-130 MAJOR-3).
536
+ *
537
+ * @param resuming This launch continues an existing agent conversation. It
538
+ * changes only the WORDS, never the behaviour — and the words matter,
539
+ * because a person who has just watched their session crash reads a second
540
+ * «Project prompt loaded» as «my context was thrown away» (ticket #177).
541
+ * Measured, so the line can say it plainly: a system prompt lives in the
542
+ * agent PROCESS, not in the saved conversation. Resuming Claude Code
543
+ * without re-supplying `--append-system-prompt` restores every message and
544
+ * ZERO bytes of the appended prompt; re-supplying it restores the same
545
+ * messages and exactly ONE copy of the prompt, never two. Codex is the same
546
+ * fact by a different route — it rebuilds the developer message from the
547
+ * current parameters on every request. So re-reading the file here is not
548
+ * waste, and skipping it would silently strip the project's rules (and
549
+ * DevBridge's own) from every session that ever resumed.
512
550
  */
513
- resolveAgentPrompt(running) {
551
+ resolveAgentPrompt(running, resuming = false) {
514
552
  const { descriptor } = running;
515
553
  const configured = descriptor.workspace.agentPromptPath?.trim();
516
554
  if (!configured)
@@ -551,7 +589,9 @@ export class Supervisor {
551
589
  // mode.
552
590
  this.sendEvent(running, 'notice', {
553
591
  level: 'info',
554
- text: `Project prompt loaded from ${quotePath(result.relPath)} — ${agentPromptSizeLabel(result.bytes)}, sha ${result.sha}.`,
592
+ text: resuming
593
+ ? `Project prompt re-applied from ${quotePath(result.relPath)} — ${agentPromptSizeLabel(result.bytes)}, sha ${result.sha}. A new agent process needs it again; the conversation is unchanged.`
594
+ : `Project prompt loaded from ${quotePath(result.relPath)} — ${agentPromptSizeLabel(result.bytes)}, sha ${result.sha}.`,
555
595
  });
556
596
  return { text: result.text, absPath: result.absPath };
557
597
  }
@@ -1046,8 +1086,47 @@ export class Supervisor {
1046
1086
  }
1047
1087
  running.session.stop('session_parked');
1048
1088
  }
1089
+ /**
1090
+ * The agent is producing output while this runner still says a human is
1091
+ * expected — put `lastReported` back to RUNNING (ticket #185).
1092
+ *
1093
+ * This is not cosmetics on top of the API's own repair. While `lastReported`
1094
+ * lies, two things break here and only here:
1095
+ *
1096
+ * - `isParkable()` reads WAITING_INPUT with no open question as «idle», so
1097
+ * `ensureCapacity` may KILL a live agent process to hand its slot to
1098
+ * another session;
1099
+ * - `isBillable()` counts only RUNNING/STARTING, so `agentActiveMs` stops
1100
+ * growing and the time budget never fires for work that is really happening.
1101
+ *
1102
+ * It happens for real: a background subagent's report wakes a new turn inside
1103
+ * the agent process (SDK 0.3.226 emits a second `system:init` and a `result`
1104
+ * carrying `origin: {kind:'task-notification'}`), and nothing here reports a
1105
+ * turn the runner did not start. Compaction does the same, and so does a
1106
+ * question this runner withdraws by itself.
1107
+ *
1108
+ * An open question is the one thing that must survive: there, WAITING_INPUT
1109
+ * means a tool call is parked on a person, and the agent narrating around its
1110
+ * own question must not take the card off the screen.
1111
+ */
1112
+ noteAgentIsWorking(running) {
1113
+ if (running.openQuestions.size > 0)
1114
+ return;
1115
+ if (running.stopRequested || running.parkRequested || running.budgetSpent)
1116
+ return;
1117
+ if (running.lastReported !== 'WAITING_INPUT' && running.lastReported !== 'REVIEW')
1118
+ return;
1119
+ running.lastReported = 'RUNNING';
1120
+ this.syncBudgetClock(running);
1121
+ this.reportStatus(running.descriptor.id, 'RUNNING', {});
1122
+ }
1049
1123
  forwardEvent(running, event) {
1050
1124
  const { descriptor } = running;
1125
+ // Anything below that is the agent talking means the agent is working. Read
1126
+ // before the switch so every such case gets it, including the ones added
1127
+ // after this line was written.
1128
+ if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type))
1129
+ this.noteAgentIsWorking(running);
1051
1130
  switch (event.type) {
1052
1131
  case 'provider_session': {
1053
1132
  running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
@@ -2050,7 +2129,13 @@ export class Supervisor {
2050
2129
  */
2051
2130
  const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
2052
2131
  const resumeId = descriptor.providerSessionId;
2053
- const willContinue = wasMidTurn && claimAutoResume(descriptor.id);
2132
+ // Ticket #177: `resumeId` is required, not merely nice to have. Without
2133
+ // it the relaunch starts a FRESH conversation, and `AUTO_RESUME_PROMPT`
2134
+ // — "continue from where you stopped, re-check what you were in the
2135
+ // middle of" — would be addressed to an agent that remembers none of
2136
+ // it. A process killed before it reported its session id (the SIGABRT
2137
+ // this ticket came from) leaves the row in exactly that state.
2138
+ const willContinue = wasMidTurn && Boolean(resumeId) && claimAutoResume(descriptor.id);
2054
2139
  // The note stays either way (owner's call): an interruption is a fact
2055
2140
  // about the session and must not disappear just because we recovered
2056
2141
  // from it. Only the instruction at the end changes — telling someone to
@@ -2058,7 +2143,9 @@ export class Supervisor {
2058
2143
  this.sendEvent(running, 'system_note', {
2059
2144
  text: willContinue
2060
2145
  ? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
2061
- : 'Runner reconnected. The session was resumed — send a message to continue.',
2146
+ : resumeId
2147
+ ? 'Runner reconnected. The session was resumed — send a message to continue.'
2148
+ : 'Runner reconnected, but the agent never got as far as naming its conversation, so it starts this one over. Your files, your branch and everything above are untouched.',
2062
2149
  });
2063
2150
  if (willContinue) {
2064
2151
  // Resumed through the PROVIDER session, so the agent keeps its whole
@@ -2431,6 +2518,21 @@ export class Supervisor {
2431
2518
  error: 'The agent could not compact right now — wait for the current turn to finish',
2432
2519
  });
2433
2520
  }
2521
+ // Ticket #185. Compaction is a whole agent turn — `/compact` goes in
2522
+ // as an ordinary user message — and until now nobody said so: the
2523
+ // session read «Ваш ход» for the entire squeeze, and the dashboard
2524
+ // papered over it with `compactingSince`, a variable that exists only
2525
+ // in the tab that pressed the button. A second tab, a teammate or a
2526
+ // reload saw a session waiting for a person who had nothing to do.
2527
+ //
2528
+ // It is also not only cosmetic here: a session reading WAITING_INPUT
2529
+ // with no open question is parkable, and parking it would kill the
2530
+ // compaction mid-flight.
2531
+ if (running.lastReported !== 'RUNNING') {
2532
+ running.lastReported = 'RUNNING';
2533
+ this.syncBudgetClock(running);
2534
+ this.reportStatus(running.descriptor.id, 'RUNNING', {});
2535
+ }
2434
2536
  return void reply({ ok: true, result: { started: true } });
2435
2537
  }
2436
2538
  case 'git_status': {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.33.0";
1
+ export declare const RUNNER_VERSION = "0.35.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.33.0';
2
+ export const RUNNER_VERSION = '0.35.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",
@@ -37,16 +37,16 @@
37
37
  "prepublishOnly": "tsc"
38
38
  },
39
39
  "dependencies": {
40
- "@anthropic-ai/claude-agent-sdk": "^0.3.218",
41
- "smol-toml": "^1.7.0",
42
- "ws": "^8.21.1",
43
- "zod": "^3.24.0"
40
+ "@anthropic-ai/claude-agent-sdk": "^0.3.226",
41
+ "smol-toml": "^1.7.1",
42
+ "ws": "^8.21.3",
43
+ "zod": "^3.25.76"
44
44
  },
45
45
  "devDependencies": {
46
- "@types/node": "^22.10.2",
46
+ "@types/node": "^22.20.1",
47
47
  "@types/ws": "^8.18.1",
48
- "tsx": "^4.19.2",
49
- "typescript": "~5.7.2",
50
- "vitest": "^2.1.9"
48
+ "tsx": "^4.23.11",
49
+ "typescript": "~5.7.3",
50
+ "vitest": "^3.2.7"
51
51
  }
52
52
  }