@bridge4dev/runner 0.36.0 → 0.37.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.
package/dist/index.js CHANGED
@@ -389,6 +389,11 @@ function runnerCapabilities(apiUrlOverride) {
389
389
  'agent_prompt_state',
390
390
  'propose_commit_message',
391
391
  'recall_message',
392
+ // The answer to a parked question, as a command that ANSWERS rather than
393
+ // a frame written into a socket and hoped for. Announced here because
394
+ // this list is what `runCommand` actually dispatches on, and the API
395
+ // falls back to the old frame for runners that do not name it.
396
+ 'answer_question',
392
397
  'compact_context',
393
398
  ...(checkpointsEnabled
394
399
  ? [
@@ -272,6 +272,78 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
272
272
  workMode?: unknown;
273
273
  }>;
274
274
  export type SessionDescriptor = z.infer<typeof SessionDescriptorSchema>;
275
+ /**
276
+ * One human answer to a parked agent question, minus the session it belongs to.
277
+ *
278
+ * Written once and used twice on purpose: as the body of the `question_answer`
279
+ * frame every runner since 0.14 understands, and as the arguments of the
280
+ * `answer_question` COMMAND that replaced it (session 18). The two must not
281
+ * drift — an answer that the frame accepts and the command rejects would fail
282
+ * exactly on the machines whose connection is bad enough to need the command.
283
+ */
284
+ export declare const QuestionAnswerShape: {
285
+ readonly askId: z.ZodString;
286
+ readonly action: z.ZodEnum<["answer", "discuss"]>;
287
+ readonly answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
288
+ questionId: z.ZodString;
289
+ values: z.ZodArray<z.ZodString, "many">;
290
+ custom: z.ZodOptional<z.ZodString>;
291
+ notes: z.ZodOptional<z.ZodString>;
292
+ }, "strip", z.ZodTypeAny, {
293
+ values: string[];
294
+ questionId: string;
295
+ custom?: string | undefined;
296
+ notes?: string | undefined;
297
+ }, {
298
+ values: string[];
299
+ questionId: string;
300
+ custom?: string | undefined;
301
+ notes?: string | undefined;
302
+ }>, "many">>;
303
+ readonly text: z.ZodOptional<z.ZodString>;
304
+ };
305
+ /** The `answer_question` command's arguments — the shape above on its own. */
306
+ export declare const QuestionAnswerArgsSchema: z.ZodObject<{
307
+ readonly askId: z.ZodString;
308
+ readonly action: z.ZodEnum<["answer", "discuss"]>;
309
+ readonly answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
310
+ questionId: z.ZodString;
311
+ values: z.ZodArray<z.ZodString, "many">;
312
+ custom: z.ZodOptional<z.ZodString>;
313
+ notes: z.ZodOptional<z.ZodString>;
314
+ }, "strip", z.ZodTypeAny, {
315
+ values: string[];
316
+ questionId: string;
317
+ custom?: string | undefined;
318
+ notes?: string | undefined;
319
+ }, {
320
+ values: string[];
321
+ questionId: string;
322
+ custom?: string | undefined;
323
+ notes?: string | undefined;
324
+ }>, "many">>;
325
+ readonly text: z.ZodOptional<z.ZodString>;
326
+ }, "strip", z.ZodTypeAny, {
327
+ askId: string;
328
+ action: "answer" | "discuss";
329
+ text?: string | undefined;
330
+ answers?: {
331
+ values: string[];
332
+ questionId: string;
333
+ custom?: string | undefined;
334
+ notes?: string | undefined;
335
+ }[] | undefined;
336
+ }, {
337
+ askId: string;
338
+ action: "answer" | "discuss";
339
+ text?: string | undefined;
340
+ answers?: {
341
+ values: string[];
342
+ questionId: string;
343
+ custom?: string | undefined;
344
+ notes?: string | undefined;
345
+ }[] | undefined;
346
+ }>;
275
347
  export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
276
348
  type: z.ZodLiteral<"hello_ack">;
277
349
  serverId: z.ZodString;
@@ -1104,8 +1176,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1104
1176
  requestId: string;
1105
1177
  note?: string | undefined;
1106
1178
  }>, z.ZodObject<{
1107
- type: z.ZodLiteral<"question_answer">;
1108
- sessionId: z.ZodString;
1109
1179
  askId: z.ZodString;
1110
1180
  action: z.ZodEnum<["answer", "discuss"]>;
1111
1181
  answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -1125,6 +1195,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1125
1195
  notes?: string | undefined;
1126
1196
  }>, "many">>;
1127
1197
  text: z.ZodOptional<z.ZodString>;
