@bridge4dev/runner 0.46.1 → 0.48.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.
@@ -3,7 +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
+ import { findClaudeCli, USE_BUNDLED_CLAUDE } from './agent-binary.js';
7
7
  import { log } from './log.js';
8
8
  import { stateDir } from './paths.js';
9
9
  import { RUNNER_VERSION } from './version.js';
@@ -237,18 +237,32 @@ function installArgs(source, prefix) {
237
237
  // npm cheerfully installs a SECOND copy somewhere the service does not exec,
238
238
  // reports success, and the runner restarts on the old version forever.
239
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.
240
+ // `--omit=optional` since 0.47.0, and it used to be its exact opposite.
241
+ //
242
+ // Until C3 the Claude CLI a session ran on WAS an optional platform package of
243
+ // the Agent SDK inside this package, so `--include=optional` was stated
244
+ // explicitly (ticket #225): a single `omit=optional` inherited from an
245
+ // `.npmrc` produced a runner that could not start one Claude session, and npm
246
+ // called that a success. Sessions now run on the system `claude` — installed,
247
+ // measured and updated like any other agent — so the 215 MB binary in here is
248
+ // no longer used by anything, and downloading it on every update would be a
249
+ // tax on every dev server for a file nothing opens.
250
+ //
251
+ // Read this beside `npmInstallArgs` in `agent-install.ts`, which says
252
+ // `--include=optional` and is not a contradiction: DIFFERENT packages. The
253
+ // runner no longer wants Claude's platform binary; `@openai/codex` keeps its
254
+ // own 320 MB executable in exactly such an optional package, and installing
255
+ // Codex without it yields a shim that cannot run — with npm exiting 0 either
256
+ // way (гоча #297, and #415 for the Codex half).
257
+ //
258
+ // C4 is what makes this safe: the bundled-binary check below is conditional on
259
+ // `USE_BUNDLED_CLAUDE`, so an update that no longer ships the binary is not
260
+ // read as a broken install and rolled back on every single machine, forever.
247
261
  return [
248
262
  'install',
249
263
  '-g',
250
264
  '--ignore-scripts',
251
- '--include=optional',
265
+ '--omit=optional',
252
266
  '--loglevel=error',
253
267
  ...(prefix ? ['--prefix', prefix] : []),
254
268
  source,
@@ -269,7 +283,7 @@ export function manualUpdateCommand(tarballUrl, options = {}) {
269
283
  const prefix = options.prefix === undefined ? installPrefixFor(packageDir) : options.prefix;
270
284
  const user = options.user ?? os.userInfo().username;
271
285
  const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : -1);
272
- const install = ['npm install -g --ignore-scripts --include=optional --loglevel=error']
286
+ const install = ['npm install -g --ignore-scripts --omit=optional --loglevel=error']
273
287
  .concat(prefix ? [`--prefix ${prefix}`] : [])
274
288
  .concat([tarballUrl])
275
289
  .join(' ');
@@ -367,6 +381,7 @@ export async function selfUpdate(options) {
367
381
  }
368
382
  // Derived once and threaded through every npm call below.
369
383
  const prefix = installPrefixFor(packageDir);
384
+ const usesBundledClaude = options.bundledClaude ?? USE_BUNDLED_CLAUDE;
370
385
  // Pack the current version FIRST: without a rollback artefact there is no
371
386
  // honest way back if the new build turns out to be broken.
372
387
  const rollbackDir = path.join(stateDir(), 'rollback');
@@ -442,7 +457,14 @@ export async function selfUpdate(options) {
442
457
  // One repair attempt first, because that is what the failure usually deserves:
443
458
  // a 300 MB optional package that did not download is a network hiccup, not a
444
459
  // broken release, and reinstalling it is cheaper for the user than a rollback.
445
- if (!findClaudeCli(newPackageDir)) {
460
+ //
461
+ // Conditional since C4, and it has to be: after `USE_BUNDLED_CLAUDE` flips,
462
+ // the runner is installed WITHOUT that optional package on purpose (`--omit=
463
+ // optional`, −215 MB), so an unconditional check would find it missing and
464
+ // roll back every single runner update, for ever. The check is not deleted —
465
+ // while the bundled binary is what sessions run on, its absence is still the
466
+ // 2026-08-11 outage waiting to happen.
467
+ if (usesBundledClaude && !findClaudeCli(newPackageDir)) {
446
468
  log.warn('self-update: the Claude CLI is missing from the new build — repairing', {
447
469
  packageDir: newPackageDir,
448
470
  });
@@ -453,7 +475,7 @@ export async function selfUpdate(options) {
453
475
  log.warn('self-update: the repair install failed', { error: describe(error) });
454
476
  }
455
477
  }
456
- if (!findClaudeCli(newPackageDir)) {
478
+ if (usesBundledClaude && !findClaudeCli(newPackageDir)) {
457
479
  log.error('self-update: still no Claude CLI after the repair — rolling back', {
458
480
  packageDir: newPackageDir,
459
481
  });
@@ -465,7 +487,7 @@ export async function selfUpdate(options) {
465
487
  }
466
488
  catch (rollbackError) {
467
489
  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 } : {}) });
490
+ `Restore it on the server with: npm install -g --omit=optional${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
469
491
  }
470
492
  }
471
493
  // The service unit may be pinned to a file inside the directory this update
@@ -553,4 +575,14 @@ function describe(error) {
553
575
  const meaningful = npmErrors || raw;
554
576
  return meaningful.length > 400 ? `…${meaningful.slice(-400)}` : meaningful;
555
577
  }
578
+ /**
579
+ * The same reading of a failed child process, for `agent-install.ts`.
580
+ *
581
+ * Exported rather than copied: installing an agent CLI shells out to the very
582
+ * same npm, and a second implementation would drift into reporting the ERESOLVE
583
+ * wall this one exists to suppress.
584
+ */
585
+ export function describeCommandFailure(error) {
586
+ return describe(error);
587
+ }
556
588
  //# sourceMappingURL=self-update.js.map
@@ -1,6 +1,9 @@
1
1
  import { JournalStore } from './journal.js';
2
2
  import { proposeCommitMessage } from './commit-message.js';
3
3
  import { selfUpdate, type SelfUpdateOutcome } from './self-update.js';
4
+ import { installAgent } from './agent-install.js';
5
+ import { pruneNativeClaudeVersions } from './agent-cleanup.js';
6
+ import { type AgentVersionsMeasurement } from './agent-versions.js';
4
7
  import type { RunnerWsClient } from './ws-client.js';
5
8
  import type { SessionDescriptor } from './protocol.js';
6
9
  import type { AgentAdapter } from './adapters/types.js';
@@ -53,6 +56,50 @@ export interface SupervisorOptions {
53
56
  * announced and no rewind can be started.
54
57
  */
55
58
  checkpointsEnabled?: boolean;
59
+ /**
60
+ * Test seam for the agent version measurement.
61
+ *
62
+ * A seam and not a detail: the real one spawns `claude`, `codex` and their
63
+ * doctors, and this class is constructed in every supervisor test — an
64
+ * unstubbed probe would run four processes per test file, on machines that
65
+ * may have neither CLI.
66
+ */
67
+ measureAgentVersions?: (options?: {
68
+ force?: boolean;
69
+ }) => Promise<AgentVersionsMeasurement>;
70
+ /**
71
+ * `[agents] install_enabled` from the runner's own config (#371), by the same
72
+ * rule as `[verify]` and `[checkpoints]`. Announced AND enforced: an API that
73
+ * has not noticed the veto still cannot install anything here.
74
+ */
75
+ agentInstallEnabled?: boolean;
76
+ /**
77
+ * Test seam for installing an agent CLI. The real one downloads 200–330 MB
78
+ * and writes into the npm prefix — not something a unit test may do.
79
+ */
80
+ installAgent?: typeof installAgent;
81
+ /**
82
+ * Test seam for the Р12 sweep of old Claude version files.
83
+ *
84
+ * A seam because the real one deletes 300 MB executables out of `$HOME`, and
85
+ * the thing worth testing HERE is not the deletion — that is
86
+ * `agent-cleanup.test.ts` — but the gate in front of it: no sessions, no
87
+ * install running. A test that had to move `$HOME` to prove a conditional
88
+ * would be proving the wrong thing in the wrong place.
89
+ */
90
+ pruneAgentVersions?: typeof pruneNativeClaudeVersions;
91
+ /**
92
+ * Test seam: how often the Р12 sweep runs, in ms — the first pass and every
93
+ * one after it.
94
+ *
95
+ * The same reason `emptyTurnSettleMs` exists. The real numbers are an hour
96
+ * and a day; no suite can wait either out, and fake timers are not an option
97
+ * here — they fire the WS client's liveness watchdog, which terminates the
98
+ * socket as «half-dead» and leaves the supervisor receiving nothing at all.
99
+ * Without this seam the gate in front of the sweep would never be executed by
100
+ * a test. Never set in production.
101
+ */
102
+ agentCleanupMs?: number;
56
103
  /**
57
104
  * How long a turn that produced nothing is held before it counts (#300).
58
105
  *
@@ -62,6 +109,13 @@ export interface SupervisorOptions {
62
109
  * firing over a working agent (QA-2026-08-16 M-4). Never set in production.
63
110
  */
64
111
  emptyTurnSettleMs?: number;
112
+ /**
113
+ * How long an unchanged plan-usage snapshot is held before it is re-sent
114
+ * (#366) – a test seam over `RATE_LIMITS_RESEND_INTERVAL_MS`, for the same
115
+ * reason `emptyTurnSettleMs` exists: the real floor is three minutes and this
116
+ * suite runs on real timers. Never set in production.
117
+ */
118
+ rateLimitsResendMs?: number;
65
119
  }
66
120
  export declare class Supervisor {
67
121
  private readonly ws;
@@ -89,6 +143,7 @@ export declare class Supervisor {
89
143
  private static readonly EMPTY_TURN_SETTLE_MS;
90
144
  /** The window actually used — the constant, or a test's own shorter one. */
91
145
  private readonly emptyTurnSettleMs;
146
+ private readonly rateLimitsResendMs;
92
147
  /** A finished session's journal is kept this long for a late reconnect. */
93
148
  private static readonly JOURNAL_TTL_MS;
94
149
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -117,12 +172,118 @@ export declare class Supervisor {
117
172
  * than having no restore point for that one message.
118
173
  */
119
174
  private readonly repoLockDepth;
120
- /** An update is installing right now — a second one would fight it. */
121
- private selfUpdateInFlight;
175
+ /**
176
+ * One install at a time on this machine, whoever asked for it.
177
+ *
178
+ * `self_update` and `agent_install` both end in `npm install -g` into the
179
+ * SAME npm prefix under the dedicated-user layout, so two of them at once is
180
+ * a real conflict rather than a theoretical one. The API takes a Redis lock
181
+ * before pressing either button, but that lock cannot cover an install the
182
+ * runner starts by itself (stage D) — and the runner has no Redis. This is
183
+ * the only lock that sees both.
184
+ *
185
+ * Holds the holder's name rather than a boolean, so the refusal can say
186
+ * which of the two is running.
187
+ */
188
+ private installInFlight;
122
189
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
123
190
  private readonly verify;
124
191
  private readonly verifyReports;
125
192
  constructor(ws: RunnerWsClient, opts: SupervisorOptions);
193
+ /** How often the agent versions are re-derived. See the constructor. */
194
+ private static readonly AGENT_VERSIONS_INTERVAL_MS;
195
+ private readonly agentVersionsTimer;
196
+ /**
197
+ * Tell the API which agents this machine has and at which versions.
198
+ *
199
+ * Never throws and never blocks its caller: a machine where a probe hangs
200
+ * must still start sessions. A frame that could not be written is simply not
201
+ * sent — the next `hello_ack` or the hourly tick carries the same fact, and
202
+ * the measurement behind it is cached, so retrying is nearly free.
203
+ */
204
+ /**
205
+ * Why an install cannot start right now, in words for the person who pressed.
206
+ *
207
+ * `null` means the way is clear.
208
+ */
209
+ private installBusyReason;
210
+ /**
211
+ * A version change that has happened but has not reached the API yet.
212
+ *
213
+ * `ws.send` returns false when the socket is down, and an install finishing
214
+ * during a reconnect is not a rare case — it is a 300 MB download that takes
215
+ * minutes. The VERSION recovers on its own (the hourly tick and every
216
+ * `hello_ack` re-send the measurement), but `reason` and `changed` are the
217
+ * audit line itself: dropped, «why is my Codex suddenly different» has no
218
+ * answer anywhere, which is precisely what Р14 exists to prevent.
219
+ *
220
+ * Kept in memory rather than on disk on purpose. A daemon that died mid-install
221
+ * has a bigger hole than one lost line, and persisting it would risk filing an
222
+ * install that a rollback then undid.
223
+ */
224
+ private readonly pendingVersionChanges;
225
+ /**
226
+ * Two installs cannot overlap — the local lock sees to that — but two can
227
+ * both FINISH inside one outage, and each is a line the log owes. A queue
228
+ * rather than a slot, because the second would otherwise overwrite the first
229
+ * and the older install would be the one that vanished.
230
+ *
231
+ * Bounded: an outage long enough to strand nine installs is one where the
232
+ * missing audit lines are not the problem worth solving.
233
+ */
234
+ private static readonly MAX_PENDING_VERSION_CHANGES;
235
+ publishAgentVersions(reason: 'measured' | 'manual' | 'auto', changed?: {
236
+ agent: string;
237
+ from: string | null;
238
+ to: string;
239
+ }): Promise<void>;
240
+ private queueVersionChange;
241
+ /**
242
+ * Р13: the agent of a starting session, moved forward in the background.
243
+ *
244
+ * The whole mechanism hangs off ONE fact — whether the API put a version in
245
+ * the descriptor. It does that only for machines whose «Auto-update agents»
246
+ * switch is on, so there is no second copy of the setting here to disagree
247
+ * with it, and a runner that is told nothing does nothing.
248
+ *
249
+ * Never throws and never reports anything into the session feed. A person who
250
+ * pressed «start» asked for a session; a failed background download is not
251
+ * their business, and a scary red line about one would be worse than the
252
+ * silence. Where it IS visible is the server card: the `agent_versions` frame
253
+ * at the end goes out after every outcome, so a machine that ended up without
254
+ * a working agent says so within seconds rather than at the next hourly tick.
255
+ */
256
+ private maybeAutoUpdateAgent;
257
+ /** Once a day, per Р12 — the vendor adds at most one file a day. */
258
+ private static readonly AGENT_CLEANUP_INTERVAL_MS;
259
+ /** Long enough for `hello_ack` to have told us which sessions are live. */
260
+ private static readonly AGENT_CLEANUP_FIRST_DELAY_MS;
261
+ private readonly agentCleanupTimer;
262
+ private readonly agentCleanupFirstTimer;
263
+ /**
264
+ * Throw away the Claude version files nobody will run again (Р12, §4.7).
265
+ *
266
+ * Gated on NO LIVE AGENT PROCESS — `liveSessionCount`, not `sessions.size`.
267
+ *
268
+ * The stricter reading («nothing tracked at all») was the first version of
269
+ * this gate and it made the feature dead: parked sessions sit in that map
270
+ * until somebody closes them, so on any machine actually in use the sweep
271
+ * would never once have run, and Р12 would have shipped as a no-op nobody
272
+ * noticed. A parked session is not a risk to a version file either — it has
273
+ * no process, and when it wakes it relaunches through the launcher, which is
274
+ * the one file this sweep never touches.
275
+ *
276
+ * What guards a version somebody IS running is the per-file check in
277
+ * `agent-cleanup.ts` (`/proc/<pid>/exe`), which is the plan's own mechanism —
278
+ * «проверка по списку процессов». This gate is the cheap belt beside it: it
279
+ * keeps the sweep away from the minutes when Claude is most likely to be
280
+ * mid-spawn, where the process check has a window it cannot see.
281
+ *
282
+ * Also skipped while an install holds the lock: `claude install <version>` is
283
+ * rewriting the launcher right then, and «which file is current» has no stable
284
+ * answer until it finishes.
285
+ */
286
+ private sweepOldAgentVersions;
126
287
  /** How often the seat report re-derives the truth. See the constructor. */
127
288
  private static readonly SLOTS_REPORT_INTERVAL_MS;
128
289
  private readonly slotsTimer;
@@ -372,6 +533,29 @@ export declare class Supervisor {
372
533
  * its timer runs out, and it must end in exactly the way it would have
373
534
  * ended immediately. A copy would be two behaviours one edit apart.
374
535
  */
536
+ /**
537
+ * The level gate for the context meter (#366).
538
+ *
539
+ * While a turn is open, a frame goes out only when the meter moved by at
540
+ * least one of the shared thresholds or the window itself changed; the
541
+ * newest held-back value is flushed right before `turn_end`, so the ring at
542
+ * rest is exact. Outside a turn every frame passes – there are only a handful
543
+ * (process start, a model change, and the Claude adapter's own measurement,
544
+ * which resolves AFTER `turn_end`), and each one is news.
545
+ */
546
+ private forwardContextUsage;
547
+ /** Send the value the gate was holding, if any – strictly before `turn_end`. */
548
+ private flushHeldContextUsage;
549
+ /**
550
+ * The level gate for plan usage (#366).
551
+ *
552
+ * A snapshot goes out when it is the first, when anything but `measuredAt`
553
+ * changed, when it carries a refusal (#258 – always, even twice in a row), or
554
+ * when the one last sent is older than the resend floor. The fingerprint is
555
+ * the whole payload minus the clock, so a field an adapter adds tomorrow is
556
+ * part of it without anybody remembering to list it here.
557
+ */
558
+ private forwardRateLimits;
375
559
  private completeTurn;
376
560
  /**
377
561
  * Move the session to the status a finished turn leaves it in.