@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.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { log } from './log.js';
4
+ import { CONTEXT_USAGE_MIN_DELTA_RATIO, CONTEXT_USAGE_MIN_DELTA_TOKENS, RATE_LIMITS_RESEND_INTERVAL_MS, } from './levels.js';
4
5
  import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
5
6
  import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryDelayMs, } from './adapters/error-policy.js';
6
7
  import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
@@ -17,6 +18,11 @@ import { fsView } from './fsview.js';
17
18
  import { publishFile } from './file-publish.js';
18
19
  import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
19
20
  import { selfUpdate } from './self-update.js';
21
+ import { installAgent } from './agent-install.js';
22
+ import { autoUpdateRefusal, claimAgentAutoUpdate } from './agent-auto-update.js';
23
+ import { pruneNativeClaudeVersions } from './agent-cleanup.js';
24
+ import { agentByDbValue } from './agent-registry.js';
25
+ import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
20
26
  import { rememberWorkspacePath } from './environment.js';
21
27
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
22
28
  import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
@@ -53,6 +59,16 @@ function gitPolicyOf(descriptor) {
53
59
  : {}),
54
60
  };
55
61
  }
62
+ function freshLevels() {
63
+ return {
64
+ contextSent: null,
65
+ contextHeld: null,
66
+ contextPassNext: false,
67
+ limitsSent: null,
68
+ sent: 0,
69
+ dropped: 0,
70
+ };
71
+ }
56
72
  const LAUNCH_REFUSED = { ok: false, reason: 'refused' };
