@bridge4dev/runner 0.59.1 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,105 @@ const MAX_ATTACHMENT_BYTES = 51 * 1024 * 1024;
44
44
  * generous is nil: a stalled download fails the same way, just later.
45
45
  */
46
46
  const DOWNLOAD_TIMEOUT_MS = 180_000;
47
+ /**
48
+ * How many times one file is fetched before the message goes without it (#407).
49
+ *
50
+ * One attempt was the whole defect: on 13.09.2026 a 656 KB screenshot died on
51
+ * `ECONNRESET` half-way through the body — the API had answered 200 and the
52
+ * proxy had written all 656 604 bytes — and the person got a line in the feed
53
+ * instead of their screenshot. Re-fetching the same file from the same machine
54
+ * reproduced it about 5 times in 65, so the second attempt is very nearly free
55
+ * and the third is the one that covers a bad minute.
56
+ *
57
+ * Three and not more because of the far end: `RATE_LIMIT.DEV_RUNNER_FILE` is 60
58
+ * requests a minute per runner and a message carries at most
59
+ * `DEV_SESSION_MESSAGE_ATTACHMENT_CAP` = 5 files, so the worst message on this
60
+ * path costs 15 — a quarter of the budget, with the rest left for the other
61
+ * sessions on the machine.
62
+ */
63
+ const DOWNLOAD_ATTEMPTS = 3;
64
+ /**
65
+ * What to wait before attempt 2 and before attempt 3.
66
+ *
67
+ * Short on purpose: a person is watching a message they just sent, and the
68
+ * fault this retries is a dropped connection rather than a busy server — the
69
+ * one case that genuinely needs a long wait (429) brings its own number below.
70
+ */
71
+ const RETRY_PAUSES_MS = [1_000, 3_000];
72
+ /**
73
+ * The longest a `Retry-After` may hold up one file.
74
+ *
75
+ * The header is honoured because a 429 is the far end asking for room, and
76
+ * capped because it is allowed to say «600» — which would spend the whole
77
+ * budget below on waiting and deliver the message without the file anyway.
78
+ */
79
+ const MAX_RETRY_AFTER_MS = 10_000;
80
+ /** `Retry-After` in seconds or as an HTTP date, clamped — or nothing usable. */
81
+ function retryAfterMs(response, now) {
82
+ const header = response.headers.get('retry-after');
83
+ if (!header)
84
+ return undefined;
85
+ const seconds = Number(header.trim());
86
+ const ms = Number.isFinite(seconds)
87
+ ? seconds * 1_000
88
+ : Number.isNaN(Date.parse(header))
89
+ ? NaN
90
+ : Date.parse(header) - now;
91
+ if (!Number.isFinite(ms) || ms <= 0)
92
+ return undefined;
93
+ return Math.min(ms, MAX_RETRY_AFTER_MS);
94
+ }
95
+ /**
96
+ * Fetch one attachment once, and say whether the failure is worth repeating.
97
+ *
98
+ * Retried: anything `fetch` throws (a refused connection, a reset, a timeout)
99
+ * and anything thrown while reading the body — the incident was in the body, not
100
+ * in the response — plus 5xx and 429, which are the far end saying «not now».
101
+ *
102
+ * Not retried: every other 4xx (the file is gone, or this token may not have
103
+ * it), and a file over the ceiling, which will be over it again.
104
+ */
105
+ async function fetchAttachmentOnce(doFetch, url, token, budgetMs) {
106
+ let response;
107
+ try {
108
+ response = await doFetch(url, {
109
+ headers: { Authorization: `Bearer ${token}` },
110
+ signal: AbortSignal.timeout(budgetMs),
111
+ });
112
+ }
113
+ catch (error) {
114
+ return { ok: false, retry: true, reason: String(error) };
115
+ }
116
+ if (!response.ok) {
117
+ const retry = response.status >= 500 || response.status === 429;
118
+ return {
119
+ ok: false,
120
+ retry,
121
+ reason: `HTTP ${response.status}`,
122
+ ...(response.status === 429
123
+ ? (() => {
124
+ const after = retryAfterMs(response, Date.now());
125
+ return after === undefined ? {} : { retryAfterMs: after };
126
+ })()
127
+ : {}),
128
+ };
129
+ }
130
+ let buffer;
131
+ try {
132
+ buffer = Buffer.from(await response.arrayBuffer());
133
+ }
134
+ catch (error) {
135
+ return { ok: false, retry: true, reason: String(error) };
136
+ }
137
+ if (buffer.length > MAX_ATTACHMENT_BYTES) {
138
+ return {
139
+ ok: false,
140
+ retry: false,
141
+ reason: `file is larger than ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB`,
142
+ };
143
+ }
144
+ return { ok: true, buffer };
145
+ }
47
146
  /**
48
147
  * A file name that is safe to write and unambiguous to read.
49
148
  *
@@ -134,9 +233,18 @@ export async function ensureGitExclude(worktreePath) {
134
233
  * Returns what actually landed — a file that could not be fetched is reported
135
234
  * and skipped rather than failing the whole message, because the text the user
136
235
  * typed is usually still worth delivering.
236
+ *
237
+ * Each file gets up to `DOWNLOAD_ATTEMPTS` goes inside ONE `DOWNLOAD_TIMEOUT_MS`
238
+ * budget (#407): the ceiling on how long a person waits for their own message is
239
+ * unchanged, and the attempts share it rather than each getting their own.
137
240
  */