1198
+ type: z.ZodLiteral<"question_answer">;
1199
+ sessionId: z.ZodString;
1128
1200
  }, "strip", z.ZodTypeAny, {
1129
1201
  sessionId: string;
1130
1202
  type: "question_answer";
package/dist/protocol.js CHANGED
@@ -183,6 +183,31 @@ export const SessionDescriptorSchema = z.object({
183
183
  // project). Preferred over the [mcp] section of config.toml when present.
184
184
  mcp: z.object({ url: z.string().url(), token: z.string().min(1) }).optional(),
185
185
  });
186
+ /**
187
+ * One human answer to a parked agent question, minus the session it belongs to.
188
+ *
189
+ * Written once and used twice on purpose: as the body of the `question_answer`
190
+ * frame every runner since 0.14 understands, and as the arguments of the
191
+ * `answer_question` COMMAND that replaced it (session 18). The two must not
192
+ * drift — an answer that the frame accepts and the command rejects would fail
193
+ * exactly on the machines whose connection is bad enough to need the command.
194
+ */
195
+ export const QuestionAnswerShape = {
196
+ askId: z.string().min(1).max(64),
197
+ action: z.enum(['answer', 'discuss']),
198
+ answers: z
199
+ .array(z.object({
200
+ questionId: z.string().min(1).max(120),
201
+ values: z.array(z.string().max(2_000)).max(16),
202
+ custom: z.string().max(10_000).optional(),
203
+ notes: z.string().max(2_000).optional(),
204
+ }))
205
+ .max(4)
206
+ .optional(),
207
+ text: z.string().max(20_000).optional(),
208
+ };
209
+ /** The `answer_question` command's arguments — the shape above on its own. */
210
+ export const QuestionAnswerArgsSchema = z.object(QuestionAnswerShape);
186
211
  export const GatewayFrameSchema = z.discriminatedUnion('type', [
187
212
  z.object({
188
213
  type: z.literal('hello_ack'),
@@ -238,18 +263,7 @@ export const GatewayFrameSchema = z.discriminatedUnion('type', [
238
263
  z.object({
239
264
  type: z.literal('question_answer'),
240
265
  sessionId: z.string().uuid(),
241
- askId: z.string().min(1).max(64),
242
- action: z.enum(['answer', 'discuss']),
243
- answers: z
244
- .array(z.object({
245
- questionId: z.string().min(1).max(120),
246
- values: z.array(z.string().max(2_000)).max(16),
247
- custom: z.string().max(10_000).optional(),
248
- notes: z.string().max(2_000).optional(),
249
- }))
250
- .max(4)
251
- .optional(),
252
- text: z.string().max(20_000).optional(),
266
+ ...QuestionAnswerShape,
253
267
  }),
254
268
  z.object({ type: z.literal('session_stop'), sessionId: z.string().uuid() }),
255
269
  z.object({ type: z.literal('session_interrupt'), sessionId: z.string().uuid() }),
@@ -18,6 +18,7 @@ import { selfUpdate } from './self-update.js';
18
18
  import { rememberWorkspacePath } from './environment.js';
19
19
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
20
20
  import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
21
+ import { QuestionAnswerArgsSchema } from './protocol.js';
21
22
  import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
22
23
  /** Refusals shared by every checkpoint command (ticket #126). */
23
24
  const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
@@ -309,6 +310,7 @@ export class Supervisor {
309
310
  extraBudgetMinutes: descriptor.extraBudgetMinutes,
310
311
  epoch: descriptor.epoch,
311
312
  openQuestions: new Set(),
313
+ answeredAsks: new Set(),
312
314
  // Ticket #196: a pause is part of what a session IS, so it is read off
313
315
  // the descriptor rather than waiting for a frame. Without this a runner
314
316
  // that restarted mid-pause would come back knowing nothing and pick the
@@ -1471,8 +1473,13 @@ export class Supervisor {
1471
1473
  if (!running) {
1472
1474
  log.warn('supervisor: question answer for unknown session', { sessionId: frame.sessionId });
1473
1475
  this.ws.send({ type: 'session_unknown', sessionId: frame.sessionId });
1474
- return;
1476
+ return 'unknown_session';
1475
1477
  }
1478
+ // A redelivery of an answer this runner already took. Silence here is the
1479
+ // point: the first copy did everything, and the second must not add a note,
1480
+ // a message or a status report — see `answeredAsks`.
1481
+ if (running.answeredAsks.has(frame.askId))
1482
+ return 'duplicate';
1476
1483
  const typed = frame.text?.trim();
1477
1484
  // Asked first, echoed second: the adapter resolves synchronously but its
1478
1485
  // own events reach the feed a microtask later, so the user's words still
@@ -1485,12 +1492,17 @@ export class Supervisor {
1485
1492
  ...(frame.text !== undefined ? { text: frame.text } : {}),
1486
1493
  });
1487
1494
  if (accepted) {
1495
+ running.answeredAsks.add(frame.askId);
1488
1496
  // What the user typed belongs in the conversation, whichever exit they took.
1489
1497
  if (typed)
1490
1498
  this.sendEvent(running, 'message', { role: 'user', text: typed });
1491
- return;
1499
+ return 'answered';
1492
1500
  }
1493
1501
  running.openQuestions.delete(frame.askId);
1502
+ // Remembered even though it was a miss: whatever happened to the ask, the
1503
+ // words below have now been published once, and a redelivery must not
1504
+ // publish them again.
1505
+ running.answeredAsks.add(frame.askId);
1494
1506
  this.sendEvent(running, 'system_note', {
1495
1507
  text: typed
1496
1508
  ? 'That question is no longer open — sending your reply as an ordinary message instead.'
@@ -1505,11 +1517,12 @@ export class Supervisor {
1505
1517
  sessionId: frame.sessionId,
1506
1518
  error: String(error),
1507
1519
  }));
1508
- return;
1520
+ return 'not_open';
1509
1521
  }
1510
1522
  // Nothing to deliver — but the API optimistically flipped the session to
1511
1523
  // RUNNING when it relayed the answer, so put the real status back.
1512
1524
  this.reportStatus(frame.sessionId, statusForReport(running), {});
1525
+ return 'not_open';
1513
1526
  }
1514
1527
  async onUserMessage(sessionId, text, attachments) {
1515
1528
  const running = this.sessions.get(sessionId);
@@ -2258,6 +2271,7 @@ export class Supervisor {
2258
2271
  extraBudgetMinutes: descriptor.extraBudgetMinutes,
2259
2272
  epoch: descriptor.epoch,
2260
2273
  openQuestions: new Set(),
2274
+ answeredAsks: new Set(),
2261
2275
  ...pausedUntilOf(descriptor),
2262
2276
  mode: descriptor.mode,
2263
2277
  ...(descriptor.model ? { model: descriptor.model } : {}),
@@ -2457,6 +2471,39 @@ export class Supervisor {
2457
2471
  ok: false,
2458
2472
  error: 'reset_workspace is not supported by this runner version',
2459
2473
  });
2474
+ case 'answer_question': {
2475
+ // The same work as the `question_answer` frame, with one difference
2476
+ // that is the whole point: this one ANSWERS. The frame was
2477
+ // fire-and-forget, so the API called a write into a socket a delivery
2478
+ // — and a socket reports OPEN for up to three missed pongs after the
2479
+ // machine behind it has gone. An answer lost in that window took the
2480
+ // card with it for a day (2026-08-11).
2481
+ //
2482
+ // No `await` before the reply, for the same reason as `recall_message`
2483
+ // below: `onQuestionAnswer` is synchronous, and a yield here would let
2484
+ // a concurrent frame close the ask between the check and the answer.
2485
+ const sessionId = frame.sessionId;
2486
+ if (!sessionId)
2487
+ return void reply({ ok: false, error: 'sessionId is required' });
2488
+ const parsed = QuestionAnswerArgsSchema.safeParse(frame.args ?? {});
2489
+ if (!parsed.success) {
2490
+ return void reply({
2491
+ ok: false,
2492
+ error: parsed.error.issues[0]?.message ?? 'malformed answer',
2493
+ });
2494
+ }
2495
+ const outcome = this.onQuestionAnswer({
2496
+ type: 'question_answer',
2497
+ sessionId,
2498
+ ...parsed.data,
2499
+ });
2500
+ // `ok` is about the RELAY, not about the agent's luck: an answer for a
2501
+ // session this runner no longer holds is the one case the API can fix
2502
+ // by trying again later, so it is the one case reported as a failure.
2503
+ return void reply(outcome === 'unknown_session'
2504
+ ? { ok: false, error: 'Unknown session', result: { outcome } }
2505
+ : { ok: true, result: { outcome } });
2506
+ }
2460
2507
  case 'recall_message': {
2461
2508
  // Ticket #125: take a queued message back before any agent sees it.
2462
2509
  const sessionId = frame.sessionId;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.36.0";
1
+ export declare const RUNNER_VERSION = "0.37.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.36.0';
2
+ export const RUNNER_VERSION = '0.37.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.36.0",
3
+ "version": "0.37.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",