@bridge4dev/runner 0.45.1 → 0.46.1

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.
@@ -14,6 +14,7 @@ import { VerifyRunner, runOneOffCommand, } from './verify.js';
14
14
  import { VerifyReportQueue } from './verify-queue.js';
15
15
  import { applySession, gitBranches, gitCommit, gitDiff, gitLog, gitPush, gitRefs, gitShow, gitStatus, revertApply, gitStage, gitUnstage, gitDiscard, gitPull, gitMergeAbort, updateFromBase, workspaceState, } from './gitops.js';
16
16
  import { fsView } from './fsview.js';
17
+ import { publishFile } from './file-publish.js';
17
18
  import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
18
19
  import { selfUpdate } from './self-update.js';
19
20
  import { rememberWorkspacePath } from './environment.js';
@@ -131,7 +132,26 @@ export class Supervisor {
131
132
  ws.on('frame', (frame) => {
132
133
  void this.onFrame(frame).catch((error) => log.error('supervisor: frame handler failed', { type: frame.type, error: String(error) }));
133
134
  });
135
+ /**
136
+ * The seat report is on a heartbeat, not only on the events that change it.
137
+ *
138
+ * Deliberate, and the lesson of the bug this whole frame exists for: seat
139
+ * state lives in three mutable fields on each entry (`session`,
140
+ * `stopRequested`, `parkRequested`), so there is no single mutation to hook
141
+ * — and «every call site remembers to tell the API» is exactly the rule
142
+ * that had already been broken in three places. A tick that re-derives the
143
+ * truth cannot be forgotten by a future call site.
144
+ *
145
+ * Nearly free: `publishSlots` fingerprints what it last sent and returns
146
+ * without touching the socket when nothing moved, so a quiet runner sends
147
+ * one frame per change and none in between.
148
+ */
149
+ this.slotsTimer = setInterval(() => this.publishSlots(), Supervisor.SLOTS_REPORT_INTERVAL_MS);
150
+ this.slotsTimer.unref?.();
134
151
  }