138
241
  export async function saveAttachments(input) {
139
242
  const doFetch = input.fetchImpl ?? fetch;
243
+ const sleep = input.sleepImpl ??
244
+ ((ms) => new Promise((resolve) => {
245
+ const timer = setTimeout(resolve, ms);
246
+ timer.unref?.();
247
+ }));
140
248
  const dir = path.join(input.worktreePath, ATTACHMENT_DIR);
141
249
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
142
250
  await ensureGitExclude(input.worktreePath);
@@ -144,18 +252,59 @@ export async function saveAttachments(input) {
144
252
  const failed = [];
145
253
  const base = input.apiUrl.replace(/\/$/, '');
146
254
  for (const attachment of input.attachments) {
147
- try {
148
- const response = await doFetch(`${base}/api/v1/dev/runner/attachments/${encodeURIComponent(attachment.id)}`, {
149
- headers: { Authorization: `Bearer ${input.token}` },
150
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
151
- });
152
- if (!response.ok) {
153
- throw new Error(`HTTP ${response.status}`);
255
+ const url = `${base}/api/v1/dev/runner/attachments/${encodeURIComponent(attachment.id)}`;
256
+ const deadline = Date.now() + DOWNLOAD_TIMEOUT_MS;
257
+ let buffer = null;
258
+ let lastReason = 'unknown';
259
+ /** What actually happened, for the summary line — not the ceiling. */
260
+ let made = 0;
261
+ for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt++) {
262
+ const budget = deadline - Date.now();
263
+ if (budget <= 0) {
264
+ lastReason = 'the time allowed for this file ran out';
265
+ break;
154
266
  }
155
- const buffer = Buffer.from(await response.arrayBuffer());
156
- if (buffer.length > MAX_ATTACHMENT_BYTES) {
157
- throw new Error(`file is larger than ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB`);
267
+ made += 1;
268
+ const outcome = await fetchAttachmentOnce(doFetch, url, input.token, budget);
269
+ if (outcome.ok) {
270
+ buffer = outcome.buffer;
271
+ break;
158
272
  }
273
+ lastReason = outcome.reason;
274
+ // Every attempt is in the log with its number, so «it worked the second
275
+ // time» is visible afterwards rather than being a silent success.
276
+ log.warn('attachments: download attempt failed', {
277
+ attachmentId: attachment.id,
278
+ attempt,
279
+ of: DOWNLOAD_ATTEMPTS,
280
+ willRetry: outcome.retry && attempt < DOWNLOAD_ATTEMPTS,
281
+ error: outcome.reason,
282
+ });
283
+ if (!outcome.retry || attempt === DOWNLOAD_ATTEMPTS)
284
+ break;
285
+ const pause = Math.min(outcome.retryAfterMs ?? RETRY_PAUSES_MS[attempt - 1] ?? 0,
286
+ // Never wait past the budget: the wait would be the whole of what is
287
+ // left and the attempt it buys would have no time to run.
288
+ Math.max(0, deadline - Date.now()));
289
+ if (pause > 0)
290
+ await sleep(pause);
291
+ }
292
+ if (buffer === null) {
293
+ // One line per FILE at the end, next to the line the session feed gets.
294
+ log.warn('attachments: download failed', {
295
+ attachmentId: attachment.id,
296
+ // What was actually tried, and the ceiling beside it. Printing the
297
+ // ceiling alone said «3 attempts» for a 404 that was asked once — and
298
+ // «did the retry run on this file» is the one question this whole
299
+ // change exists to let somebody answer from the log.
300
+ attempts: made,
301
+ of: DOWNLOAD_ATTEMPTS,
302
+ error: lastReason,
303
+ });
304
+ failed.push(attachment.fileName);
305
+ continue;
306
+ }
307
+ try {
159
308
  const name = safeAttachmentName(attachment.id, attachment.fileName);
160
309
  fs.writeFileSync(path.join(dir, name), buffer, { mode: 0o600 });
161
310
  saved.push({
@@ -166,7 +315,9 @@ export async function saveAttachments(input) {
166
315
  });
167
316
  }
168
317
  catch (error) {
169
- log.warn('attachments: download failed', {
318
+ // The bytes are here and the disk refused them — not something a repeat
319
+ // of the download would mend.
320
+ log.warn('attachments: could not write the file', {
170
321
  attachmentId: attachment.id,
171
322
  error: String(error),
172
323
  });
@@ -1,5 +1,5 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
- import { claudeExecutableOption } from './agent-binary.js';
2
+ import { assertClaudeInstalled, claudeExecutableOption } from './agent-binary.js';
3
3
  import { scrubbedEnv } from './adapters/claude.js';
4
4
  import { gitBranchDiff } from './gitops.js';
5
5
  import { isSecretPath, maskString } from './policy.js';
@@ -149,6 +149,13 @@ export async function proposeCommitMessage(input, queryFn = query) {
149
149
  }
150
150
  let text = '';
151
151
  try {
152
+ // The second place a real Claude run starts (#395). Same question as
153
+ // `ClaudeAdapter.startSession`, asked for the same reason and only of the
154
+ // real SDK: without it this run would reach for whatever binary the SDK
155
+ // resolves by itself. Inside the `try`, so the answer is the ordinary
156
+ // `{ ok: false, error }` this function already returns.
157
+ if (queryFn === query)
158
+ assertClaudeInstalled();
152
159
  const run = queryFn({
153
160
  prompt: buildPrompt(input, diff),
154
161
  options: {
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { claimCageAuthority, runSystemctl } from './cage-authority.js';
13
13
  import { acquireDaemonLock, isHeldByAnother } from './daemon-lock.js';
14
14
  import { loadConfig, mergeIntoPairedConfig, requireConfig, saveConfig, } from './config.js';
15
15
  import { log } from './log.js';
16
- import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
16
+ import { installIsWritable, installPrefixFor, isSupervisedProcess, restartCapability, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
17
17
  import { applyStoredClaudeToken } from './agent-auth.js';
18
18
  import { Supervisor } from './supervisor.js';
19
19
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
@@ -175,6 +175,21 @@ function runnerCapabilities(apiUrlOverride) {
175
175
  ...(selfUpdatable()
176
176
  ? { selfUpdate: true }
177
177
  : { selfUpdateBlocked: selfUpdateBlockedReason() ?? 'unsupervised' }),
178
+ /**
179
+ * 0.60.0: can be told to restart itself (#396), the same contract shape as
180
+ * the pair above.
181
+ *
182
+ * The condition is `isSupervisedProcess()` ALONE, and deliberately not
183
+ * `selfUpdatable()`: replacing the package needs an installed, writable npm
184
+ * package, but restarting needs only something that will start us again —
185
+ * a source checkout under systemd (this dogfood box) restarts perfectly
186
+ * well and must get the button.
187
+ *
188
+ * Announced rather than inferred from the version, because an older runner
189
+ * drops a command it cannot parse WITHOUT answering, and the dashboard
190
+ * would then offer a button that hangs until the gateway gives up.
191
+ */
192
+ ...restartCapability(),
178
193
  /**
179
194
  * Which OS user this daemon runs as (0.24.0).
180
195
  *
@@ -526,6 +541,12 @@ function runnerCapabilities(apiUrlOverride) {
526
541
  // это чтение, а не установка, и машину, чей владелец запретил ставить из
527
542
  // дашборда, спросить о том, что на ней стоит, по-прежнему можно.
528
543
  'agent_versions_refresh',
544
+ // #396. Listed unconditionally, unlike the `restart` flag above: the
545
+ // flag is what the dashboard draws a button from, and this list is what
546
+ // `runCommand` dispatches on. A machine that cannot restart still
547
+ // ANSWERS the command, with a sentence saying why — which is a better
548
+ // outcome than a frame nobody replies to.
549
+ 'runner_restart',
529
550
  ],
530
551
  };
531
552
  }
@@ -911,6 +932,20 @@ async function cmdDaemon() {
911
932
  CLAUDE: new ClaudeAdapter(),
912
933
  ...(codex ? { CODEX: codex } : {}),
913
934
  },
935
+ /**
936
+ * #395: an agent installed from the dashboard is usable without a restart.
937
+ *
938
+ * The map above is built once, from what was on PATH when this process
939
+ * started — the same frozen snapshot `capabilities.agents` was. Claude does
940
+ * not need this (its adapter is unconditional and asks for the binary at
941
+ * session start), Codex does: its adapter is only built when `codex` was
942
+ * already there, so pressing «Install Codex» used to leave a machine that
943
+ * the card called ready and every session on which died at once.
944
+ *
945
+ * `hasExecutable` is asked HERE, at the moment of the question, which is
946
+ * the whole point.
947
+ */
948
+ makeAdapter: (agent) => agent === 'CODEX' && hasExecutable('codex') ? bootstrapCodex(config) : null,
914
949
  ...(config.mcp ? { mcp: { url: config.mcp.url, token: config.mcp.token } } : {}),
915
950
  ...(config.limits?.max_sessions ? { maxSessionsLimit: config.limits.max_sessions } : {}),
916
951
  /**
@@ -961,6 +996,22 @@ async function cmdDaemon() {
961
996
  }, RESTART_DELAY_MS);
962
997
  timer.unref();
963
998
  },
999
+ // #396: the same exit, asked for directly rather than as the tail of an
1000
+ // update. The delay is the same and for the same reason — the answer has
1001
+ // to leave the socket before the process does, or the card shows a timeout
1002
+ // for a restart that is happening.
1003
+ onRestartCommanded: (note) => {
1004
+ log.info('daemon: restarting on a command from the dashboard', {
1005
+ sessions: supervisor.activeSessionIds.length,
1006
+ told: Boolean(note),
1007
+ });
1008
+ const timer = setTimeout(() => {
1009
+ supervisor.shutdown();
1010
+ ws.stop();
1011
+ process.exit(0);
1012
+ }, RESTART_DELAY_MS);
1013
+ timer.unref();
1014
+ },
964
1015
  });
965
1016
  const updateStatus = () => writeStatusFile({
966
1017
  pid: process.pid,
@@ -1276,7 +1327,13 @@ async function runnerChecks() {
1276
1327
  // /root/.local — a directory the daemon's user cannot write — and
1277
1328
  // the install died naming a home nobody had chosen. `sudo -iu` hands
1278
1329
  // the string to the TARGET user's login shell, so `$HOME` is theirs.
1279
- fix: `sudo -iu ${me.user} sh -lc 'npm config set prefix "$HOME/.local" && npm install -g --ignore-scripts --loglevel=error @bridge4dev/runner'`,
1330
+ // `--omit=optional` is not cosmetic here (#395): without it npm pulls
1331
+ // the SDK's bundled Claude — 215 MB and a SECOND Claude of a
1332
+ // different version on the machine — and every other install path in
1333
+ // the product omits it (`dev-runner-install.sh`, `self-update.ts`).
1334
+ // This one line was how a hand-repaired machine ended up with a
1335
+ // binary no card could account for.
1336
+ fix: `sudo -iu ${me.user} sh -lc 'npm config set prefix "$HOME/.local" && npm install -g --ignore-scripts --omit=optional --loglevel=error @bridge4dev/runner'`,
1280
1337
  fixMore: `(the \`npm config set prefix\` half is what keeps the button working: without it every LATER update aims at the system prefix again and fails with EACCES)`,
1281
1338
  }),
1282
1339
  });
package/dist/journal.d.ts CHANGED
@@ -58,6 +58,23 @@ export declare class SessionJournal {
58
58
  anchor: string;
59
59
  providerSessionId: string;
60
60
  } | null;
61
+ /**
62
+ * Every card this journal has published, and whether it is still open —
63
+ * in the order they were asked (#392).
64
+ *
65
+ * The persistent twin of the supervisor's `running.openQuestions`: that set
66
+ * dies with the process, and a process that dies ungracefully never gets to
67
+ * `shutdown()`, which is the only place it withdraws them. This one is
68
+ * rebuilt from the file, so the next process can close what the last one
69
+ * left open — the card in the browser does not know the runner restarted.
70
+ *
71
+ * Closed cards are remembered too, not only dropped: «this card was closed
72
+ * by somebody» is the fact that stops a SECOND tombstone. A queued answer
73
+ * that reaches the next process after a restore has already closed the card
74
+ * must rescue the words and say nothing more about the card — and the only
75
+ * witness that the card was closed is this file.
76
+ */
77
+ private readonly asks;
61
78
  constructor(sessionId: string, dir?: string);
62
79
  private replay;
63
80
  private write;
@@ -86,6 +103,29 @@ export declare class SessionJournal {
86
103
  recordStatus(status: string, extra?: Record<string, unknown>, epoch?: number): void;
87
104
  /** Assign the next seq and persist the event before it is sent. */
88
105
  append(eventType: string, payload: Record<string, unknown>): JournalEvent;
106
+ /**
107
+ * Keep `openAsks` in step with the cards going out through `append` (#392).
108
+ *
109
+ * Here and not in the supervisor, because `append` is the one door every
110
+ * event takes: a card that was published was published through it, and so
111
+ * was every resolution — the adapter's own, `withdrawOpenQuestions`, the
112
+ * `not_open` miss. A set kept beside the callers would need every one of
113
+ * them to remember it, which is exactly how the in-memory set came to be
114
+ * empty at the moment it was needed.
115
+ *
116
+ * A resolution for a card this journal never published writes nothing:
117
+ * there is nothing to close, and remembering strangers would only grow the
118
+ * file.
119
+ */
120
+ private trackAsk;
121
+ /** The cards still waiting on a person, oldest first (#392). */
122
+ openAskIds(): string[];
123
+ /**
124
+ * What this journal knows about one card (#392): `open`, `closed`, or
125
+ * nothing at all — a card asked by a runner from before these lines existed,
126
+ * or one this state directory never saw.
127
+ */
128
+ askState(askId: string): 'open' | 'closed' | undefined;
89
129
  /**
90
130
  * Never reuse a seq the API already stored: after a runner state-dir wipe the
91
131
  * local counter restarts at 1 and every replayed event would collide with an
package/dist/journal.js CHANGED
@@ -21,6 +21,23 @@ export class SessionJournal {
21
21
  lastStatus = null;
22
22
  /** The agent's conversation tip, and the provider session it lives in. */
23
23
  lastAnchor = null;
24
+ /**
25
+ * Every card this journal has published, and whether it is still open —
26
+ * in the order they were asked (#392).
27
+ *
28
+ * The persistent twin of the supervisor's `running.openQuestions`: that set
29
+ * dies with the process, and a process that dies ungracefully never gets to
30
+ * `shutdown()`, which is the only place it withdraws them. This one is
31
+ * rebuilt from the file, so the next process can close what the last one
32
+ * left open — the card in the browser does not know the runner restarted.
33
+ *
34
+ * Closed cards are remembered too, not only dropped: «this card was closed
35
+ * by somebody» is the fact that stops a SECOND tombstone. A queued answer
36
+ * that reaches the next process after a restore has already closed the card
37
+ * must rescue the words and say nothing more about the card — and the only
38
+ * witness that the card was closed is this file.
39
+ */
40
+ asks = new Map();
24
41
  constructor(sessionId, dir = journalDir()) {
25
42
  this.sessionId = sessionId;
26
43
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -50,6 +67,16 @@ export class SessionJournal {
50
67
  });
51
68
  if (parsed.seq >= this.nextSeq)
52
69
  this.nextSeq = parsed.seq + 1;
70
+ // The event lines carry the same fact as `ask_open`/`ask_closed`, and
71
+ // replaying both is harmless: the set is a set.
72
+ this.trackAsk(parsed.eventType, parsed.payload, false);
73
+ }
74
+ else if (parsed.kind === 'ask_open') {
75
+ if (this.asks.get(parsed.askId) !== 'closed')
76
+ this.asks.set(parsed.askId, 'open');
77
+ }
78
+ else if (parsed.kind === 'ask_closed') {
79
+ this.asks.set(parsed.askId, 'closed');
53
80
  }
54
81
  else if (parsed.kind === 'ack') {
55
82
  this.unackedBySeq.delete(parsed.seq);
@@ -107,6 +134,11 @@ export class SessionJournal {
107
134
  if (this.lastAnchor) {
108
135
  lines.push({ kind: 'anchor', ...this.lastAnchor });
109
136
  }
137
+ // AFTER the event lines: an unacked `question` re-written above would
138
+ // otherwise reopen, on the next replay, a card these lines say is closed.
139
+ for (const [askId, state] of this.asks) {
140
+ lines.push({ kind: state === 'open' ? 'ask_open' : 'ask_closed', askId });
141
+ }
110
142
  if (this.lastStatus) {
111
143
  lines.push({
112
144
  kind: 'status',
@@ -182,8 +214,58 @@ export class SessionJournal {
182
214
  const event = { seq: this.nextSeq++, eventType, payload };
183
215
  this.write({ kind: 'event', ...event, ts: new Date().toISOString() });
184
216
  this.unackedBySeq.set(event.seq, event);
217
+ this.trackAsk(eventType, payload, true);
185
218
  return event;
186
219
  }
220
+ /**
221
+ * Keep `openAsks` in step with the cards going out through `append` (#392).
222
+ *
223
+ * Here and not in the supervisor, because `append` is the one door every
224
+ * event takes: a card that was published was published through it, and so
225
+ * was every resolution — the adapter's own, `withdrawOpenQuestions`, the
226
+ * `not_open` miss. A set kept beside the callers would need every one of
227
+ * them to remember it, which is exactly how the in-memory set came to be
228
+ * empty at the moment it was needed.
229
+ *
230
+ * A resolution for a card this journal never published writes nothing:
231
+ * there is nothing to close, and remembering strangers would only grow the
232
+ * file.
233
+ */
234
+ trackAsk(eventType, payload, persist) {
235
+ if (eventType !== 'question' && eventType !== 'question_resolved')
236
+ return;
237
+ const askId = payload['askId'];
238
+ if (typeof askId !== 'string' || !askId)
239
+ return;
240
+ const state = this.asks.get(askId);
241
+ if (eventType === 'question') {
242
+ // A card is asked once; a replayed event line for one already closed
243
+ // (compaction keeps unacked events) must not reopen it.
244
+ if (state !== undefined)
245
+ return;
246
+ this.asks.set(askId, 'open');
247
+ if (persist)
248
+ this.write({ kind: 'ask_open', askId });
249
+ return;
250
+ }
251
+ if (state !== 'open')
252
+ return;
253
+ this.asks.set(askId, 'closed');
254
+ if (persist)
255
+ this.write({ kind: 'ask_closed', askId });
256
+ }
257
+ /** The cards still waiting on a person, oldest first (#392). */
258
+ openAskIds() {
259
+ return [...this.asks].filter(([, state]) => state === 'open').map(([askId]) => askId);
260
+ }
261
+ /**
262
+ * What this journal knows about one card (#392): `open`, `closed`, or
263
+ * nothing at all — a card asked by a runner from before these lines existed,
264
+ * or one this state directory never saw.
265
+ */
266
+ askState(askId) {
267
+ return this.asks.get(askId);
268
+ }
187
269
  /**
188
270
  * Never reuse a seq the API already stored: after a runner state-dir wipe the
189
271
  * local counter restarts at 1 and every replayed event would collide with an
@@ -32,6 +32,20 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
32
32
  * behaviour every runner had before this release.
33
33
  */
34
34
  pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
35
+ /**
36
+ * Subagents the API still believes to be alive in this session (#393).
37
+ *
38
+ * The number is the dead process's last word, kept by the API: a runner
39
+ * restart takes every subagent with it, and the new process seeds its own
40
+ * count at zero — so the descriptor is the only place the number survives
41
+ * long enough to be written down. The API sends it only while it still
42
+ * believes it (its own trust window on the count's age); absent or zero
43
+ * means «nothing to say», and a restore says nothing.
44
+ *
45
+ * `.catch(undefined)` like its neighbours: one malformed value must cost
46
+ * its own session a line at most, never the frame (QA-100 MAJOR-1).
47
+ */
48
+ backgroundTasks: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
35
49
  /**
36
50
  * The newest published version of THIS session's agent — Р13, §4.8.
37
51
  *
@@ -246,6 +260,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
246
260
  } | undefined;
247
261
  workMode?: "DIRECT" | "BRANCH" | undefined;
248
262
  pausedUntil?: string | null | undefined;
263
+ backgroundTasks?: number | undefined;
249
264
  agentLatestVersion?: string | undefined;
250
265
  skipAgentPrompt?: boolean | undefined;
251
266
  branchHint?: string | undefined;
@@ -295,6 +310,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
295
310
  activeMsBase?: number | undefined;
296
311
  extraBudgetMinutes?: number | null | undefined;
297
312
  pausedUntil?: unknown;
313
+ backgroundTasks?: unknown;
298
314
  agentLatestVersion?: unknown;
299
315
  skipAgentPrompt?: unknown;
300
316
  branchHint?: unknown;
@@ -450,6 +466,20 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
450
466
  * behaviour every runner had before this release.
451
467
  */
452
468
  pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
469
+ /**
470
+ * Subagents the API still believes to be alive in this session (#393).
471
+ *
472
+ * The number is the dead process's last word, kept by the API: a runner
473
+ * restart takes every subagent with it, and the new process seeds its own
474
+ * count at zero — so the descriptor is the only place the number survives
475
+ * long enough to be written down. The API sends it only while it still
476
+ * believes it (its own trust window on the count's age); absent or zero
477
+ * means «nothing to say», and a restore says nothing.
478
+ *
479
+ * `.catch(undefined)` like its neighbours: one malformed value must cost
480
+ * its own session a line at most, never the frame (QA-100 MAJOR-1).
481
+ */
482
+ backgroundTasks: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
453
483
  /**
454
484
  * The newest published version of THIS session's agent — Р13, §4.8.
455
485
  *
@@ -664,6 +694,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
664
694
  } | undefined;
665
695
  workMode?: "DIRECT" | "BRANCH" | undefined;
666
696
  pausedUntil?: string | null | undefined;
697
+ backgroundTasks?: number | undefined;
667
698
  agentLatestVersion?: string | undefined;
668
699
  skipAgentPrompt?: boolean | undefined;
669
700
  branchHint?: string | undefined;
@@ -713,6 +744,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
713
744
  activeMsBase?: number | undefined;
714
745
  extraBudgetMinutes?: number | null | undefined;
715
746
  pausedUntil?: unknown;
747
+ backgroundTasks?: unknown;
716
748
  agentLatestVersion?: unknown;
717
749
  skipAgentPrompt?: unknown;
718
750
  branchHint?: unknown;
@@ -759,6 +791,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
759
791
  } | undefined;
760
792
  workMode?: "DIRECT" | "BRANCH" | undefined;
761
793
  pausedUntil?: string | null | undefined;
794
+ backgroundTasks?: number | undefined;
762
795
  agentLatestVersion?: string | undefined;
763
796
  skipAgentPrompt?: boolean | undefined;
764
797
  branchHint?: string | undefined;
@@ -814,6 +847,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
814
847
  activeMsBase?: number | undefined;
815
848
  extraBudgetMinutes?: number | null | undefined;
816
849
  pausedUntil?: unknown;
850
+ backgroundTasks?: unknown;
817
851
  agentLatestVersion?: unknown;
818
852
  skipAgentPrompt?: unknown;
819
853
  branchHint?: unknown;
@@ -883,6 +917,20 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
883
917
  * behaviour every runner had before this release.
884
918
  */
885
919
  pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
920
+ /**
921
+ * Subagents the API still believes to be alive in this session (#393).
922
+ *
923
+ * The number is the dead process's last word, kept by the API: a runner
924
+ * restart takes every subagent with it, and the new process seeds its own
925
+ * count at zero — so the descriptor is the only place the number survives
926
+ * long enough to be written down. The API sends it only while it still
927
+ * believes it (its own trust window on the count's age); absent or zero
928
+ * means «nothing to say», and a restore says nothing.
929
+ *
930
+ * `.catch(undefined)` like its neighbours: one malformed value must cost
931
+ * its own session a line at most, never the frame (QA-100 MAJOR-1).
932
+ */
933
+ backgroundTasks: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
886
934
  /**
887
935
  * The newest published version of THIS session's agent — Р13, §4.8.
888
936
  *
@@ -1097,6 +1145,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1097
1145
  } | undefined;
1098
1146
  workMode?: "DIRECT" | "BRANCH" | undefined;
1099
1147
  pausedUntil?: string | null | undefined;
1148
+ backgroundTasks?: number | undefined;
1100
1149
  agentLatestVersion?: string | undefined;
1101
1150
  skipAgentPrompt?: boolean | undefined;
1102
1151
  branchHint?: string | undefined;
@@ -1146,6 +1195,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1146
1195
  activeMsBase?: number | undefined;
1147
1196
  extraBudgetMinutes?: number | null | undefined;
1148
1197
  pausedUntil?: unknown;
1198
+ backgroundTasks?: unknown;
1149
1199
  agentLatestVersion?: unknown;
1150
1200
  skipAgentPrompt?: unknown;
1151
1201
  branchHint?: unknown;
@@ -1192,6 +1242,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1192
1242
  } | undefined;
1193
1243
  workMode?: "DIRECT" | "BRANCH" | undefined;
1194
1244
  pausedUntil?: string | null | undefined;
1245
+ backgroundTasks?: number | undefined;
1195
1246
  agentLatestVersion?: string | undefined;
1196
1247
  skipAgentPrompt?: boolean | undefined;
1197
1248
  branchHint?: string | undefined;
@@ -1244,6 +1295,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1244
1295
  activeMsBase?: number | undefined;
1245
1296
  extraBudgetMinutes?: number | null | undefined;
1246
1297
  pausedUntil?: unknown;
1298
+ backgroundTasks?: unknown;
1247
1299
  agentLatestVersion?: unknown;
1248
1300
  skipAgentPrompt?: unknown;
1249
1301
  branchHint?: unknown;
package/dist/protocol.js CHANGED
@@ -59,6 +59,20 @@ export const SessionDescriptorSchema = z.object({
59
59
  * behaviour every runner had before this release.
60
60
  */
61
61
  pausedUntil: z.string().max(40).nullable().optional().catch(undefined),
62
+ /**
63
+ * Subagents the API still believes to be alive in this session (#393).
64
+ *
65
+ * The number is the dead process's last word, kept by the API: a runner
66
+ * restart takes every subagent with it, and the new process seeds its own
67
+ * count at zero — so the descriptor is the only place the number survives
68
+ * long enough to be written down. The API sends it only while it still
69
+ * believes it (its own trust window on the count's age); absent or zero
70
+ * means «nothing to say», and a restore says nothing.
71
+ *
72
+ * `.catch(undefined)` like its neighbours: one malformed value must cost
73
+ * its own session a line at most, never the frame (QA-100 MAJOR-1).
74
+ */
75
+ backgroundTasks: z.number().int().min(0).optional().catch(undefined),
62
76
  /**
63
77
  * The newest published version of THIS session's agent — Р13, §4.8.
64
78
  *