57
73
  export class Supervisor {
58
74
  ws;
@@ -80,6 +96,7 @@ export class Supervisor {
80
96
  static EMPTY_TURN_SETTLE_MS = 25_000;
81
97
  /** The window actually used — the constant, or a test's own shorter one. */
82
98
  emptyTurnSettleMs;
99
+ rateLimitsResendMs;
83
100
  /** A finished session's journal is kept this long for a late reconnect. */
84
101
  static JOURNAL_TTL_MS = 72 * 3_600_000;
85
102
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -108,8 +125,20 @@ export class Supervisor {
108
125
  * than having no restore point for that one message.
109
126
  */
110
127
  repoLockDepth = new Map();
111
- /** An update is installing right now — a second one would fight it. */
112
- selfUpdateInFlight = false;
128
+ /**
129
+ * One install at a time on this machine, whoever asked for it.
130
+ *
131
+ * `self_update` and `agent_install` both end in `npm install -g` into the
132
+ * SAME npm prefix under the dedicated-user layout, so two of them at once is
133
+ * a real conflict rather than a theoretical one. The API takes a Redis lock
134
+ * before pressing either button, but that lock cannot cover an install the
135
+ * runner starts by itself (stage D) — and the runner has no Redis. This is
136
+ * the only lock that sees both.
137
+ *
138
+ * Holds the holder's name rather than a boolean, so the refusal can say
139
+ * which of the two is running.
140
+ */
141
+ installInFlight = null;
113
142
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
114
143
  verify;
115
144
  verifyReports = new VerifyReportQueue();
@@ -118,6 +147,7 @@ export class Supervisor {
118
147
  this.opts = opts;
119
148
  this.journals = opts.journals ?? new JournalStore();
120
149
  this.emptyTurnSettleMs = opts.emptyTurnSettleMs ?? Supervisor.EMPTY_TURN_SETTLE_MS;
150
+ this.rateLimitsResendMs = opts.rateLimitsResendMs ?? RATE_LIMITS_RESEND_INTERVAL_MS;
121
151
  this.verify = new VerifyRunner({
122
152
  enabled: opts.verifyEnabled !== false,
123
153
  onReport: (report) => {
@@ -148,6 +178,293 @@ export class Supervisor {
148
178
  */
149
179
  this.slotsTimer = setInterval(() => this.publishSlots(), Supervisor.SLOTS_REPORT_INTERVAL_MS);
150
180
  this.slotsTimer.unref?.();
181
+ /**
182
+ * Agent versions on a heartbeat too, for a different reason than seats.
183
+ *
184
+ * Nothing on this machine tells us when somebody updates Codex by hand, and
185
+ * the measurement behind the frame is cached for an hour anyway — so the
186
+ * tick costs a cached read most of the time and still notices a hand-made
187
+ * change within the hour. What makes a freshly connected card correct
188
+ * immediately is the same frame sent on every `hello_ack`.
189
+ */
190
+ this.agentVersionsTimer = setInterval(() => void this.publishAgentVersions('measured'), Supervisor.AGENT_VERSIONS_INTERVAL_MS);
191
+ this.agentVersionsTimer.unref?.();
192
+ /**
193
+ * Р12: the old Claude version files, swept once a day.
194
+ *
195
+ * Two timers rather than one, and the delayed first sweep is the load-bearing
196
+ * half. The gate below is «this runner is tracking no sessions», and at the
197
+ * moment a daemon starts that is trivially true — the map fills from the
198
+ * `hello_ack` reconciliation seconds later. Sweeping in that window would be
199
+ * sweeping exactly when the answer is least trustworthy, on the one machine
200
+ * shape (restarted often) where it would happen every time.
201
+ */
202
+ const cleanupFirst = opts.agentCleanupMs ?? Supervisor.AGENT_CLEANUP_FIRST_DELAY_MS;
203
+ const cleanupEvery = opts.agentCleanupMs ?? Supervisor.AGENT_CLEANUP_INTERVAL_MS;
204
+ this.agentCleanupFirstTimer = setTimeout(() => this.sweepOldAgentVersions(), cleanupFirst);
205
+ this.agentCleanupFirstTimer.unref?.();
206
+ this.agentCleanupTimer = setInterval(() => this.sweepOldAgentVersions(), cleanupEvery);
207
+ this.agentCleanupTimer.unref?.();
208
+ }
209
+ /** How often the agent versions are re-derived. See the constructor. */
210
+ static AGENT_VERSIONS_INTERVAL_MS = 60 * 60 * 1_000;
211
+ agentVersionsTimer;
212
+ /**
213
+ * Tell the API which agents this machine has and at which versions.
214
+ *
215
+ * Never throws and never blocks its caller: a machine where a probe hangs
216
+ * must still start sessions. A frame that could not be written is simply not
217
+ * sent — the next `hello_ack` or the hourly tick carries the same fact, and
218
+ * the measurement behind it is cached, so retrying is nearly free.
219
+ */
220
+ /**
221
+ * Why an install cannot start right now, in words for the person who pressed.
222
+ *
223
+ * `null` means the way is clear.
224
+ */
225
+ installBusyReason() {
226
+ if (this.installInFlight === 'self_update')
227
+ return 'An update is already running';
228
+ if (this.installInFlight === 'agent_install') {
229
+ return 'An agent is already being installed on this server';
230
+ }
231
+ return null;
232
+ }
233
+ /**
234
+ * A version change that has happened but has not reached the API yet.
235
+ *
236
+ * `ws.send` returns false when the socket is down, and an install finishing
237
+ * during a reconnect is not a rare case — it is a 300 MB download that takes
238
+ * minutes. The VERSION recovers on its own (the hourly tick and every
239
+ * `hello_ack` re-send the measurement), but `reason` and `changed` are the
240
+ * audit line itself: dropped, «why is my Codex suddenly different» has no
241
+ * answer anywhere, which is precisely what Р14 exists to prevent.
242
+ *
243
+ * Kept in memory rather than on disk on purpose. A daemon that died mid-install
244
+ * has a bigger hole than one lost line, and persisting it would risk filing an
245
+ * install that a rollback then undid.
246
+ */
247
+ pendingVersionChanges = [];
248
+ /**
249
+ * Two installs cannot overlap — the local lock sees to that — but two can
250
+ * both FINISH inside one outage, and each is a line the log owes. A queue
251
+ * rather than a slot, because the second would otherwise overwrite the first
252
+ * and the older install would be the one that vanished.
253
+ *
254
+ * Bounded: an outage long enough to strand nine installs is one where the
255
+ * missing audit lines are not the problem worth solving.
256
+ */
257
+ static MAX_PENDING_VERSION_CHANGES = 8;
258
+ async publishAgentVersions(reason, changed) {
259
+ try {
260
+ const measure = this.opts.measureAgentVersions ?? measureAgentVersions;
261
+ // After an install — ours or the auto one — the cached number is a lie,
262
+ // and a stale `at` with it would be dropped by the API's ordering guard
263
+ // together with the audit line the install owes. Forcing it here means a
264
+ // caller cannot forget to invalidate the cache first.
265
+ const measurement = await measure({ force: reason !== 'measured' });
266
+ // A plain tick carries an earlier install's news rather than replacing it.
267
+ // The API keys the audit line by the measurement's `at`, so the same
268
+ // change arriving under a later `at` is one line, not two.
269
+ //
270
+ // `changed` is only ever handed over by an install, and an install always
271
+ // calls this with `manual` or `auto` — never `measured`.
272
+ if (changed && reason !== 'measured') {
273
+ this.queueVersionChange({ reason, changed });
274
+ }
275
+ const carried = this.pendingVersionChanges[0];
276
+ const sent = this.ws.send({
277
+ type: 'agent_versions',
278
+ at: measurement.at,
279
+ reason: carried?.reason ?? reason,
280
+ agents: measurement.agents,
281
+ ...(carried ? { changed: carried.changed } : {}),
282
+ });
283
+ // Dropped only once a frame is genuinely written.
284
+ if (sent && carried) {
285
+ this.pendingVersionChanges.shift();
286
+ // One line per frame, so a backlog needs one frame each. Recursing only
287
+ // after a SUCCESSFUL send is what makes this terminate: the queue is
288
+ // shorter every time, and a failed write leaves the loop instead.
289
+ if (this.pendingVersionChanges.length > 0) {
290
+ void this.publishAgentVersions('measured');
291
+ }
292
+ }
293
+ }
294
+ catch (error) {
295
+ if (changed && reason !== 'measured')
296
+ this.queueVersionChange({ reason, changed });
297
+ log.warn('supervisor: could not report agent versions', { error: String(error) });
298
+ }
299
+ }
300
+ queueVersionChange(entry) {
301
+ if (this.pendingVersionChanges.length >= Supervisor.MAX_PENDING_VERSION_CHANGES) {
302
+ log.warn('supervisor: dropping the oldest unreported version change', {
303
+ agent: this.pendingVersionChanges[0]?.changed.agent,
304
+ });
305
+ this.pendingVersionChanges.shift();
306
+ }
307
+ this.pendingVersionChanges.push(entry);
308
+ }
309
+ /**
310
+ * Р13: the agent of a starting session, moved forward in the background.
311
+ *
312
+ * The whole mechanism hangs off ONE fact — whether the API put a version in
313
+ * the descriptor. It does that only for machines whose «Auto-update agents»
314
+ * switch is on, so there is no second copy of the setting here to disagree
315
+ * with it, and a runner that is told nothing does nothing.
316
+ *
317
+ * Never throws and never reports anything into the session feed. A person who
318
+ * pressed «start» asked for a session; a failed background download is not
319
+ * their business, and a scary red line about one would be worse than the
320
+ * silence. Where it IS visible is the server card: the `agent_versions` frame
321
+ * at the end goes out after every outcome, so a machine that ended up without
322
+ * a working agent says so within seconds rather than at the next hourly tick.
323
+ */
324
+ async maybeAutoUpdateAgent(descriptor) {
325
+ const latest = descriptor.agentLatestVersion;
326
+ // The switch. Absent = off, and that covers «the card says no», «we never
327
+ // managed to ask a registry» and «the API predates this release» alike.
328
+ if (!latest)
329
+ return;
330
+ // The machine owner's veto over installing agents at all covers this too:
331
+ // it is the same download onto the same disk, asked for by us instead of by
332
+ // a person. Refusing the button and allowing the timer would be a hole in a
333
+ // control whose entire point is that the server cannot talk past it.
334
+ if (this.opts.agentInstallEnabled === false)
335
+ return;
336
+ const runtime = agentByDbValue(descriptor.agent);
337
+ if (!runtime)
338
+ return;
339
+ try {
340
+ // The CACHED measurement, not a fresh probe.
341
+ //
342
+ // This runs on every session start, and a fresh probe is two child
343
+ // processes per start — `claude --version` plus `claude doctor` — on the
344
+ // machine somebody is trying to start work on. The cache is an hour old at
345
+ // worst and the throttle below is a day, so staleness cannot cost a missed
346
+ // update; and being wrong the other way costs nothing either, because
347
+ // `installAgent` measures again for real and answers «already at that
348
+ // version» without touching anything.
349
+ const measure = this.opts.measureAgentVersions ?? measureAgentVersions;
350
+ const snapshot = await measure();
351
+ const measured = snapshot.agents[runtime.wireKey] ?? {
352
+ version: null,
353
+ managedBy: 'unknown',
354
+ };
355
+ const refusal = autoUpdateRefusal({ runtime, measured, latest });
356
+ if (refusal) {
357
+ log.debug('agent-auto-update: leaving it alone', {
358
+ agent: runtime.wireKey,
359
+ reason: refusal,
360
+ });
361
+ return;
362
+ }
363
+ // The lock is read BEFORE the day is claimed, and the order matters: three
364
+ // sessions starting at once are the ordinary case here, and spending the
365
+ // claim on an attempt that the lock then refuses would cost the agent its
366
+ // whole day for an install that never began.
367
+ //
368
+ // Still exactly one winner: `installBusyReason`, `claimAgentAutoUpdate`
369
+ // and the assignment below are all synchronous, with no await between
370
+ // them, so no second coroutine can interleave into the gap.
371
+ if (this.installBusyReason() !== null)
372
+ return;
373
+ // Claimed before the install and written to disk: a machine that fails for
374
+ // a reason it cannot fix (no space, no network) must go quiet for the day
375
+ // instead of retrying at every session start.
376
+ if (!claimAgentAutoUpdate(runtime.wireKey))
377
+ return;
378
+ this.installInFlight = 'agent_install';
379
+ let outcome;
380
+ try {
381
+ const run = this.opts.installAgent ?? installAgent;
382
+ outcome = await run({ agent: runtime.wireKey, version: latest });
383
+ }
384
+ finally {
385
+ this.installInFlight = null;
386
+ }
387
+ if (outcome.ok) {
388
+ log.info('agent-auto-update: installed', {
389
+ agent: runtime.wireKey,
390
+ from: outcome.fromVersion,
391
+ to: outcome.toVersion,
392
+ });
393
+ }
394
+ else {
395
+ log.warn('agent-auto-update: install failed', {
396
+ agent: runtime.wireKey,
397
+ version: latest,
398
+ error: outcome.detail,
399
+ });
400
+ }
401
+ // After EVERY outcome, for the reason the button's handler gives: a failed
402
+ // install is exactly when the card is most likely to be wrong, because npm
403
+ // can unlink the old package before dying. `reason: 'auto'` is what earns
404
+ // the `dev.server.agent_auto_updated` audit line on the other side (Р14),
405
+ // and `changed` is its payload — omitted when nothing actually moved.
406
+ invalidateAgentVersions();
407
+ const moved = outcome.ok && outcome.toVersion !== undefined && outcome.toVersion !== outcome.fromVersion
408
+ ? { agent: outcome.agent, from: outcome.fromVersion, to: outcome.toVersion }
409
+ : undefined;
410
+ await this.publishAgentVersions('auto', moved);
411
+ }
412
+ catch (error) {
413
+ // `installAgent` never throws, but `measureAgent` spawns processes and the
414
+ // whole point of this path is that nothing it does can reach the session.
415
+ log.warn('agent-auto-update: gave up', {
416
+ agent: runtime.wireKey,
417
+ error: String(error instanceof Error ? error.message : error),
418
+ });
419
+ }
420
+ }
421
+ /** Once a day, per Р12 — the vendor adds at most one file a day. */
422
+ static AGENT_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000;
423
+ /** Long enough for `hello_ack` to have told us which sessions are live. */
424
+ static AGENT_CLEANUP_FIRST_DELAY_MS = 60 * 60 * 1_000;
425
+ agentCleanupTimer;
426
+ agentCleanupFirstTimer;
427
+ /**
428
+ * Throw away the Claude version files nobody will run again (Р12, §4.7).
429
+ *
430
+ * Gated on NO LIVE AGENT PROCESS — `liveSessionCount`, not `sessions.size`.
431
+ *
432
+ * The stricter reading («nothing tracked at all») was the first version of
433
+ * this gate and it made the feature dead: parked sessions sit in that map
434
+ * until somebody closes them, so on any machine actually in use the sweep
435
+ * would never once have run, and Р12 would have shipped as a no-op nobody
436
+ * noticed. A parked session is not a risk to a version file either — it has
437
+ * no process, and when it wakes it relaunches through the launcher, which is
438
+ * the one file this sweep never touches.
439
+ *
440
+ * What guards a version somebody IS running is the per-file check in
441
+ * `agent-cleanup.ts` (`/proc/<pid>/exe`), which is the plan's own mechanism —
442
+ * «проверка по списку процессов». This gate is the cheap belt beside it: it
443
+ * keeps the sweep away from the minutes when Claude is most likely to be
444
+ * mid-spawn, where the process check has a window it cannot see.
445
+ *
446
+ * Also skipped while an install holds the lock: `claude install <version>` is
447
+ * rewriting the launcher right then, and «which file is current» has no stable
448
+ * answer until it finishes.
449
+ */
450
+ sweepOldAgentVersions() {
451
+ // The empty string matches no session id, so this is «any live process».
452
+ if (this.liveSessionCount('') > 0)
453
+ return;
454
+ if (this.installBusyReason() !== null)
455
+ return;
456
+ try {
457
+ const swept = (this.opts.pruneAgentVersions ?? pruneNativeClaudeVersions)();
458
+ if (swept.removed.length > 0) {
459
+ log.info('agent-cleanup: removed old Claude versions', {
460
+ removed: swept.removed,
461
+ freedMb: Math.round(swept.freedBytes / 1024 / 1024),
462
+ });
463
+ }
464
+ }
465
+ catch (error) {
466
+ log.warn('agent-cleanup: sweep failed', { error: String(error) });
467
+ }
151
468
  }
152
469
  /** How often the seat report re-derives the truth. See the constructor. */
153
470
  static SLOTS_REPORT_INTERVAL_MS = 15_000;
@@ -253,6 +570,10 @@ export class Supervisor {
253
570
  // A build that finished while the socket was down has its verdict
254
571
  // sitting on disk. This is the moment it can be delivered.
255
572
  this.flushVerifyReports();
573
+ // The card of a machine that just came back must not show yesterday's
574
+ // agent versions. Deliberately not awaited: this same frame carries the
575
+ // session reconciliation, and a slow `codex doctor` must not hold it up.
576
+ void this.publishAgentVersions('measured');
256
577
  break;
257
578
  case 'verify_report_ack':
258
579
  this.verifyReports.ack(frame.runId);
@@ -417,6 +738,17 @@ export class Supervisor {
417
738
  });
418
739
  return;
419
740
  }
741
+ /**
742
+ * Р13: move THIS session's agent forward, behind this session's back.
743
+ *
744
+ * Deliberately not awaited, and started before the workspace is prepared:
745
+ * the person asked for a session, and a 320 MB download must not be
746
+ * something they wait through. This session then finishes on the file it
747
+ * has already opened — replacing a binary does not disturb a process that
748
+ * holds it — and the new version takes effect for the next one. That is
749
+ * exactly the promise the switch's caption makes on the card.
750
+ */
751
+ void this.maybeAutoUpdateAgent(descriptor);
420
752
  const running = {
421
753
  descriptor,
422
754
  journal: this.journals.open(descriptor.id),
@@ -424,6 +756,7 @@ export class Supervisor {
424
756
  lastReported: descriptor.status,
425
757
  costUsd: descriptor.costUsd,
426
758
  costBaseUsd: descriptor.costUsd,
759
+ levels: freshLevels(),
427
760
  stopRequested: false,
428
761
  parkRequested: false,
429
762
  pendingMessages: [],
@@ -1378,12 +1711,101 @@ export class Supervisor {
1378
1711
  * its timer runs out, and it must end in exactly the way it would have
1379
1712
  * ended immediately. A copy would be two behaviours one edit apart.
1380
1713
  */
1714
+ /**
1715
+ * The level gate for the context meter (#366).
1716
+ *
1717
+ * While a turn is open, a frame goes out only when the meter moved by at
1718
+ * least one of the shared thresholds or the window itself changed; the
1719
+ * newest held-back value is flushed right before `turn_end`, so the ring at
1720
+ * rest is exact. Outside a turn every frame passes – there are only a handful
1721
+ * (process start, a model change, and the Claude adapter's own measurement,
1722
+ * which resolves AFTER `turn_end`), and each one is news.
1723
+ */
1724
+ forwardContextUsage(running, usedTokens, maxTokens) {
1725
+ const levels = running.levels;
1726
+ const frame = { usedTokens, maxTokens };
1727
+ const last = levels.contextSent;
1728
+ const turnOpen = running.lastReported === 'RUNNING';
1729
+ const moved = last === null ? Number.POSITIVE_INFINITY : Math.abs(usedTokens - last.usedTokens);
1730
+ const pass = last === null ||
1731
+ last.maxTokens !== maxTokens ||
1732
+ levels.contextPassNext ||
1733
+ !turnOpen ||
1734
+ moved >= CONTEXT_USAGE_MIN_DELTA_TOKENS ||
1735
+ moved / maxTokens >= CONTEXT_USAGE_MIN_DELTA_RATIO;
1736
+ // Only the newest value is worth keeping; whatever it replaces is gone.
1737
+ if (levels.contextHeld !== null)
1738
+ levels.dropped += 1;
1739
+ if (!pass) {
1740
+ levels.contextHeld = frame;
1741
+ return;
1742
+ }
1743
+ levels.contextHeld = null;
1744
+ levels.contextPassNext = false;
1745
+ levels.contextSent = frame;
1746
+ levels.sent += 1;
1747
+ this.sendEvent(running, 'context_usage', frame);
1748
+ }
1749
+ /** Send the value the gate was holding, if any – strictly before `turn_end`. */
1750
+ flushHeldContextUsage(running) {
1751
+ const levels = running.levels;
1752
+ const held = levels.contextHeld;
1753
+ if (held === null)
1754
+ return;
1755
+ levels.contextHeld = null;
1756
+ // A held value identical to the last one sent is not news – flushing it
1757
+ // would add a frame to every turn and give back half the saving.
1758
+ const sent = levels.contextSent;
1759
+ if (sent !== null && sent.usedTokens === held.usedTokens && sent.maxTokens === held.maxTokens) {
1760
+ return;
1761
+ }
1762
+ levels.contextSent = held;
1763
+ levels.sent += 1;
1764
+ this.sendEvent(running, 'context_usage', held);
1765
+ }
1766
+ /**
1767
+ * The level gate for plan usage (#366).
1768
+ *
1769
+ * A snapshot goes out when it is the first, when anything but `measuredAt`
1770
+ * changed, when it carries a refusal (#258 – always, even twice in a row), or
1771
+ * when the one last sent is older than the resend floor. The fingerprint is
1772
+ * the whole payload minus the clock, so a field an adapter adds tomorrow is
1773
+ * part of it without anybody remembering to list it here.
1774
+ */
1775
+ forwardRateLimits(running, limits) {
1776
+ const levels = running.levels;
1777
+ const print = JSON.stringify({ ...limits, measuredAt: undefined });
1778
+ const now = Date.now();
1779
+ const last = levels.limitsSent;
1780
+ const pass = last === null ||
1781
+ Boolean(limits.blocked) ||
1782
+ last.print !== print ||
1783
+ now - last.at >= this.rateLimitsResendMs;
1784
+ if (!pass) {
1785
+ levels.dropped += 1;
1786
+ return;
1787
+ }
1788
+ levels.limitsSent = { print, at: now };
1789
+ levels.sent += 1;
1790
+ this.sendEvent(running, 'rate_limits', { ...limits });
1791
+ }
1381
1792
  completeTurn(running, descriptor, event) {
1793
+ // #366: the ring at rest must be exact – the value the gate held back
1794
+ // during the turn goes out first, so it is older than `turn_end` by seq.
1795
+ this.flushHeldContextUsage(running);
1382
1796
  this.sendEvent(running, 'turn_end', {
1383
1797
  ok: event.ok,
1384
1798
  errorMessage: event.errorMessage,
1385
1799
  ...(event.aborted ? { aborted: true } : {}),
1386
1800
  });
1801
+ // …and whatever the adapter measures once the turn is over (Claude does,
1802
+ // asynchronously) is news, not a step of the same turn.
1803
+ running.levels.contextPassNext = true;
1804
+ log.info('supervisor: levels', {
1805
+ sessionId: descriptor.id,
1806
+ sent: running.levels.sent,
1807
+ dropped: running.levels.dropped,
1808
+ });
1387
1809
  // The session is already on its way out with a status that MEANS
1388
1810
  // something — a spent budget, a Stop, a teardown. A turn ending inside
1389
1811
  // that window is a consequence of it, and letting the line below
@@ -1902,17 +2324,17 @@ export class Supervisor {
1902
2324
  });
1903
2325
  return;
1904
2326
  case 'context_usage':
1905
- this.sendEvent(running, 'context_usage', {
1906
- usedTokens: event.usedTokens,
1907
- maxTokens: event.maxTokens,
1908
- });
2327
+ // #366. A LEVEL: only a value that moved is worth a frame. The gate
2328
+ // decides before `sendEvent`, so a dropped frame never spends a seq.
2329
+ this.forwardContextUsage(running, event.usedTokens, event.maxTokens);
1909
2330
  return;
1910
2331
  // #279. A LEVEL signal like `agent_tasks`: every frame carries the whole
1911
- // snapshot, so the API stores what arrived rather than merging, and a
2332
+ // snapshot, so the API keeps what arrived rather than merging, and a
1912
2333
  // dropped frame costs freshness, never correctness. Account-wide, not
1913
2334
  // session-wide — the API files it under the SERVER, not this session.
2335
+ // #366: gated the same way as the context meter, see `forwardRateLimits`.
1914
2336
  case 'rate_limits':
1915
- this.sendEvent(running, 'rate_limits', { ...event.limits });
2337
+ this.forwardRateLimits(running, event.limits);
1916
2338
  return;
1917
2339
  case 'agent_tasks': {
1918
2340
  // Ticket #113. A LEVEL signal: every frame carries the whole live set,
@@ -3125,6 +3547,7 @@ export class Supervisor {
3125
3547
  lastReported: descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT',
3126
3548
  costUsd: descriptor.costUsd,
3127
3549
  costBaseUsd: descriptor.costUsd,
3550
+ levels: freshLevels(),
3128
3551
  stopRequested: false,
3129
3552
  parkRequested: false,
3130
3553
  pendingMessages: [],
@@ -4109,10 +4532,10 @@ export class Supervisor {
4109
4532
  // Second line of defence behind the API's per-server lock: two
4110
4533
  // overlapping `npm install -g` into the same prefix is not something
4111
4534
  // to leave to chance on someone else's machine.
4112
- if (this.selfUpdateInFlight) {
4113
- return void reply({ ok: false, error: 'An update is already running' });
4114
- }
4115
- this.selfUpdateInFlight = true;
4535
+ const busy = this.installBusyReason();
4536
+ if (busy)
4537
+ return void reply({ ok: false, error: busy });
4538
+ this.installInFlight = 'self_update';
4116
4539
  const run = this.opts.selfUpdate ?? selfUpdate;
4117
4540
  let outcome;
4118
4541
  try {
@@ -4120,7 +4543,7 @@ export class Supervisor {
4120
4543
  }
4121
4544
  catch (error) {
4122
4545
  // A failed update must be retryable without restarting the daemon.
4123
- this.selfUpdateInFlight = false;
4546
+ this.installInFlight = null;
4124
4547
  throw error;
4125
4548
  }
4126
4549
  // Deliberately NOT cleared on success: the process only exits ~1.5s
@@ -4128,7 +4551,7 @@ export class Supervisor {
4128
4551
  // API releases its own lock the moment the reply lands. Clearing here
4129
4552
  // left a window in which a second press started an `npm install -g`
4130
4553
  // that systemd then killed mid-flight (QA-103 MINOR-8).
4131
- this.selfUpdateInFlight = outcome.ok && outcome.restart;
4554
+ this.installInFlight = outcome.ok && outcome.restart ? 'self_update' : null;
4132
4555
  reply({
4133
4556
  ok: outcome.ok,
4134
4557
  result: outcome,
@@ -4138,6 +4561,69 @@ export class Supervisor {
4138
4561
  this.opts.onRestartRequested?.(outcome);
4139
4562
  return;
4140
4563
  }
4564
+ case 'agent_install': {
4565
+ // The veto is enforced here as well as withheld from `hello`: an API
4566
+ // that has not noticed still must not install anything on a machine
4567
+ // whose owner said no.
4568
+ if (this.opts.agentInstallEnabled === false) {
4569
+ return void reply({
4570
+ ok: false,
4571
+ error: 'Installing agents is switched off on this server ([agents] install_enabled = false)',
4572
+ });
4573
+ }
4574
+ // Only these three ever cross the wire. The package name, the
4575
+ // installer URL and the command come from the registry compiled into
4576
+ // this build — a server that could name them would be a server that
4577
+ // could run anything here (§6 of the plan).
4578
+ const agent = str(frame.args?.['agent']);
4579
+ if (!agent)
4580
+ return void reply({ ok: false, error: 'agent is required' });
4581
+ const version = str(frame.args?.['version']);
4582
+ if (!version)
4583
+ return void reply({ ok: false, error: 'version is required' });
4584
+ const allowDowngrade = frame.args?.['allowDowngrade'] === true;
4585
+ const installBusy = this.installBusyReason();
4586
+ if (installBusy)
4587
+ return void reply({ ok: false, error: installBusy });
4588
+ this.installInFlight = 'agent_install';
4589
+ let installed;
4590
+ try {
4591
+ const runInstall = this.opts.installAgent ?? installAgent;
4592
+ installed = await runInstall({
4593
+ agent,
4594
+ version,
4595
+ ...(allowDowngrade ? { allowDowngrade: true } : {}),
4596
+ });
4597
+ }
4598
+ finally {
4599
+ // Always cleared, unlike `self_update`: an agent install does not
4600
+ // restart the daemon, so a holder left behind would block every
4601
+ // later install for the life of the process.
4602
+ this.installInFlight = null;
4603
+ }
4604
+ reply({
4605
+ ok: installed.ok,
4606
+ result: installed,
4607
+ ...(installed.ok ? {} : { error: installed.detail ?? 'Install failed' }),
4608
+ });
4609
+ // Re-measure after EVERY terminal outcome, not only after a success.
4610
+ // A failed install is exactly when the card is most likely to be
4611
+ // wrong: npm can unlink the old global package before dying, and a
4612
+ // rollback can fail too — and the hour-long measurement cache would
4613
+ // then keep re-publishing «0.150.0, up to date» about a machine that
4614
+ // no longer has the agent at all, on every reconnect.
4615
+ //
4616
+ // `changed` stays conditional: it is the audit payload (Р14), and
4617
+ // «updated from 1.2.3 to 1.2.3» would be a line about nothing.
4618
+ invalidateAgentVersions();
4619
+ const moved = installed.ok &&
4620
+ installed.toVersion !== undefined &&
4621
+ installed.toVersion !== installed.fromVersion
4622
+ ? { agent: installed.agent, from: installed.fromVersion, to: installed.toVersion }
4623
+ : undefined;
4624
+ void this.publishAgentVersions('manual', moved);
4625
+ return;
4626
+ }
4141
4627
  /**
4142
4628
  * «Would this file reach the agent, and how big is it» (ticket #192).
4143
4629
  *
@@ -4522,6 +5008,9 @@ export class Supervisor {
4522
5008
  /** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
4523
5009
  shutdown() {
4524
5010
  clearInterval(this.slotsTimer);
5011
+ clearInterval(this.agentVersionsTimer);
5012
+ clearTimeout(this.agentCleanupFirstTimer);
5013
+ clearInterval(this.agentCleanupTimer);
4525
5014
  this.authRelay.cancel();
4526
5015
  for (const running of this.sessions.values()) {
4527
5016
  this.clearBudgetTimers(running);
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.46.1";
1
+ export declare const RUNNER_VERSION = "0.48.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.46.1';
2
+ export const RUNNER_VERSION = '0.48.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.46.1",
3
+ "version": "0.48.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",