152
+ /** How often the seat report re-derives the truth. See the constructor. */
153
+ static SLOTS_REPORT_INTERVAL_MS = 15_000;
154
+ slotsTimer;
135
155
  /**
136
156
  * Push every unacked verdict at the API.
137
157
  *
@@ -153,11 +173,83 @@ export class Supervisor {
153
173
  get activeSessionIds() {
154
174
  return [...this.sessions.keys()];
155
175
  }
176
+ /** The last set of seat-holders we told the API about, to send only changes. */
177
+ lastPublishedSlots = '';
178
+ /**
179
+ * Tell the API which sessions are actually holding a seat here (0.46.0).
180
+ *
181
+ * The seat count is a property of THIS process, and until now the API could
182
+ * only guess it from its own rows. The guess was wrong whenever a session
183
+ * ended on paper without releasing its entry, and wrong in the direction that
184
+ * costs a person their session: the dashboard offers a seat, the create
185
+ * passes, this runner refuses, the session dies in fifty milliseconds.
186
+ *
187
+ * Called from `reportStatus` — the choke point every seat change already
188
+ * passes through — and from the interval in the constructor, which is the net
189
+ * under any path that forgets. Both are safe to call as often as they like:
190
+ * this compares what it is about to say against what it last said and returns
191
+ * without touching the socket when nothing moved.
192
+ *
193
+ * Two lists, because two different questions are being asked and answering
194
+ * both with one number is wrong in both directions:
195
+ *
196
+ * - `sessionIds` — everything still tracked here, parked or not. This is the
197
+ * reconciliation list, and it must be generous: a session the API has
198
+ * buried needs laying to rest whatever state it is in on this side.
199
+ * - `blockingIds` — what a new session would really have to wait for.
200
+ * `ensureCapacity` parks an idle REVIEW or WAITING_INPUT session to make
201
+ * room, so those seats are available on demand.
202
+ *
203
+ * Reporting the second as the first would have the API stop live REVIEW
204
+ * sessions; reporting the first as the second would call a machine full while
205
+ * it had room. Both were caught by the independent QA review of this change.
206
+ */
207
+ publishSlots() {
208
+ const tracked = [];
209
+ const blocking = [];
210
+ for (const entry of this.sessions.values()) {
211
+ const id = entry.descriptor.id;
212
+ tracked.push(id);
213
+ if (entry.stopRequested || entry.parkRequested || !entry.session)
214
+ continue;
215
+ // `ensureCapacity` would park this one to make room, so its seat is
216
+ // available on demand and does not refuse anybody. Counting it as taken
217
+ // reported a machine with three finished-turn sessions as full while it
218
+ // would happily have started a fourth (independent QA, 31.08.2026).
219
+ if (this.isParkable(entry))
220
+ continue;
221
+ blocking.push(id);
222
+ }
223
+ tracked.sort();
224
+ blocking.sort();
225
+ const limit = this.maxSessions;
226
+ const fingerprint = `${limit}:${tracked.join(',')}|${blocking.join(',')}`;
227
+ if (fingerprint === this.lastPublishedSlots)
228
+ return;
229
+ // Recorded only when it actually went out. A frame dropped because the
230
+ // socket was down must not be remembered as sent — otherwise the seat
231
+ // report would go quiet until the set changed again, which on an idle
232
+ // machine is exactly never.
233
+ if (this.ws.send({
234
+ type: 'session_slots',
235
+ sessionIds: tracked,
236
+ blockingIds: blocking,
237
+ maxSessions: limit,
238
+ })) {
239
+ this.lastPublishedSlots = fingerprint;
240
+ }
241
+ }
156
242
  async onFrame(frame) {
157
243
  switch (frame.type) {
158
244
  case 'hello_ack':
245
+ // A new connection knows nothing about the seats we reported to the
246
+ // last one — the API keeps that beside the socket, not in the database,
247
+ // because it is only true while the socket is. Forget what we told the
248
+ // old one so the first tick after this reconnect actually sends.
249
+ this.lastPublishedSlots = '';
159
250
  this.setMaxSessions(frame.maxSessions);
160
251
  await this.reconcile(frame.sessions);
252
+ this.publishSlots();
161
253
  // A build that finished while the socket was down has its verdict
162
254
  // sitting on disk. This is the moment it can be delivered.
163
255
  this.flushVerifyReports();
@@ -1111,6 +1203,11 @@ export class Supervisor {
1111
1203
  isTerminal(running.lastReported)) {
1112
1204
  this.journals.closeAndDelete(descriptor.id);
1113
1205
  }
1206
+ // The map just lost an entry, and this is the one place where that happens
1207
+ // WITHOUT a status frame to carry the news: the terminal status was already
1208
+ // reported before the process stream ended. Without this the API goes on
1209
+ // believing the runner tracks a session that is gone, until the next tick.
1210
+ this.publishSlots();
1114
1211
  // A resume arrived while this life was winding down — start it now that the
1115
1212
  // map entry is gone. One place, after every removal path above.
1116
1213
  if (running.pendingRestart && !this.sessions.has(descriptor.id)) {
@@ -1356,7 +1453,12 @@ export class Supervisor {
1356
1453
  });
1357
1454
  }
1358
1455
  else {
1359
- this.reportStatus(descriptor.id, 'FAILED', {
1456
+ // The turn failed for a reason nothing above could soften: not a plan
1457
+ // refusal, not a retryable provider fault. FAILED is terminal, so the
1458
+ // process and the seat go with it — see `finishSession`. Reporting the
1459
+ // status alone is what left this machine holding a seat for a session
1460
+ // the API had already buried (31.08.2026).
1461
+ this.finishSession(running, 'FAILED', {
1360
1462
  costUsd: running.costUsd,
1361
1463
  activeMs: Supervisor.spentMs(running),
1362
1464
  errorMessage: event.errorMessage ?? 'Agent turn failed',
@@ -1620,7 +1722,43 @@ export class Supervisor {
1620
1722
  // No `lastPrompt` guard: a free CHAT session boots with an empty prompt
1621
1723
  // and is exactly the case that hits an auth failure at startup, so
1622
1724
  // requiring one excluded the sessions that need the retry most.
1623
- if (isAuthCode(event.code) && !running.authRetryDone) {
1725
+ // FIRST, and before any decision about retrying (#365). This is the only
1726
+ // authority on a login the credentials file cannot see through — a
1727
+ // provider-side revocation leaves the file looking perfectly healthy,
1728
+ // and `codex login status` never leaves the machine — so it is the one
1729
+ // thing that turns the server panel from «signed in» to «sign in
1730
+ // needed» (#121).
1731
+ //
1732
+ // It used to sit BELOW the retry, i.e. it wanted a SECOND refusal. On
1733
+ // Codex a second refusal never came: the first one was swallowed in
1734
+ // favour of a relaunch that could not happen, so the panel kept showing
1735
+ // a dead login as healthy — and the owner spent an hour signing into
1736
+ // the wrong machine while this line waited for its turn.
1737
+ //
1738
+ // `auth_expired` ONLY, and not every auth code. `auth_missing` means the
1739
+ // credential was locally absent — nothing was refused — and the panel's
1740
+ // own probe already answers «not signed in» for that machine without
1741
+ // any help from here. Marking it would only add a way to be wrong: the
1742
+ // mark outranks a healthy file for fifteen minutes, so a home that was
1743
+ // repaired a second later would keep a red panel over a working login.
1744
+ if (event.code === 'auth_expired') {
1745
+ const refused = relayAgent(String(descriptor.agent).toLowerCase());
1746
+ if (refused)
1747
+ noteAgentAuthFailure(refused);
1748
+ }
1749
+ // Only `auth_missing` is worth a relaunch, and the distinction is the
1750
+ // adapter's own (`probeAuth`), not a guess from prose.
1751
+ //
1752
+ // - `auth_missing` — there is no credential where we look. Usually the
1753
+ // link into the shared store went out from under a live daemon, and
1754
+ // the adapter re-asserts its home on start, so one relaunch really
1755
+ // does fix it.
1756
+ // - `auth_expired` — the provider refused a credential that IS there.
1757
+ // A repeat presents the same dead token and gets the same answer,
1758
+ // while the person waits for a retry that was never going to work.
1759
+ // `error-policy.ts` has said exactly this about Claude since 0.44.1
1760
+ // («A person has to sign in; a repeat cannot») — the two now agree.
1761
+ if (event.code === 'auth_missing' && !running.authRetryDone) {
1624
1762
  running.authRetry = { prompt: running.lastPrompt };
1625
1763
  this.sendEvent(running, 'notice', {
1626
1764
  level: 'warn',
@@ -1628,22 +1766,18 @@ export class Supervisor {
1628
1766
  });
1629
1767
  return;
1630
1768
  }
1631
- // The retry is spent and the agent is still refused — this is the only
1632
- // authority on a login the credentials file cannot see through (a
1633
- // provider-side revocation leaves the file looking perfectly healthy).
1634
- // The panel is told from here, not from a guess (#121).
1635
- if (isAuthCode(event.code)) {
1636
- const refused = relayAgent(String(descriptor.agent).toLowerCase());
1637
- if (refused)
1638
- noteAgentAuthFailure(refused);
1639
- }
1640
1769
  // Forward the code: the API stores the payload as-is, so the dashboard
1641
1770
  // can offer "Sign in" instead of a dead error card.
1642
1771
  this.sendEvent(running, 'error', {
1643
1772
  message: event.message,
1644
1773
  ...(event.code ? { code: event.code } : {}),
1645
1774
  });
1646
- this.reportStatus(descriptor.id, 'FAILED', {
1775
+ // Through `finishSession`, not `reportStatus`: this branch is the one
1776
+ // the 31.08.2026 incident was traced to. It filed the session FAILED
1777
+ // and returned, and the agent process went on streaming for another
1778
+ // seventy-one seconds — holding a seat on a machine whose database said
1779
+ // it was free.
1780
+ this.finishSession(running, 'FAILED', {
1647
1781
  costUsd: running.costUsd,
1648
1782
  activeMs: Supervisor.spentMs(running),
1649
1783
  errorMessage: event.message,
@@ -2185,9 +2319,11 @@ export class Supervisor {
2185
2319
  }
2186
2320
  if (!running.session) {
2187
2321
  // The process died while we waited. Relaunching is `launchAgent`'s job and
2188
- // it needs a prompt; without one there is nothing honest to do here.
2189
- this.clearApiRetry(running);
2190
- this.reportStatus(running.descriptor.id, 'FAILED', {
2322
+ // it needs a prompt; without one there is nothing honest to do here — so
2323
+ // the session ends, entry and all (`finishSession`). It used to end only
2324
+ // on paper, and the row left behind then swallowed the next
2325
+ // `session_start` for the same id.
2326
+ this.finishSession(running, 'FAILED', {
2191
2327
  errorMessage: 'The agent process ended while waiting to retry',
2192
2328
  });
2193
2329
  return;
@@ -2708,6 +2844,65 @@ export class Supervisor {
2708
2844
  });
2709
2845
  }
2710
2846
  }
2847
+ /**
2848
+ * Let go of a session the API says no longer exists, so its files can go.
2849
+ *
2850
+ * `purge_session` and `clean` used to refuse outright while ANY entry for the
2851
+ * id was in the map. That reads as caution and behaves as a deadlock: the
2852
+ * frame arrives precisely because the API has already deleted the row, so
2853
+ * nothing will ever come along to release the entry, the worktree stays on
2854
+ * disk, and the retry runs until the purge ages out of its window. On
2855
+ * production one such tombstone was still failing two hours later, on a
2856
+ * machine that was refusing new sessions for exactly the seat it held.
2857
+ *
2858
+ * The API is the authority on whether a session exists. If it says gone, the
2859
+ * honest answer is to end it here too — the same teardown a reconnect
2860
+ * performs for a session missing from `hello_ack` — and only then report that
2861
+ * we could not finish, if the process really will not go.
2862
+ *
2863
+ * Returns true when nothing is holding the id any more.
2864
+ */
2865
+ async releaseForCleanup(sessionId) {
2866
+ const running = this.sessions.get(sessionId);
2867
+ if (!running)
2868
+ return true;
2869
+ log.warn('supervisor: cleanup asked for a session this runner still held', { sessionId });
2870
+ if (!running.stopRequested) {
2871
+ running.stopRequested = true;
2872
+ this.clearBudgetTimers(running);
2873
+ this.clearApiRetry(running);
2874
+ this.clearEmptyTurn(running);
2875
+ this.withdrawOpenQuestions(running, 'session_stopped');
2876
+ // No status report: the row is already gone on the other side, so a frame
2877
+ // about it would be answered with «Unknown session» and nothing else.
2878
+ running.session?.stop('session_stopped');
2879
+ if (!running.session)
2880
+ this.sessions.delete(sessionId);
2881
+ this.drainSessionsWaitingForCapacity();
2882
+ this.publishSlots();
2883
+ }
2884
+ // `pumpEvents` removes the entry when the process stream actually ends, and
2885
+ // that is asynchronous. Bounded wait rather than an unbounded one: the
2886
+ // caller is a command with its own timeout, and a purge that has to happen
2887
+ // on the next drain instead of this one costs a retry, not the worktree.
2888
+ for (let waited = 0; waited < Supervisor.CLEANUP_RELEASE_MS; waited += 100) {
2889
+ if (!this.sessions.has(sessionId))
2890
+ return true;
2891
+ await new Promise((resolve) => setTimeout(resolve, 100));
2892
+ }
2893
+ return !this.sessions.has(sessionId);
2894
+ }
2895
+ /**
2896
+ * How long cleanup waits for a stopped agent process to actually be gone.
2897
+ *
2898
+ * Deliberately a small slice of the API's ten-second command budget: removing
2899
+ * the worktree still has to happen after this, under the repository lock, and
2900
+ * a purge that times out on the wire is reported as a failure even when it
2901
+ * succeeded here. Three seconds is enough for an ordinary exit; anything
2902
+ * slower is better answered honestly, so the drain comes back and finds the
2903
+ * entry already gone.
2904
+ */
2905
+ static CLEANUP_RELEASE_MS = 3_000;
2711
2906
  stopSession(sessionId) {
2712
2907
  const running = this.sessions.get(sessionId);
2713
2908
  if (!running)
@@ -2726,6 +2921,59 @@ export class Supervisor {
2726
2921
  this.journals.closeAndDelete(sessionId);
2727
2922
  }
2728
2923
  }
2924
+ this.publishSlots();
2925
+ }
2926
+ /**
2927
+ * The ONE way a session ends on this runner.
2928
+ *
2929
+ * A terminal status is two facts, not one: the API is told the session is
2930
+ * over, AND this machine stops holding a seat for it. They used to be
2931
+ * separate lines at eight call sites, and three of them wrote only the first
2932
+ * — `settleTurnStatus`'s failed-turn branch, `forwardEvent`'s `case 'error'`
2933
+ * and `runApiRetry`. Each left a `RunningSession` in the map with a live
2934
+ * `session` handle and `stopRequested === false`, which is precisely what
2935
+ * `liveSessionCount` counts. The seat was then held for a session the API had
2936
+ * already buried, until the next reconnect — weeks, on a healthy runner.
2937
+ *
2938
+ * On production (31.08.2026) that arithmetic refused a fourth session on a
2939
+ * machine whose database said one was running, and the dashboard had offered
2940
+ * the seat a moment earlier. `launchCrashed` had the rule right all along
2941
+ * («an entry left in the map would hold one of the runner's few slots»); it
2942
+ * just could not be the only place that knew it.
2943
+ *
2944
+ * Deliberately NOT folded into `reportStatus`: that method is also how a
2945
+ * session reaches REVIEW, WAITING_INPUT and RUNNING, and a teardown hidden
2946
+ * inside it would be invisible at exactly the call sites that must not tear
2947
+ * anything down. The name says what it does.
2948
+ */
2949
+ finishSession(running, status, extra) {
2950
+ const sessionId = running.descriptor.id;
2951
+ // Before the report, so a frame that races the teardown cannot re-arm
2952
+ // anything the teardown is in the middle of taking away.
2953
+ running.stopRequested = true;
2954
+ this.clearBudgetTimers(running);
2955
+ this.clearApiRetry(running);
2956
+ this.clearEmptyTurn(running);
2957
+ // The cards die with the turn: a question nobody can answer any more is a
2958
+ // worse thing to leave on screen than no question at all.
2959
+ this.withdrawOpenQuestions(running, 'session_stopped');
2960
+ this.reportStatus(sessionId, status, extra);
2961
+ if (running.session) {
2962
+ // `pumpEvents` deletes the entry when the stream ends, and its
2963
+ // `stopRequested` branch skips the STOPPED frame because `lastReported`
2964
+ // is already terminal. Until then the seat is free anyway: `stopRequested`
2965
+ // takes the entry out of `liveSessionCount` on this very line.
2966
+ running.session.stop('session_stopped');
2967
+ }
2968
+ else {
2969
+ this.sessions.delete(sessionId);
2970
+ if (this.ws.connected && running.journal.unacked().length === 0) {
2971
+ this.journals.closeAndDelete(sessionId);
2972
+ }
2973
+ }
2974
+ // A freed seat is only useful to whoever is waiting for one.
2975
+ this.drainSessionsWaitingForCapacity();
2976
+ this.publishSlots();
2729
2977
  }
2730
2978
  // ─── Reconciliation (hello_ack) ────────────────────────────────────
2731
2979
  async reconcile(descriptors) {
@@ -2977,11 +3225,30 @@ export class Supervisor {
2977
3225
  });
2978
3226
  }
2979
3227
  }
2980
- // REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
2981
- // mid-turn statuses are downgraded to "waiting for the user".
2982
- if (descriptor.status !== 'REVIEW') {
2983
- this.reportStatus(descriptor.id, 'WAITING_INPUT', {});
2984
- }
3228
+ /**
3229
+ * REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
3230
+ * mid-turn statuses are downgraded to "waiting for the user".
3231
+ *
3232
+ * REVIEW is now REPORTED rather than skipped — ticket #356, and the
3233
+ * news in that frame is not the status, which has not moved. It is
3234
+ * the ZERO riding on it.
3235
+ *
3236
+ * `running.backgroundTasks` is seeded to 0 above, because a runner
3237
+ * restart takes every subagent with it. But the number the API holds
3238
+ * is written by the runner ALONE, and it is only ever written by a
3239
+ * frame that carries the field — which `reportStatus` attaches only
3240
+ * for a tracked session. Skipping the report here left the API
3241
+ * believing whatever the dead process last said, for good: the badge
3242
+ * would keep saying «Agents», the Inbox would keep hiding the card,
3243
+ * and no later frame would ever correct either. `setBackgroundTasks`
3244
+ * cannot help — it compares against the in-memory 0 and returns
3245
+ * early, having nothing to announce.
3246
+ *
3247
+ * Same-status frames are legal (`canDevSessionTransition` answers
3248
+ * `true` for `from === to`), so this costs one no-op write and buys
3249
+ * back a session that would otherwise have been lost.
3250
+ */
3251
+ this.reportStatus(descriptor.id, statusForReport(running), {});
2985
3252
  this.flushPendingMessages(running);
2986
3253
  }
2987
3254
  else if (descriptor.status === 'WAITING_INPUT') {
@@ -3072,8 +3339,22 @@ export class Supervisor {
3072
3339
  const sessionId = frame.sessionId;
3073
3340
  if (!sessionId)
3074
3341
  return void reply({ ok: false, error: 'sessionId is required' });
3075
- const running = this.sessions.get(sessionId);
3076
- if (running)
3342
+ /**
3343
+ * `clean` keeps the flat refusal. `purge_session` does not. The
3344
+ * difference is who is allowed to ask.
3345
+ *
3346
+ * `purge_session` only ever arrives after the API has deleted the
3347
+ * row, so «wait for it to wind down» waits for something that will
3348
+ * never happen — that is the deadlock `releaseForCleanup` exists to
3349
+ * break. `clean` is a user-callable command
3350
+ * (`POST /dev/sessions/:id/commands`, any dev member) and the API
3351
+ * checks no status before relaying it. This refusal is the ONLY guard
3352
+ * on that route: releasing here would stop a mid-turn agent and then
3353
+ * `git worktree remove --force` its worktree, destroying every
3354
+ * uncommitted change in it. Caught by the independent QA review of
3355
+ * this change, by two reviewers separately.
3356
+ */
3357
+ if (this.sessions.get(sessionId))
3077
3358
  return void reply({ ok: false, error: 'Session is still active — stop it first' });
3078
3359
  // `worktree remove` also mutates the shared .git registration.
3079
3360
  await this.withRepoLockFor(sessionWorktreePath(sessionId), () => removeSessionWorktree(sessionId));
@@ -3085,7 +3366,7 @@ export class Supervisor {
3085
3366
  const sessionId = frame.sessionId;
3086
3367
  if (!sessionId)
3087
3368
  return void reply({ ok: false, error: 'sessionId is required' });
3088
- if (this.sessions.get(sessionId)) {
3369
+ if (!(await this.releaseForCleanup(sessionId))) {
3089
3370
  return void reply({ ok: false, error: 'Session is still active — stop it first' });
3090
3371
  }
3091
3372
  const branch = str(frame.args?.['branch']);
@@ -3777,6 +4058,43 @@ export class Supervisor {
3777
4058
  return void reply({ ok: false, error: 'root argument is required' });
3778
4059
  return void reply({ ok: true, result: fsView(root, str(frame.args?.['path']) ?? '.') });
3779
4060
  }
4061
+ /**
4062
+ * Выложить файл с этой машины (0.46.0).
4063
+ *
4064
+ * Адрес, куда уходят байты, раннер собирает САМ из своего `apiUrl` —
4065
+ * во фрейме его нет и быть не должно. Иначе одна подделанная команда
4066
+ * стала бы способом вытянуть файл с чужой машины на чужой хост.
4067
+ */
4068
+ case 'fs_publish': {
4069
+ const root = str(frame.args?.['root']);
4070
+ const filePath = str(frame.args?.['path']);
4071
+ const slotId = str(frame.args?.['slotId']);
4072
+ if (!root || !filePath || !slotId) {
4073
+ return void reply({ ok: false, error: 'root, path and slotId are required' });
4074
+ }
4075
+ if (!this.opts.apiUrl || !this.opts.runnerToken) {
4076
+ return void reply({
4077
+ ok: false,
4078
+ error: 'This runner has no API credentials configured',
4079
+ });
4080
+ }
4081
+ const maxBytesRaw = frame.args?.['maxBytes'];
4082
+ const maxBytes = typeof maxBytesRaw === 'number' ? maxBytesRaw : 0;
4083
+ try {
4084
+ const result = await publishFile({
4085
+ root,
4086
+ relPath: filePath,
4087
+ slotId,
4088
+ maxBytes,
4089
+ apiUrl: this.opts.apiUrl,
4090
+ token: this.opts.runnerToken,
4091
+ });
4092
+ return void reply({ ok: true, result });
4093
+ }
4094
+ catch (error) {
4095
+ return void reply({ ok: false, error: String(error.message ?? error) });
4096
+ }
4097
+ }
3780
4098
  case 'self_update': {
3781
4099
  // Everything that can refuse this lives in self-update.ts; here we
3782
4100
  // only make sure the answer is on the wire BEFORE the process goes
@@ -4193,9 +4511,17 @@ export class Supervisor {
4193
4511
  // would mean it goes on claiming background work forever.
4194
4512
  ...(running ? { backgroundTasks: running.backgroundTasks } : {}),
4195
4513
  });
4514
+ // Every seat change is accompanied by a status report — a session starting,
4515
+ // parking, ending. Hooking the seat report here rather than at each of those
4516
+ // is the whole point: «remember to also tell the API» is the rule that had
4517
+ // already been broken in three places, and it is what this frame exists to
4518
+ // stop mattering. `publishSlots` sends only when the answer actually
4519
+ // changed, and the interval in the constructor is the net under both.
4520
+ this.publishSlots();
4196
4521
  }
4197
4522
  /** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
4198
4523
  shutdown() {
4524
+ clearInterval(this.slotsTimer);
4199
4525
  this.authRelay.cancel();
4200
4526
  for (const running of this.sessions.values()) {
4201
4527
  this.clearBudgetTimers(running);
@@ -4380,10 +4706,6 @@ const AGENT_LABELS = { CLAUDE: 'Claude Code', CODEX: 'Codex' };
4380
4706
  function reportsCost(agent) {
4381
4707
  return agent === 'CLAUDE';
4382
4708
  }
4383
- /** Adapter error codes that mean "the sign-in did not work". */
4384
- function isAuthCode(code) {
4385
- return code === 'auth_expired' || code === 'auth_missing';
4386
- }
4387
4709
  /**
4388
4710
  * Does this descriptor point at work that already exists on a branch?
4389
4711
  * If so, silently creating a fresh branch off HEAD would hide the agent's
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.45.1";
1
+ export declare const RUNNER_VERSION = "0.46.1";
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.45.1';
2
+ export const RUNNER_VERSION = '0.46.1';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.45.1",
3
+ "version": "0.46.1",
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",