@bridge4dev/runner 0.59.1 → 0.61.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.
@@ -11,7 +11,7 @@ import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
11
11
  import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
12
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
13
13
  import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
14
- import { claudeExecutableOption, sessionClaudePath } from '../agent-binary.js';
14
+ import { assertClaudeInstalled, claudeExecutableOption, sessionClaudePath, } from '../agent-binary.js';
15
15
  /** Same 2KB the SDK keeps: enough for the CLI's last words, not a log sink. */
16
16
  const STDERR_TAIL_LIMIT = 2048;
17
17
  import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
@@ -340,10 +340,21 @@ class ClaudeSession {
340
340
  * has no such field. Without stashing it, the supervisor would be left with a
341
341
  * sentence — and a decision made from a sentence can be made by prose (#252).
342
342
  *
343
- * Reset per turn in `endTaskTurn`, which already owns the turn epoch.
343
+ * Reset when a turn BEGINS — `beginTurnFacts`, reached from `resumingTurn`
344
+ * for every turn this runner starts and from the boundary guard in the read
345
+ * loop for one the CLI started by itself (#406). The docblock used to say
346
+ * `endTaskTurn` owns the epoch; it never has, and reading it that way is what
347
+ * made «a code from this turn» and «a code from some turn» look identical.
344
348
  */
345
349
  turnFailureCode = null;
346
350
  turnFailureStatus = null;
351
+ /**
352
+ * A `result` has gone out and no new message has arrived since (#406).
353
+ *
354
+ * The marker the read loop uses to tell a turn the CLI opened by itself from
355
+ * a second `result` for the turn that just ended.
356
+ */
357
+ turnClosed = false;
347
358
  /**
348
359
  * Did this turn put ANYTHING on the wire — text, thinking, or a tool call?
349
360
  *
@@ -2169,6 +2180,65 @@ class ClaudeSession {
2169
2180
  conversationAnchor() {
2170
2181
  return this.lastMessageUuid;
2171
2182
  }
2183
+ /** A compaction the CLI has announced and not yet ended (#348). */
2184
+ compacting = false;
2185
+ /**
2186
+ * `system:status` — the CLI's own word on what it is doing (#348).
2187
+ *
2188
+ * Read the way the CLI's own remote client reads it (`noteInbound`):
2189
+ * «compacting» opens a compaction, `requesting` says nothing about one (the
2190
+ * summary itself is a model request, and this status arrives DURING a
2191
+ * compaction), and any other status closes it — with `compact_result` when
2192
+ * the CLI says how it went, and as «skipped» when it does not: a PreCompact
2193
+ * hook that said no, or a reactive compaction that found nothing to fold,
2194
+ * both end on a bare `status: null`. Left unclosed, that start line would
2195
+ * keep the dashboard saying «compacting» until the turn ended, minutes later.
2196
+ *
2197
+ * De-duplicated here as well as in the supervisor: the CLI re-says
2198
+ * «compacting» every thirty seconds while it waits on a precomputed summary.
2199
+ *
2200
+ * No `standalone` on this agent: `/compact` is a turn, and its `result`
2201
+ * settles the status the ordinary way.
2202
+ */
2203
+ noteStatus(msg) {
2204
+ const status = msg.status;
2205
+ if (status === 'requesting')
2206
+ return;
2207
+ if (status === 'compacting') {
2208
+ if (this.compacting)
2209
+ return;
2210
+ this.compacting = true;
2211
+ this.emit({ type: 'compaction', phase: 'started' });
2212
+ return;
2213
+ }
2214
+ // An end is only an end of something that began: without a start on
2215
+ // record there is nothing to close, and the second of two endings (a
2216
+ // `compact_result` after a `compact_boundary`) must not open a second
2217
+ // pair. A mode change also travels on this message — `{status: null,
2218
+ // permissionMode}` on every `setPermissionMode` in CLI 2.1.271 — and says
2219
+ // nothing about a compaction at all.
2220
+ if (!this.compacting)
2221
+ return;
2222
+ const compactResult = msg.compact_result;
2223
+ if (compactResult === undefined && msg.permissionMode !== undefined)
2224
+ return;
2225
+ this.compacting = false;
2226
+ if (compactResult === 'failed') {
2227
+ this.emit({
2228
+ type: 'compaction',
2229
+ phase: 'finished',
2230
+ ok: false,
2231
+ error: maskString(String(msg.compact_error ?? 'unknown')).slice(0, 300),
2232
+ });
2233
+ return;
2234
+ }
2235
+ this.emit({
2236
+ type: 'compaction',
2237
+ phase: 'finished',
2238
+ ok: true,
2239
+ ...(compactResult === 'success' ? {} : { skipped: true }),
2240
+ });
2241
+ }
2172
2242
  /**
2173
2243
  * `/compact` — the CLI's own command, delivered as ordinary user input.
2174
2244
  *
@@ -2226,6 +2296,25 @@ class ClaudeSession {
2226
2296
  async consume() {
2227
2297
  try {
2228
2298
  for await (const msg of this.q) {
2299
+ /**
2300
+ * A turn the CLI started by itself begins here (#406).
2301
+ *
2302
+ * `beginTurnFacts` has exactly one other caller, `resumingTurn`, and it
2303
+ * covers every turn THIS runner opens. The CLI opens turns too — a
2304
+ * background subagent finishing wakes one, and its `result` carries
2305
+ * `origin.kind: 'task-notification'` — and those used to inherit the
2306
+ * previous turn's `turnFailureCode`, `turnProduced` and
2307
+ * `turnIrreversible`, which is how a long-healed stream cut could still
2308
+ * be the reason attached to a turn an hour later.
2309
+ *
2310
+ * The guard is «a message that is not another `result`», not «the turn
2311
+ * ended»: one Stop can produce two results (see the stop-cycle tests),
2312
+ * and the second one has to see the same facts as the first.
2313
+ */
2314
+ if (this.turnClosed && msg.type !== 'result') {
2315
+ this.turnClosed = false;
2316
+ this.beginTurnFacts();
2317
+ }
2229
2318
  // Ticket #126: where we are in the transcript, remembered BEFORE the
2230
2319
  // message is interpreted.
2231
2320
  //
@@ -2266,19 +2355,15 @@ class ClaudeSession {
2266
2355
  this.refreshUsage();
2267
2356
  }
2268
2357
  else if (msg.subtype === 'status') {
2269
- const status = msg.status;
2270
- const compactResult = msg.compact_result;
2271
- if (status === 'compacting') {
2272
- this.emit({ type: 'notice', level: 'info', text: 'Compacting the conversation…' });
2273
- }
2274
- else if (compactResult) {
2275
- this.emit({
2276
- type: 'notice',
2277
- level: compactResult === 'failed' ? 'warn' : 'info',
2278
- text: compactResult === 'failed'
2279
- ? `Compaction failed: ${maskString(String(msg.compact_error ?? 'unknown')).slice(0, 300)}`
2280
- : 'Conversation compacted',
2281
- });
2358
+ this.noteStatus(msg);
2359
+ }
2360
+ else if (msg.subtype === 'compact_boundary') {
2361
+ // The summary is in place: this marker is the end of a
2362
+ // compaction whether or not a status said so first (#348) — the
2363
+ // CLI's own remote client reads it the same way.
2364
+ if (this.compacting) {
2365
+ this.compacting = false;
2366
+ this.emit({ type: 'compaction', phase: 'finished', ok: true });
2282
2367
  }
2283
2368
  }
2284
2369
  else if (msg.subtype === 'api_retry') {
@@ -2410,6 +2495,10 @@ class ClaudeSession {
2410
2495
  break;
2411
2496
  }
2412
2497
  case 'result': {
2498
+ // A turn's end is the end of any compaction it carried (#348):
2499
+ // the supervisor closes the pair on `turn_end`, and the flag here
2500
+ // has to agree, or the next «compacting» would be read as a repeat.
2501
+ this.compacting = false;
2413
2502
  this.emit({
2414
2503
  type: 'cost',
2415
2504
  costUsd: msg.total_cost_usd,
@@ -2426,7 +2515,50 @@ class ClaudeSession {
2426
2515
  // once, for exactly one result.
2427
2516
  const aborted = this.aborting;
2428
2517
  this.aborting = false;
2429
- const failure = msg.subtype === 'success' ? '' : classifyError(msg.subtype, msg.errors);
2518
+ /**
2519
+ * The CLI's own word for why it stopped — and the one signal that
2520
+ * it gave up (#406).
2521
+ *
2522
+ * `subtype` alone is not that signal: on 12.09.2026 Claude Code
2523
+ * wrote «API Error: Connection lost mid-response», ended the turn,
2524
+ * and stamped the result `subtype: "success"` with
2525
+ * `terminal_reason: "api_error"` beside it. The runner read the
2526
+ * first field, reported a clean turn, and the session stood for
2527
+ * 5 h 17 min with «your turn» on it.
2528
+ *
2529
+ * Deliberately NOT the accumulated `turnFailureCode`: Claude Code
2530
+ * survives most stream cuts by itself, and a code left over from
2531
+ * one it survived would fail three healthy turns an hour (the
2532
+ * correction on §492, ticket #406's second comment).
2533
+ */
2534
+ const terminalReason = typeof msg.terminal_reason === 'string'
2535
+ ? msg.terminal_reason
2536
+ : null;
2537
+ /**
2538
+ * The other half of the turn boundary above (#406).
2539
+ *
2540
+ * The guard in the read loop forgets the last turn on the first
2541
+ * message that is not a `result` — which a turn the CLI woke and
2542
+ * that said NOTHING never sends. Its only message is its own
2543
+ * closing `result`, and `origin.kind` is the field the SDK marks it
2544
+ * with; #373 already treats that field as the one thing that tells a
2545
+ * genuinely new internal turn from a second ending of the last one.
2546
+ * Without this, a silent internal turn would report the `produced`
2547
+ * and `irreversible` of whatever ran before it.
2548
+ */
2549
+ const originKind = typeof msg.origin?.kind === 'string'
2550
+ ? msg.origin.kind
2551
+ : null;
2552
+ if (this.turnClosed && originKind === 'task-notification') {
2553
+ this.turnClosed = false;
2554
+ this.beginTurnFacts();
2555
+ }
2556
+ const gaveUp = msg.subtype === 'success' && terminalReason === 'api_error';
2557
+ const failure = gaveUp
2558
+ ? API_ERROR_TURN_MESSAGE
2559
+ : msg.subtype === 'success'
2560
+ ? ''
2561
+ : classifyError(msg.subtype, msg.errors);
2430
2562
  // #373, plan stage D. What the incident could not answer: the second
2431
2563
  // result's origin was never recorded, so «is this the same turn
2432
2564
  // twice or a turn the CLI started by itself» had no evidence either
@@ -2452,10 +2584,13 @@ class ClaudeSession {
2452
2584
  code: 'rewind_failed',
2453
2585
  });
2454
2586
  }
2455
- else if (msg.subtype === 'success' || aborted) {
2456
- // A turn the user stopped is not a failed turn. Reporting it as
2457
- // one moved the session to FAILED, which is terminal — pressing
2458
- // Stop cost people the session they meant to keep.
2587
+ else if (aborted || (msg.subtype === 'success' && !gaveUp)) {
2588
+ // `aborted` is tested FIRST, and stays ahead of `gaveUp`: a turn
2589
+ // the user stopped is not a failed turn whatever the CLI writes
2590
+ // beside it, and an abort can land on top of an API error.
2591
+ // Reporting a stop as a failure moved the session to FAILED,
2592
+ // which is terminal — pressing Stop cost people the session they
2593
+ // meant to keep.
2459
2594
  this.emit({
2460
2595
  type: 'turn_end',
2461
2596
  ok: true,
@@ -2487,7 +2622,17 @@ class ClaudeSession {
2487
2622
  // the closed enum rides on the assistant message, this `result`
2488
2623
  // has no such field. Handed over so the supervisor can decide
2489
2624
  // from a code rather than from the sentence in `errorMessage`.
2490
- ...(this.turnFailureCode !== null ? { failureCode: this.turnFailureCode } : {}),
2625
+ //
2626
+ // #406: a turn the CLI gave up on always carries a code, even
2627
+ // when nothing in it named one — `server_error` is what «their
2628
+ // side, try again» is spelled as, and without it `classifyFailure`
2629
+ // would have only the sentence to go on, which is the thing #252
2630
+ // took away from it.
2631
+ ...(this.turnFailureCode !== null
2632
+ ? { failureCode: this.turnFailureCode }
2633
+ : gaveUp
2634
+ ? { failureCode: 'server_error' }
2635
+ : {}),
2491
2636
  ...(this.turnFailureStatus !== null
2492
2637
  ? { failureStatus: this.turnFailureStatus }
2493
2638
  : {}),
@@ -2496,6 +2641,11 @@ class ClaudeSession {
2496
2641
  });
2497
2642
  this.refreshUsage();
2498
2643
  }
2644
+ // #406: the facts above have been reported, so the next message —
2645
+ // whoever starts the turn it belongs to — may forget them. Armed
2646
+ // here rather than cleared here, because a second `result` for this
2647
+ // same ending has to read exactly what the first one did.
2648
+ this.turnClosed = true;
2499
2649
  break;
2500
2650
  }
2501
2651
  default:
@@ -2671,6 +2821,15 @@ function stringifyContent(content) {
2671
2821
  }
2672
2822
  return content === undefined ? '' : JSON.stringify(content);
2673
2823
  }
2824
+ /**
2825
+ * What the feed says about a turn Claude Code gave up on (#406).
2826
+ *
2827
+ * Deliberately free of «mid-response» and «may be incomplete»: `refine()` in
2828
+ * `error-policy.ts` matches those two phrases and narrows a retry to a single
2829
+ * «carry on» attempt. Here the narrowing must come from `produced`, which is a
2830
+ * fact about the turn, and not from the wording of a sentence.
2831
+ */
2832
+ const API_ERROR_TURN_MESSAGE = 'The connection to the model failed and Claude Code ended the turn on an API error';
2674
2833
  function classifyError(subtype, errors) {
2675
2834
  // Optional on purpose despite the SDK's type: a `result` without `errors`
2676
2835
  // threw from inside the event pump, which the loop's catch turned into a
@@ -2745,6 +2904,23 @@ export class ClaudeAdapter {
2745
2904
  this.queryFn = queryFn;
2746
2905
  }
2747
2906
  startSession(spec) {
2907
+ /**
2908
+ * «There is no Claude here» is answered once, and here (#395).
2909
+ *
2910
+ * BEFORE the session object exists, and that placement is the point: the
2911
+ * constructor writes the MCP config — a 0600 file holding this workspace's
2912
+ * live key — before it builds `Options`, and nothing removes that file for
2913
+ * a constructor that threw. The supervisor turns this throw into one red
2914
+ * line in the feed (`launchCrashed`) and a FAILED session, which is the
2915
+ * honest answer when the agent is not on the machine.
2916
+ *
2917
+ * Only for the real SDK. An adapter built on an injected `query` is a test
2918
+ * or a probe: it spawns nothing, so the fallback this guards against cannot
2919
+ * happen — and asking anyway would fail the whole suite on every machine
2920
+ * without the CLI, CI included. That is how the 0.56.0 release broke.
2921
+ */
2922
+ if (this.queryFn === query)
2923
+ assertClaudeInstalled();
2748
2924
  return new ClaudeSession(spec, this.queryFn);
2749
2925
  }
2750
2926
  }
@@ -876,6 +876,7 @@ class CodexSession {
876
876
  return false;
877
877
  try {
878
878
  await this.client.request('thread/compact/start', { threadId: this.threadId });
879
+ this.requestedCompaction = true;
879
880
  return true;
880
881
  }
881
882
  catch (error) {
@@ -883,6 +884,14 @@ class CodexSession {
883
884
  return false;
884
885
  }
885
886
  }
887
+ /**
888
+ * A compaction THIS adapter asked for with `thread/compact/start` (#348) —
889
+ * the one kind that runs outside any turn and therefore has nothing to
890
+ * settle the session status behind it. The agent's own compaction inside a
891
+ * turn is not it, even when its item happens to complete before the
892
+ * `turn/started` that announces the turn.
893
+ */
894
+ requestedCompaction = false;
886
895
  stop(reason = 'session_stopped') {
887
896
  if (this.stopped)
888
897
  return;
@@ -1472,8 +1481,19 @@ class CodexSession {
1472
1481
  return;
1473
1482
  }
1474
1483
  case 'contextCompaction': {
1475
- if (done)
1476
- this.notice('info', 'Conversation compacted');
1484
+ // #348: an event, not a notice — see `AgentEvent['compaction']`. A
1485
+ // compaction started from the dashboard runs outside any turn on this
1486
+ // agent (`thread/compact/start` creates none), so nothing will settle
1487
+ // the status behind it; `standalone` tells the supervisor to.
1488
+ if (method === 'item/started') {
1489
+ this.emit({ type: 'compaction', phase: 'started' });
1490
+ return;
1491
+ }
1492
+ if (done) {
1493
+ const standalone = this.requestedCompaction && this.activeTurnId === null;
1494
+ this.requestedCompaction = false;
1495
+ this.emit({ type: 'compaction', phase: 'finished', ok: true, standalone });
1496
+ }
1477
1497
  return;
1478
1498
  }
1479
1499
  case 'webSearch':
@@ -110,6 +110,7 @@ const INVALIDATION_PHRASES = {
110
110
  turn_aborted: 'the turn was interrupted',
111
111
  agent_cancelled: 'the agent withdrew it',
112
112
  budget_spent: 'the session ran out of its allowed working time',
113
+ not_held: 'the agent no longer holds this question',
113
114
  };
114
115
  /** The question was taken away from the user — say why, never fake an answer. */
115
116
  export function invalidationMessage(reason) {
@@ -366,7 +366,16 @@ export interface AgentQuestionAnswer {
366
366
  notes?: string;
367
367
  }
368
368
  /** Why a question was taken away from the user without them answering it. */
369
- export type QuestionInvalidationReason = 'session_stopped' | 'session_parked' | 'runner_restarted' | 'turn_aborted' | 'agent_cancelled' | 'budget_spent';
369
+ export type QuestionInvalidationReason = 'session_stopped' | 'session_parked' | 'runner_restarted' | 'turn_aborted' | 'agent_cancelled' | 'budget_spent'
370
+ /**
371
+ * An answer arrived for a card no live process holds (#392): the card
372
+ * outlived the process that asked, or the ask was withdrawn before the
373
+ * answer got here. The runner does not know which, and says only what it
374
+ * does know. Never handed to an agent — there is no agent holding the ask
375
+ * to hand it to — so its phrase in `INVALIDATION_PHRASES` is for the type
376
+ * checker; the words a person reads are the dashboard's.
377
+ */
378
+ | 'not_held';
370
379
  /** The dashboard's answer to one open ask. */
371
380
  export interface QuestionReply {
372
381
  askId: string;
@@ -502,6 +511,38 @@ export type AgentEvent = {
502
511
  type: 'notice';
503
512
  level: 'info' | 'warn';
504
513
  text: string;
514
+ }
515
+ /**
516
+ * The conversation is being folded into a summary, or just was (#348).
517
+ *
518
+ * Its own event and not a `notice` on purpose: adapter notices are
519
+ * de-duplicated per session on their text (gotcha §148), so «Conversation
520
+ * compacted» reached the feed once and every later compaction was silence.
521
+ * A compaction is a fact the feed has to hear every time — the dashboard's
522
+ * «compacting…» indicator is read off the feed, and a second compaction
523
+ * with no lines is a second compaction the person cannot see end.
524
+ *
525
+ * `standalone` on the ending: `true` when NO turn is going to close behind
526
+ * this compaction, so the supervisor has to settle the session status
527
+ * itself. Codex runs a compaction started from the dashboard outside any
528
+ * turn (`thread/compact/start`); Claude delivers `/compact` as a turn, and
529
+ * that turn's own ending settles the status the ordinary way.
530
+ */
531
+ | {
532
+ type: 'compaction';
533
+ phase: 'started';
534
+ } | {
535
+ type: 'compaction';
536
+ phase: 'finished';
537
+ ok: boolean;
538
+ error?: string;
539
+ standalone?: boolean;
540
+ /**
541
+ * It ended without a summary: the CLI decided not to compact after all
542
+ * (a PreCompact hook said no, or it found nothing worth folding). Not a
543
+ * failure — nothing broke — and not a compaction either.
544
+ */
545
+ skipped?: boolean;
505
546
  } | {
506
547
  type: 'cost';
507
548
  costUsd: number;
@@ -62,13 +62,54 @@ export declare const USE_BUNDLED_CLAUDE = false;
62
62
  * the tests also need the real behaviour of; production always takes the default.
63
63
  */
64
64
  export declare function sessionClaudePath(useBundled?: boolean): string | null;
65
+ /**
66
+ * What every caller says when Claude Code is not on this machine (#395).
67
+ *
68
+ * Word for word the API's own refusal (`assertAgentAvailable` in
69
+ * `dev-sessions.service.ts`): the two halves answer the same question about the
70
+ * same binary, and a person who meets both — the API when it can see the
71
+ * measurement, this one when it cannot — should not have to work out whether
72
+ * they are being told two different things.
73
+ */
74
+ export declare const CLAUDE_NOT_INSTALLED = "Claude Code is not installed on this server \u2014 install it there, or pick another agent";
75
+ /** Thrown where a run would otherwise start on a binary nobody chose. */
76
+ export declare class ClaudeNotInstalledError extends Error {
77
+ constructor();
78
+ }
79
+ /**
80
+ * Refuse, in words, when a real Claude run has nothing legitimate to start.
81
+ *
82
+ * ## The hole this closes (#395, decision D14)
83
+ *
84
+ * With the bundled binary switched off and no system `claude`,
85
+ * `claudeExecutableOption` has nothing to pin and returns `{}`. To the SDK an
86
+ * absent `pathToClaudeCodeExecutable` does not mean «there is no Claude» — it
87
+ * means «resolve your own». So the machine either ran sessions on a SECOND
88
+ * Claude of a different version while its card said «not installed» (five
89
+ * sessions on `vmi3024903`, one of them $93), or — on a machine installed the
90
+ * standard way, where `--omit=optional` means the bundled package is not there
91
+ * at all — died inside the SDK with `Native CLI binary for linux-x64 not found`
92
+ * before its first word.
93
+ *
94
+ * ## Why it is a separate call and not a throw from `claudeExecutableOption`
95
+ *
96
+ * Both places that build `Options` do so eagerly, including in tests, where the
97
+ * SDK is replaced by a fake `query` and no binary is ever spawned. A throw from
98
+ * the option builder would therefore fail the whole suite on any machine
99
+ * without the CLI — CI among them, which is exactly how the 0.56.0 release
100
+ * broke. The callers below ask this question only on the path that reaches the
101
+ * REAL SDK.
102
+ */
103
+ export declare function assertClaudeInstalled(useBundled?: boolean,
104
+ /** Where the binary is looked up. Production always takes the default. */
105
+ resolve?: (useBundled: boolean) => string | null): void;
65
106
  /**
66
107
  * The SDK option that pins the executable, for both places that build `Options`.
67
108
  *
68
- * Empty while the bundled binary is in use: the SDK then resolves its own, which
69
- * is exactly today's behaviour and keeps `main` neutral. Empty also when the
70
- * system binary is missing — passing a path we know is not there would turn a
71
- * clear «Claude is not installed» into an SDK spawn error.
109
+ * Empty while the bundled binary is in use: the SDK then resolves its own,
110
+ * which is exactly what `USE_BUNDLED_CLAUDE = true` asks for. Empty also when
111
+ * there is no system binary — and that emptiness is no longer allowed to reach
112
+ * the real SDK: `assertClaudeInstalled` is asked first, on both paths.
72
113
  */
73
114
  export declare function claudeExecutableOption(useBundled?: boolean): {
74
115
  pathToClaudeCodeExecutable?: string;
@@ -137,13 +137,62 @@ export const USE_BUNDLED_CLAUDE = false;
137
137
  export function sessionClaudePath(useBundled = USE_BUNDLED_CLAUDE) {
138
138
  return useBundled ? claudeCliPath() : whichExecutable(AGENT_RUNTIMES.claude.bin);
139
139
  }
140
+ /**
141
+ * What every caller says when Claude Code is not on this machine (#395).
142
+ *
143
+ * Word for word the API's own refusal (`assertAgentAvailable` in
144
+ * `dev-sessions.service.ts`): the two halves answer the same question about the
145
+ * same binary, and a person who meets both — the API when it can see the
146
+ * measurement, this one when it cannot — should not have to work out whether
147
+ * they are being told two different things.
148
+ */
149
+ export const CLAUDE_NOT_INSTALLED = 'Claude Code is not installed on this server — install it there, or pick another agent';
150
+ /** Thrown where a run would otherwise start on a binary nobody chose. */
151
+ export class ClaudeNotInstalledError extends Error {
152
+ constructor() {
153
+ super(CLAUDE_NOT_INSTALLED);
154
+ this.name = 'ClaudeNotInstalledError';
155
+ }
156
+ }
157
+ /**
158
+ * Refuse, in words, when a real Claude run has nothing legitimate to start.
159
+ *
160
+ * ## The hole this closes (#395, decision D14)
161
+ *
162
+ * With the bundled binary switched off and no system `claude`,
163
+ * `claudeExecutableOption` has nothing to pin and returns `{}`. To the SDK an
164
+ * absent `pathToClaudeCodeExecutable` does not mean «there is no Claude» — it
165
+ * means «resolve your own». So the machine either ran sessions on a SECOND
166
+ * Claude of a different version while its card said «not installed» (five
167
+ * sessions on `vmi3024903`, one of them $93), or — on a machine installed the
168
+ * standard way, where `--omit=optional` means the bundled package is not there
169
+ * at all — died inside the SDK with `Native CLI binary for linux-x64 not found`
170
+ * before its first word.
171
+ *
172
+ * ## Why it is a separate call and not a throw from `claudeExecutableOption`
173
+ *
174
+ * Both places that build `Options` do so eagerly, including in tests, where the
175
+ * SDK is replaced by a fake `query` and no binary is ever spawned. A throw from
176
+ * the option builder would therefore fail the whole suite on any machine
177
+ * without the CLI — CI among them, which is exactly how the 0.56.0 release
178
+ * broke. The callers below ask this question only on the path that reaches the
179
+ * REAL SDK.
180
+ */
181
+ export function assertClaudeInstalled(useBundled = USE_BUNDLED_CLAUDE,
182
+ /** Where the binary is looked up. Production always takes the default. */
183
+ resolve = sessionClaudePath) {
184
+ if (useBundled)
185
+ return;
186
+ if (!resolve(useBundled))
187
+ throw new ClaudeNotInstalledError();
188
+ }
140
189
  /**
141
190
  * The SDK option that pins the executable, for both places that build `Options`.
142
191
  *
143
- * Empty while the bundled binary is in use: the SDK then resolves its own, which
144
- * is exactly today's behaviour and keeps `main` neutral. Empty also when the
145
- * system binary is missing — passing a path we know is not there would turn a
146
- * clear «Claude is not installed» into an SDK spawn error.
192
+ * Empty while the bundled binary is in use: the SDK then resolves its own,
193
+ * which is exactly what `USE_BUNDLED_CLAUDE = true` asks for. Empty also when
194
+ * there is no system binary — and that emptiness is no longer allowed to reach
195
+ * the real SDK: `assertClaudeInstalled` is asked first, on both paths.
147
196
  */
148
197
  export function claudeExecutableOption(useBundled = USE_BUNDLED_CLAUDE) {
149
198
  if (useBundled)
@@ -56,6 +56,10 @@ export declare function ensureGitExclude(worktreePath: string): Promise<void>;
56
56
  * Returns what actually landed — a file that could not be fetched is reported
57
57
  * and skipped rather than failing the whole message, because the text the user
58
58
  * typed is usually still worth delivering.
59
+ *
60
+ * Each file gets up to `DOWNLOAD_ATTEMPTS` goes inside ONE `DOWNLOAD_TIMEOUT_MS`
61
+ * budget (#407): the ceiling on how long a person waits for their own message is
62
+ * unchanged, and the attempts share it rather than each getting their own.
59
63
  */
60
64
  export declare function saveAttachments(input: {
61
65
  worktreePath: string;
@@ -63,6 +67,8 @@ export declare function saveAttachments(input: {
63
67
  token: string;
64
68
  attachments: RemoteAttachment[];
65
69
  fetchImpl?: typeof fetch;
70
+ /** Test seam: the pauses between attempts, so a suite does not sit through them. */
71
+ sleepImpl?: (ms: number) => Promise<void>;
66
72
  }): Promise<{
67
73
  saved: SavedAttachment[];
68
74
  failed: string[];