@bridge4dev/runner 0.36.0 → 0.38.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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The platform packages the SDK will look for, in its own order (`sdk.mjs`).
3
+ *
4
+ * BOTH linux variants are accepted rather than detecting musl the way the SDK
5
+ * does: the question here is «did the optional package get installed at all»,
6
+ * and answering it must never be the thing that blocks an update. A glibc
7
+ * binary on a musl host is a different failure — and since #225 it announces
8
+ * itself in the session feed instead of hiding.
9
+ */
10
+ export declare function nativeCandidates(platform?: NodeJS.Platform, arch?: string): string[];
11
+ /**
12
+ * Absolute path to the Claude CLI inside an installed runner package, or null.
13
+ *
14
+ * Resolved from the SDK's own file, exactly like the SDK resolves it — npm may
15
+ * hoist the platform package next to the SDK, next to the runner, or several
16
+ * levels up, and only the module resolver knows which happened here.
17
+ */
18
+ export declare function findClaudeCli(packageDir: string): string | null;
19
+ /**
20
+ * The same question about THIS process's own installation.
21
+ *
22
+ * Goes through the SDK's entry point rather than a path guess, so it answers
23
+ * correctly in every layout the runner runs in — a global npm install, a
24
+ * dedicated-user prefix, and the pnpm store of a source checkout, where the
25
+ * platform package is reachable from the SDK and from nowhere else.
26
+ */
27
+ export declare function claudeCliPath(): string | null;
28
+ //# sourceMappingURL=agent-binary.d.ts.map
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ /**
5
+ * Is the binary this runner would actually launch Claude with present?
6
+ *
7
+ * Not a hypothetical check (ticket #225, гоча #297). The Claude CLI does not
8
+ * live in this package: `@anthropic-ai/claude-agent-sdk` keeps it in a ~300 MB
9
+ * **optional** platform package, one per platform+arch. For npm the failure of
10
+ * an optional dependency is not an error — `npm install -g` exits 0, the runner
11
+ * reports a successful update and restarts into a build that cannot start a
12
+ * single Claude session. That is exactly what happened to transitway-dev-01 on
13
+ * 2026-08-11: every launch threw
14
+ * `Native CLI binary for linux-x64 not found` synchronously, before any event
15
+ * could be emitted, and a live session went silent for an hour and a half.
16
+ *
17
+ * The runner's own smoke test could not catch it: `devbridge-runner --version`
18
+ * loads the module graph, and the SDK resolves the platform binary lazily — on
19
+ * the first `query()`, which is a session, not a startup.
20
+ */
21
+ const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk';
22
+ /**
23
+ * The platform packages the SDK will look for, in its own order (`sdk.mjs`).
24
+ *
25
+ * BOTH linux variants are accepted rather than detecting musl the way the SDK
26
+ * does: the question here is «did the optional package get installed at all»,
27
+ * and answering it must never be the thing that blocks an update. A glibc
28
+ * binary on a musl host is a different failure — and since #225 it announces
29
+ * itself in the session feed instead of hiding.
30
+ */
31
+ export function nativeCandidates(platform = process.platform, arch = process.arch) {
32
+ const exe = platform === 'win32' ? 'claude.exe' : 'claude';
33
+ const packages = platform === 'android'
34
+ ? [`${SDK_PACKAGE}-linux-${arch}-android`]
35
+ : platform === 'linux'
36
+ ? [`${SDK_PACKAGE}-linux-${arch}`, `${SDK_PACKAGE}-linux-${arch}-musl`]
37
+ : [`${SDK_PACKAGE}-${platform}-${arch}`];
38
+ return packages.map((name) => `${name}/${exe}`);
39
+ }
40
+ /**
41
+ * Absolute path to the Claude CLI inside an installed runner package, or null.
42
+ *
43
+ * Resolved from the SDK's own file, exactly like the SDK resolves it — npm may
44
+ * hoist the platform package next to the SDK, next to the runner, or several
45
+ * levels up, and only the module resolver knows which happened here.
46
+ */
47
+ export function findClaudeCli(packageDir) {
48
+ const found = resolveFromSdkEntry(path.join(packageDir, 'node_modules', SDK_PACKAGE, 'sdk.mjs'));
49
+ // Inside THIS installation, or it does not count. Node's resolver walks up the
50
+ // directory tree and consults the global folders, so a package that is absent
51
+ // from the build we just installed can still be found in the one we are about
52
+ // to retire — and answering «present» from there is exactly the false green
53
+ // this check exists to prevent. A global npm install keeps its dependencies
54
+ // under its own package directory, so containment is also simply true.
55
+ return found && isInside(packageDir, found) ? found : null;
56
+ }
57
+ function isInside(dir, file) {
58
+ const relative = path.relative(path.resolve(dir), path.resolve(file));
59
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
60
+ }
61
+ /**
62
+ * The same question about THIS process's own installation.
63
+ *
64
+ * Goes through the SDK's entry point rather than a path guess, so it answers
65
+ * correctly in every layout the runner runs in — a global npm install, a
66
+ * dedicated-user prefix, and the pnpm store of a source checkout, where the
67
+ * platform package is reachable from the SDK and from nowhere else.
68
+ */
69
+ export function claudeCliPath() {
70
+ let sdkEntry;
71
+ try {
72
+ sdkEntry = createRequire(import.meta.url).resolve(SDK_PACKAGE);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ return resolveFromSdkEntry(sdkEntry);
78
+ }
79
+ function resolveFromSdkEntry(sdkEntry) {
80
+ let resolve;
81
+ try {
82
+ resolve = createRequire(sdkEntry).resolve;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ for (const candidate of nativeCandidates()) {
88
+ try {
89
+ const resolved = resolve(candidate);
90
+ if (fs.existsSync(resolved))
91
+ return resolved;
92
+ }
93
+ catch {
94
+ // Not installed under this name — try the next candidate.
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ //# sourceMappingURL=agent-binary.js.map
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { ClaudeAdapter } from './adapters/claude.js';
9
9
  import { CodexAdapter } from './adapters/codex.js';
10
10
  import { ensureCodexHome } from './adapters/codex-home.js';
11
+ import { claudeCliPath } from './agent-binary.js';
11
12
  import { loadConfig, requireConfig, saveConfig } from './config.js';
12
13
  import { log } from './log.js';
13
14
  import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
@@ -62,8 +63,14 @@ function argValue(args, flag) {
62
63
  */
63
64
  function installedAgents() {
64
65
  const agents = [];
65
- // Claude is bundled inside the Agent SDK, so it is always available.
66
- agents.push('claude');
66
+ // Claude comes with the Agent SDK — but «comes with» is a claim about THIS
67
+ // installation, not a law (ticket #225). The CLI is an optional platform
68
+ // package, and an update that silently lost it leaves a runner that reports
69
+ // Claude, accepts Claude sessions, and cannot start a single one. Reported as
70
+ // measured, so the dashboard greys the agent out instead of offering a
71
+ // session that dies before its first word.
72
+ if (claudeCliPath())
73
+ agents.push('claude');
67
74
  if (hasExecutable('codex'))
68
75
  agents.push('codex');
69
76
  return agents;
@@ -389,6 +396,11 @@ function runnerCapabilities(apiUrlOverride) {
389
396
  'agent_prompt_state',
390
397
  'propose_commit_message',
391
398
  'recall_message',
399
+ // The answer to a parked question, as a command that ANSWERS rather than
400
+ // a frame written into a socket and hoped for. Announced here because
401
+ // this list is what `runCommand` actually dispatches on, and the API
402
+ // falls back to the old frame for runners that do not name it.
403
+ 'answer_question',
392
404
  'compact_context',
393
405
  ...(checkpointsEnabled
394
406
  ? [
@@ -272,6 +272,78 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
272
272
  workMode?: unknown;
273
273
  }>;
274
274
  export type SessionDescriptor = z.infer<typeof SessionDescriptorSchema>;
275
+ /**
276
+ * One human answer to a parked agent question, minus the session it belongs to.
277
+ *
278
+ * Written once and used twice on purpose: as the body of the `question_answer`
279
+ * frame every runner since 0.14 understands, and as the arguments of the
280
+ * `answer_question` COMMAND that replaced it (session 18). The two must not
281
+ * drift — an answer that the frame accepts and the command rejects would fail
282
+ * exactly on the machines whose connection is bad enough to need the command.
283
+ */
284
+ export declare const QuestionAnswerShape: {
285
+ readonly askId: z.ZodString;
286
+ readonly action: z.ZodEnum<["answer", "discuss"]>;
287
+ readonly answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
288
+ questionId: z.ZodString;
289
+ values: z.ZodArray<z.ZodString, "many">;
290
+ custom: z.ZodOptional<z.ZodString>;
291
+ notes: z.ZodOptional<z.ZodString>;
292
+ }, "strip", z.ZodTypeAny, {
293
+ values: string[];
294
+ questionId: string;
295
+ custom?: string | undefined;
296
+ notes?: string | undefined;
297
+ }, {
298
+ values: string[];
299
+ questionId: string;
300
+ custom?: string | undefined;
301
+ notes?: string | undefined;
302
+ }>, "many">>;
303
+ readonly text: z.ZodOptional<z.ZodString>;
304
+ };
305
+ /** The `answer_question` command's arguments — the shape above on its own. */
306
+ export declare const QuestionAnswerArgsSchema: z.ZodObject<{
307
+ readonly askId: z.ZodString;
308
+ readonly action: z.ZodEnum<["answer", "discuss"]>;
309
+ readonly answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
310
+ questionId: z.ZodString;
311
+ values: z.ZodArray<z.ZodString, "many">;
312
+ custom: z.ZodOptional<z.ZodString>;
313
+ notes: z.ZodOptional<z.ZodString>;
314
+ }, "strip", z.ZodTypeAny, {
315
+ values: string[];
316
+ questionId: string;
317
+ custom?: string | undefined;
318
+ notes?: string | undefined;
319
+ }, {
320
+ values: string[];
321
+ questionId: string;
322
+ custom?: string | undefined;
323
+ notes?: string | undefined;
324
+ }>, "many">>;
325
+ readonly text: z.ZodOptional<z.ZodString>;
326
+ }, "strip", z.ZodTypeAny, {
327
+ askId: string;
328
+ action: "answer" | "discuss";
329
+ text?: string | undefined;
330
+ answers?: {
331
+ values: string[];
332
+ questionId: string;
333
+ custom?: string | undefined;
334
+ notes?: string | undefined;
335
+ }[] | undefined;
336
+ }, {
337
+ askId: string;
338
+ action: "answer" | "discuss";
339
+ text?: string | undefined;
340
+ answers?: {
341
+ values: string[];
342
+ questionId: string;
343
+ custom?: string | undefined;
344
+ notes?: string | undefined;
345
+ }[] | undefined;
346
+ }>;
275
347
  export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
276
348
  type: z.ZodLiteral<"hello_ack">;
277
349
  serverId: z.ZodString;
@@ -1104,8 +1176,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1104
1176
  requestId: string;
1105
1177
  note?: string | undefined;
1106
1178
  }>, z.ZodObject<{
1107
- type: z.ZodLiteral<"question_answer">;
1108
- sessionId: z.ZodString;
1109
1179
  askId: z.ZodString;
1110
1180
  action: z.ZodEnum<["answer", "discuss"]>;
1111
1181
  answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -1125,6 +1195,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1125
1195
  notes?: string | undefined;
1126
1196
  }>, "many">>;
1127
1197
  text: z.ZodOptional<z.ZodString>;
1198
+ type: z.ZodLiteral<"question_answer">;
1199
+ sessionId: z.ZodString;
1128
1200
  }, "strip", z.ZodTypeAny, {
1129
1201
  sessionId: string;
1130
1202
  type: "question_answer";
package/dist/protocol.js CHANGED
@@ -183,6 +183,31 @@ export const SessionDescriptorSchema = z.object({
183
183
  // project). Preferred over the [mcp] section of config.toml when present.
184
184
  mcp: z.object({ url: z.string().url(), token: z.string().min(1) }).optional(),
185
185
  });
186
+ /**
187
+ * One human answer to a parked agent question, minus the session it belongs to.
188
+ *
189
+ * Written once and used twice on purpose: as the body of the `question_answer`
190
+ * frame every runner since 0.14 understands, and as the arguments of the
191
+ * `answer_question` COMMAND that replaced it (session 18). The two must not
192
+ * drift — an answer that the frame accepts and the command rejects would fail
193
+ * exactly on the machines whose connection is bad enough to need the command.
194
+ */
195
+ export const QuestionAnswerShape = {
196
+ askId: z.string().min(1).max(64),
197
+ action: z.enum(['answer', 'discuss']),
198
+ answers: z
199
+ .array(z.object({
200
+ questionId: z.string().min(1).max(120),
201
+ values: z.array(z.string().max(2_000)).max(16),
202
+ custom: z.string().max(10_000).optional(),
203
+ notes: z.string().max(2_000).optional(),
204
+ }))
205
+ .max(4)
206
+ .optional(),
207
+ text: z.string().max(20_000).optional(),
208
+ };
209
+ /** The `answer_question` command's arguments — the shape above on its own. */
210
+ export const QuestionAnswerArgsSchema = z.object(QuestionAnswerShape);
186
211
  export const GatewayFrameSchema = z.discriminatedUnion('type', [
187
212
  z.object({
188
213
  type: z.literal('hello_ack'),
@@ -238,18 +263,7 @@ export const GatewayFrameSchema = z.discriminatedUnion('type', [
238
263
  z.object({
239
264
  type: z.literal('question_answer'),
240
265
  sessionId: z.string().uuid(),
241
- askId: z.string().min(1).max(64),
242
- action: z.enum(['answer', 'discuss']),
243
- answers: z
244
- .array(z.object({
245
- questionId: z.string().min(1).max(120),
246
- values: z.array(z.string().max(2_000)).max(16),
247
- custom: z.string().max(10_000).optional(),
248
- notes: z.string().max(2_000).optional(),
249
- }))
250
- .max(4)
251
- .optional(),
252
- text: z.string().max(20_000).optional(),
266
+ ...QuestionAnswerShape,
253
267
  }),
254
268
  z.object({ type: z.literal('session_stop'), sessionId: z.string().uuid() }),
255
269
  z.object({ type: z.literal('session_interrupt'), sessionId: z.string().uuid() }),
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
+ import { findClaudeCli } from './agent-binary.js';
6
7
  import { log } from './log.js';
7
8
  import { stateDir } from './paths.js';
8
9
  import { RUNNER_VERSION } from './version.js';
@@ -235,10 +236,19 @@ function installArgs(source, prefix) {
235
236
  // a directory the daemon cannot write — and, on the rarer host where it can,
236
237
  // npm cheerfully installs a SECOND copy somewhere the service does not exec,
237
238
  // reports success, and the runner restarts on the old version forever.
239
+ //
240
+ // `--include=optional` is npm's default and is stated anyway (ticket #225):
241
+ // the Claude CLI ships as an OPTIONAL platform package, and a single
242
+ // `omit=optional` inherited from an `.npmrc`, an environment variable or a CI
243
+ // habit turns an update into a runner that cannot start a single Claude
244
+ // session — silently, because for npm a failed optional dependency is not a
245
+ // failure at all. The flag makes this deployment's intent explicit rather
246
+ // than dependent on whatever configuration the machine happens to carry.
238
247
  return [
239
248
  'install',
240
249
  '-g',
241
250
  '--ignore-scripts',
251
+ '--include=optional',
242
252
  '--loglevel=error',
243
253
  ...(prefix ? ['--prefix', prefix] : []),
244
254
  source,
@@ -259,7 +269,7 @@ export function manualUpdateCommand(tarballUrl, options = {}) {
259
269
  const prefix = options.prefix === undefined ? installPrefixFor(packageDir) : options.prefix;
260
270
  const user = options.user ?? os.userInfo().username;
261
271
  const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : -1);
262
- const install = ['npm install -g --ignore-scripts --loglevel=error']
272
+ const install = ['npm install -g --ignore-scripts --include=optional --loglevel=error']
263
273
  .concat(prefix ? [`--prefix ${prefix}`] : [])
264
274
  .concat([tarballUrl])
265
275
  .join(' ');
@@ -420,6 +430,44 @@ export async function selfUpdate(options) {
420
430
  `Restore it on the server with: npm install -g${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
421
431
  }
422
432
  }
433
+ // The binary the sessions will actually be started with (ticket #225).
434
+ //
435
+ // The probe above is not enough and 2026-08-11 proved it: `--version` loads
436
+ // the module graph, but the SDK resolves the Claude CLI lazily — on the first
437
+ // `query()`, i.e. inside a session, long after this update reported success.
438
+ // A missing OPTIONAL platform package therefore sailed through every check
439
+ // here, the daemon restarted, and the machine spent an hour and a half
440
+ // answering «продолжай» with nothing.
441
+ //
442
+ // One repair attempt first, because that is what the failure usually deserves:
443
+ // a 300 MB optional package that did not download is a network hiccup, not a
444
+ // broken release, and reinstalling it is cheaper for the user than a rollback.
445
+ if (!findClaudeCli(newPackageDir)) {
446
+ log.warn('self-update: the Claude CLI is missing from the new build — repairing', {
447
+ packageDir: newPackageDir,
448
+ });
449
+ try {
450
+ await installGlobal(exec, options.tarballUrl, prefix);
451
+ }
452
+ catch (error) {
453
+ log.warn('self-update: the repair install failed', { error: describe(error) });
454
+ }
455
+ }
456
+ if (!findClaudeCli(newPackageDir)) {
457
+ log.error('self-update: still no Claude CLI after the repair — rolling back', {
458
+ packageDir: newPackageDir,
459
+ });
460
+ const detail = 'the Claude CLI (an optional platform package of @anthropic-ai/claude-agent-sdk) did not install, ' +
461
+ 'so no Claude session could have started on this build';
462
+ try {
463
+ await installGlobal(exec, rollbackTarball, prefix);
464
+ return fail(`The new version was not activated (${detail}). The previous version was restored and the runner keeps working.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
465
+ }
466
+ catch (rollbackError) {
467
+ return fail(`The new version is broken (${detail}) and the rollback failed too (${describe(rollbackError)}). ` +
468
+ `Restore it on the server with: npm install -g --include=optional${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
469
+ }
470
+ }
423
471
  // The service unit may be pinned to a file inside the directory this update
424
472
  // just replaced — early versions wrote the resolved script path, and a package
425
473
  // rename moves it. Then the restart we are about to ask for would fail with
@@ -19,6 +19,12 @@ export interface SupervisorOptions {
19
19
  runnerToken?: string;
20
20
  /** Test seam for the `self_update` command. */
21
21
  selfUpdate?: typeof selfUpdate;
22
+ /**
23
+ * How long a freshly started agent process may say nothing before the session
24
+ * says so out loud (ticket #225). Test seam — the default is a minute, and a
25
+ * test that had to wait one would not be written.
26
+ */
27
+ startupSilenceMs?: number;
22
28
  /**
23
29
  * Called after a successful update, once the reply is on the wire. The daemon
24
30
  * exits here and systemd starts the new build; without a handler the runner
@@ -113,11 +119,53 @@ export declare class Supervisor {
113
119
  * free CHAT session with no prompt at all (the agent boots, reports its
114
120
  * capabilities and waits for the first message).
115
121
  *
116
- * Returns whether an agent process actually started: a caller holding a user
117
- * message needs to know, because a refused launch means the message has to
118
- * stay queued rather than be marked delivered (session 9).
122
+ * Returns what came of it: a caller holding a user message needs to know,
123
+ * because anything but `ok` means the message has to stay queued rather than
124
+ * be marked delivered (session 9).
125
+ *
126
+ * NEVER throws (ticket #225). Everything from reading the project prompt to
127
+ * the adapter's own constructor runs inside one guard, because the caller
128
+ * chain above cannot tell the difference between «did not start» and
129
+ * «threw»: the message path swallows the exception into a log line, and the
130
+ * reconnect path lets it abort the restore of every OTHER session on the
131
+ * machine. A launch that fails is a state this session reports, not an
132
+ * exception somebody else has to remember to catch.
119
133
  */
120
134
  private launchAgent;
135
+ /**
136
+ * The agent process could not be started at all (ticket #225).
137
+ *
138
+ * Three things have to happen here, and until this ticket none of them did:
139
+ * the reason is said WHERE THE PERSON IS LOOKING (an `error` event is the
140
+ * feed's red line), the session stops claiming to be working, and the stack
141
+ * reaches journald for whoever has to fix the machine. The status is the
142
+ * honest one for a session with no process — the agent is not running, and a
143
+ * session left in `RUNNING` shows a stop button for a turn that does not
144
+ * exist.
145
+ *
146
+ * Never rethrows: this IS the handling. The caller gets `crashed` and decides
147
+ * what to do with the message it was holding.
148
+ */
149
+ private launchCrashed;
150
+ /** How long a freshly started agent may say nothing before we say so. */
151
+ private static readonly STARTUP_SILENCE_MS;
152
+ /**
153
+ * Watch for the first word out of a process we just started (ticket #225).
154
+ *
155
+ * «The adapter object exists» is not «the agent is running». A CLI that hangs
156
+ * before its first frame — a stuck hook, an MCP server that never answers, a
157
+ * transcript it cannot read — produces no events, no error and no exit, and
158
+ * the session sits in `RUNNING` forever. On a healthy launch the first event
159
+ * arrives in about two seconds, so a minute of silence is not a slow start,
160
+ * it is something worth saying out loud.
161
+ *
162
+ * Says it and stops there: no kill. A long conversation has the right to boot
163
+ * slowly, and killing it would cost the person the very turn they are waiting
164
+ * for.
165
+ */
166
+ private watchForFirstSignOfLife;
167
+ /** The process spoke, or went away — either way the watch is over. */
168
+ private clearStartupWatch;
121
169
  /**
122
170
  * The project's own prompt file, read fresh for THIS agent process.
123
171
  *