@bridge4dev/runner 0.11.0 → 0.22.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.
Files changed (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +435 -32
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +171 -23
  35. package/dist/service-unit.d.ts +79 -0
  36. package/dist/service-unit.js +211 -0
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  import { type CodexHome } from './codex-home.js';
2
2
  import { AppServerClient, type ServerRequest } from './codex-protocol.js';
3
- import { type AgentAdapter, type AgentSession, type EffortOption, type SessionSpec } from './types.js';
3
+ import { type AgentAdapter, type AgentQuestion, type AgentSession, type EffortOption, type SessionSpec } from './types.js';
4
4
  export interface CodexAdapterDeps {
5
5
  /** Injected in tests to drive a scripted app-server. */
6
6
  spawnClient?: (options: {
@@ -43,8 +43,23 @@ export interface CodexAdapterDeps {
43
43
  * message also talks about tokens or signing in — and never when it names MCP.
44
44
  */
45
45
  export declare function isAuthError(message: string): boolean;
46
- /** The dashboard shows "label — description"; the wire wants the label. */
47
- export declare function stripOptionHint(text: string): string;
46
+ /**
47
+ * The card shows "label — description"; the wire wants the bare label.
48
+ *
49
+ * Only strips when the text really is one of the offered options. The old
50
+ * version cut every answer at the first « — », which quietly destroyed any
51
+ * free-text answer containing a dash — "MariaDB — we already pay for it"
52
+ * reached the agent as "MariaDB".
53
+ */
54
+ export declare function stripOptionHint(text: string, labels?: string[]): string;
55
+ /**
56
+ * One `item/tool/requestUserInput` elicitation → the normalized question list.
57
+ *
58
+ * Codex sends the whole list; before session 12 we kept `questions[0]` and
59
+ * dropped the rest. `isOther` is a question-level flag meaning "free text is
60
+ * acceptable here", and `isSecret` means the answer must not be echoed.
61
+ */
62
+ export declare function parseElicitation(params: Record<string, unknown>): AgentQuestion[];
48
63
  /**
49
64
  * `supportedReasoningEfforts` from `model/list`: a list of
50
65
  * `{reasoningEffort, description}`. Codex 0.145 ships six levels on the Sol
@@ -6,6 +6,7 @@ import { repairCodexAuth } from './codex-home.js';
6
6
  import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
7
7
  import { truncate } from './claude.js';
8
8
  import { AGENT_MODES, } from './types.js';
9
+ import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
9
10
  // Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
10
11
  // contract is unchanged, so the dashboard renders Codex sessions with the same
11
12
  // components it already uses for Claude.
@@ -62,11 +63,15 @@ const SYSTEM_APPEND = [
62
63
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
63
64
  'Rules:',
64
65
  '- Work ONLY inside the current working directory (a dedicated git worktree on a session branch).',
65
- '- Commit your work in the current branch with clear messages. NEVER push to main/master and never force-push.',
66
+ '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked. A human presses «Push» and «Apply» in DevBridge when the branch is ready.',
66
67
  '- If DevBridge MCP tools are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
67
68
  '- The user is not in a terminal: if you need a decision, use your question tool or ask in plain text and end your turn.',
68
69
  '- Never print secrets (tokens, API keys, private keys) in your output.',
69
70
  ].join('\n');
71
+ /** DevBridge's own rules, then whatever this workspace adds (session 13). */
72
+ function composeSystemAppend(workspaceContext) {
73
+ return workspaceContext ? `${SYSTEM_APPEND}\n\n${workspaceContext}` : SYSTEM_APPEND;
74
+ }
70
75
  // Allowlist, not denylist: whatever secrets live in the daemon's environment
71
76
  // must not reach the agent process. OPENAI_API_KEY is absent by design — it
72
77
  // would override the subscription login the same way ANTHROPIC_API_KEY does for
@@ -113,8 +118,6 @@ const APPROVAL_METHODS = new Set([
113
118
  'item/permissions/requestApproval',
114
119
  'mcpServer/elicitation/request',
115
120
  ]);
116
- /** An unanswered agent question must not hold the turn forever. */
117
- const QUESTION_TIMEOUT_MS = 30 * 60_000;
118
121
  class CodexSession {
119
122
  spec;
120
123
  home;
@@ -127,7 +130,8 @@ class CodexSession {
127
130
  queuedInput = [];
128
131
  /** Null when the home was injected — an injected home is the whole truth. */
129
132
  repairHome;
130
- question = null;
133
+ /** Open elicitations, oldest first — Codex may have several in flight. */
134
+ questions = new Map();
131
135
  threadId = null;
132
136
  threadModel = null;
133
137
  activeTurnId = null;
@@ -340,7 +344,7 @@ class CodexSession {
340
344
  cwd: this.spec.cwd,
341
345
  approvalPolicy: policy.approvalPolicy,
342
346
  sandbox: policy.sandbox,
343
- developerInstructions: SYSTEM_APPEND,
347
+ developerInstructions: composeSystemAppend(this.spec.workspaceContext),
344
348
  ...(this.model ? { model: this.model } : {}),
345
349
  ...(this.spec.mcp ? { config: this.mcpOverlay() } : {}),
346
350
  };
@@ -395,15 +399,20 @@ class CodexSession {
395
399
  ...(this.effort ? { effort: this.effort } : {}),
396
400
  };
397
401
  // Collaboration mode is turn-scoped but sticky, and its `settings` REPLACE
398
- // the thread's developer instructions — so only send it on a real change,
399
- // and restore our own rules when leaving plan mode.
402
+ // the thread's developer instructions — so only send it on a real change.
403
+ //
404
+ // Session 13: they are sent for plan mode too. Passing `null` there did not
405
+ // mean «keep what the thread had», it meant «no developer instructions at
406
+ // all» — so the one mode a user picks when they want the agent to think
407
+ // before acting was the one mode where DevBridge's rules, the branch it is
408
+ // on and the project's own CLAUDE.md never reached it.
400
409
  if (this.lastCollabMode !== wantCollab) {
401
410
  params['collaborationMode'] = {
402
411
  mode: wantCollab,
403
412
  settings: {
404
413
  model: this.model ?? this.threadModel ?? 'gpt-5.5',
405
414
  reasoning_effort: null,
406
- developer_instructions: wantCollab === 'plan' ? null : SYSTEM_APPEND,
415
+ developer_instructions: composeSystemAppend(this.spec.workspaceContext),
407
416
  },
408
417
  };
409
418
  this.lastCollabMode = wantCollab;
@@ -434,9 +443,20 @@ class CodexSession {
434
443
  return;
435
444
  }
436
445
  // A parked elicitation takes priority: the agent is blocked on it, and
437
- // starting a turn would deadlock behind the open request.
438
- if (this.question) {
439
- this.answerQuestion(text);
446
+ // starting a turn would deadlock behind the open request. Typing is a
447
+ // legitimate way to answer (Telegram has no card), so the text becomes the
448
+ // answer to the oldest open ask.
449
+ const oldest = this.questions.values().next().value;
450
+ if (oldest) {
451
+ this.answerQuestion({
452
+ askId: oldest.askId,
453
+ action: 'answer',
454
+ answers: oldest.questions.map((question) => ({
455
+ questionId: question.id,
456
+ values: [],
457
+ custom: text,
458
+ })),
459
+ });
440
460
  return;
441
461
  }
442
462
  // Answering a plan card by typing instead of clicking is normal ("do it
@@ -509,6 +529,17 @@ class CodexSession {
509
529
  // we just dropped an unsupported pick), so the UI dial stays truthful.
510
530
  this.refreshCapabilities();
511
531
  }
532
+ /**
533
+ * Session 15: the project's trust level and auto-commit switch, changed while
534
+ * this session runs. `this.spec` is what `evaluateToolUse` reads on every
535
+ * tool call, so the next one already sees it.
536
+ */
537
+ setWorkspacePolicy(policy) {
538
+ if (policy.trustMode !== undefined)
539
+ this.spec.trustMode = policy.trustMode;
540
+ if (policy.agentAutoCommit !== undefined)
541
+ this.spec.agentAutoCommit = policy.agentAutoCommit;
542
+ }
512
543
  async setEffort(effort) {
513
544
  if (effort)
514
545
  this.effort = effort;
@@ -536,20 +567,37 @@ class CodexSession {
536
567
  log.warn('codex: interrupt failed', { error: describe(error) });
537
568
  }
538
569
  }
539
- stop() {
570
+ stop(reason = 'session_stopped') {
540
571
  if (this.stopped)
541
572
  return;
542
573
  this.stopped = true;
543
- // Release anything the agent is blocked on so the child can exit cleanly.
574
+ // Release anything the agent is blocked on so the child can exit cleanly —
575
+ // and say in the feed that the runner did it, not the user. A pending card
576
+ // used to gutter out looking like a human decision.
544
577
  for (const [requestId, pending] of [...this.approvals]) {
545
578
  this.approvals.delete(requestId);
579
+ this.emit({
580
+ type: 'permission_resolved',
581
+ requestId,
582
+ allow: false,
583
+ source: 'runner',
584
+ reason: invalidationMessage(reason),
585
+ });
546
586
  this.client.respond(pending.rpcId, { decision: 'cancel' });
547
587
  }
548
- if (this.question) {
549
- if (this.question.timer)
550
- clearTimeout(this.question.timer);
551
- this.client.respond(this.question.rpcId, { answers: {} });
552
- this.question = null;
588
+ for (const pending of [...this.questions.values()]) {
589
+ this.questions.delete(pending.askId);
590
+ this.emit({
591
+ type: 'question_resolved',
592
+ askId: pending.askId,
593
+ outcome: 'invalidated',
594
+ source: 'runner',
595
+ reason,
596
+ });
597
+ // Empty answers only because the process is being killed on the next
598
+ // line — the RPC has to be released or the child never exits. This is a
599
+ // teardown, not the 30-minute auto-answer it replaced.
600
+ this.client.respond(pending.rpcId, { answers: {} });
553
601
  }
554
602
  this.client.kill();
555
603
  }
@@ -575,6 +623,9 @@ class CodexSession {
575
623
  ? { decision: 'ask', reason: 'details unavailable' }
576
624
  : evaluateToolUse(enriched.policyTool, enriched.policyInput, {
577
625
  trustMode: this.spec.trustMode,
626
+ ...(this.spec.agentAutoCommit === undefined
627
+ ? {}
628
+ : { agentAutoCommit: this.spec.agentAutoCommit }),
578
629
  worktreePath: this.spec.cwd,
579
630
  });
580
631
  if (verdict.decision === 'allow') {
@@ -728,61 +779,99 @@ class CodexSession {
728
779
  this.client.respond(pending.rpcId, { decision: allow ? 'accept' : 'decline' });
729
780
  }
730
781
  onQuestion(request) {
731
- const questions = Array.isArray(request.params['questions']) ? request.params['questions'] : [];
732
- const first = asRecord(questions[0]);
733
- const questionId = str(first['id']);
734
- const text = str(first['question']) ?? str(first['header']);
735
- if (!questionId || !text) {
782
+ const questions = parseElicitation(request.params);
783
+ if (questions.length === 0) {
736
784
  // Nothing renderable — answer empty so the turn continues instead of
737
- // blocking forever.
785
+ // blocking forever on a card nobody can see.
738
786
  this.client.respond(request.id, { answers: {} });
739
787
  return;
740
788
  }
741
- const rawOptions = Array.isArray(first['options']) ? first['options'] : [];
742
- const options = rawOptions
743
- .map((option) => {
744
- const row = asRecord(option);
745
- const label = str(row['label']);
746
- if (!label)
747
- return null;
748
- const description = str(row['description']);
749
- return truncate(description ? `${label} — ${description}` : label, 300);
750
- })
751
- .filter((option) => Boolean(option))
752
- .slice(0, 8);
753
- // There is no server-side timeout for elicitations: an unanswered question
754
- // blocks the turn forever and the only way out is Stop (QA-100 MINOR-4).
755
- const timer = setTimeout(() => {
756
- if (this.question?.rpcId !== request.id)
757
- return;
758
- this.question = null;
759
- this.client.respond(request.id, { answers: {} });
760
- this.notice('warn', 'The agent question went unanswered for 30 minutes — it was skipped.');
761
- }, QUESTION_TIMEOUT_MS);
762
- timer.unref();
763
- this.question = { rpcId: request.id, questionId, timer };
789
+ const askId = newAskId();
790
+ this.questions.set(askId, { askId, rpcId: request.id, questions });
764
791
  this.emit({
765
792
  type: 'question',
766
- text: truncate(text, 2_000),
767
- ...(options.length ? { options } : {}),
793
+ askId,
794
+ questions,
795
+ // Mirror fields for an API/dashboard that has not been redeployed yet.
796
+ text: questions[0]?.text ?? '',
797
+ ...(mirrorOptions(questions).length ? { options: mirrorOptions(questions) } : {}),
768
798
  });
769
799
  }
770
800
  /**
771
- * The user's next message answers the open elicitation. The wire wants the
772
- * option label verbatim, and free text is legal whenever `isOther` is set —
773
- * so we forward whatever was typed, and the option list the dashboard shows
774
- * is made of those same labels.
801
+ * Answer (or talk past) an open elicitation.
802
+ *
803
+ * Codex has no "deny with a message" for elicitations — responding IS the
804
+ * only way to unblock the turn — so «discuss» reaches the agent as free-text
805
+ * answers on the questions that accept them. The wire wants an option label
806
+ * verbatim, and free text is legal whenever the question sets `isOther`.
775
807
  */
776
- answerQuestion(text) {
777
- const pending = this.question;
808
+ answerQuestion(reply) {
809
+ const pending = this.questions.get(reply.askId);
810
+ if (!pending) {
811
+ log.warn('codex: answer for a question that is no longer open', { askId: reply.askId });
812
+ return false;
813
+ }
814
+ this.questions.delete(reply.askId);
815
+ const byId = new Map(pending.questions.map((question) => [question.id, question]));
816
+ const answers = reply.action === 'discuss'
817
+ ? pending.questions.map((question) => ({
818
+ questionId: question.id,
819
+ values: [],
820
+ custom: reply.text ?? '',
821
+ }))
822
+ : (reply.answers ?? []).filter((answer) => byId.has(answer.questionId));
823
+ const wire = {};
824
+ for (const answer of answers) {
825
+ const question = byId.get(answer.questionId);
826
+ if (!question)
827
+ continue;
828
+ const labels = question.options.map((option) => option.label);
829
+ const values = [
830
+ // A picked option travels as its own label, never as the
831
+ // «label — description» line the card renders.
832
+ ...answer.values.map((value) => stripOptionHint(value, labels)),
833
+ ...(answer.custom?.trim() ? [stripOptionHint(answer.custom.trim(), labels)] : []),
834
+ ].filter(Boolean);
835
+ if (values.length === 0)
836
+ continue;
837
+ wire[question.id] = { answers: values };
838
+ }
839
+ // Never echo a secret answer into the feed — the event payload is stored
840
+ // for the retention window, and a token has no business there.
841
+ const secret = pending.questions.some((question) => question.secret);
842
+ this.emit({
843
+ type: 'question_resolved',
844
+ askId: reply.askId,
845
+ outcome: reply.action === 'discuss' ? 'discussed' : 'answered',
846
+ source: 'user',
847
+ ...(secret ? {} : { answers, summary: answerSummary(answers) }),
848
+ ...(secret ? { summary: '••••••' } : {}),
849
+ });
850
+ this.client.respond(pending.rpcId, { answers: wire });
851
+ return true;
852
+ }
853
+ cancelQuestions(reason) {
854
+ for (const pending of [...this.questions.values()]) {
855
+ this.invalidateQuestion(pending.askId, reason);
856
+ // Unlike a withdrawal seen from codex's side, this one has to release the
857
+ // rpc ourselves: an elicitation left parked keeps blocking, and `send()`
858
+ // would then feed the user's NEXT message into a question whose turn is
859
+ // already gone (QA-106 m7).
860
+ this.client.respond(pending.rpcId, { answers: {} });
861
+ }
862
+ }
863
+ /** Withdraw an open question with a reason, without answering for the user. */
864
+ invalidateQuestion(askId, reason) {
865
+ const pending = this.questions.get(askId);
778
866
  if (!pending)
779
867
  return;
780
- if (pending.timer)
781
- clearTimeout(pending.timer);
782
- this.question = null;
783
- const answer = stripOptionHint(text);
784
- this.client.respond(pending.rpcId, {
785
- answers: { [pending.questionId]: { answers: [answer] } },
868
+ this.questions.delete(askId);
869
+ this.emit({
870
+ type: 'question_resolved',
871
+ askId,
872
+ outcome: 'invalidated',
873
+ source: 'runner',
874
+ reason,
786
875
  });
787
876
  }
788
877
  // ─── Notifications ─────────────────────────────────────────────────
@@ -841,6 +930,13 @@ class CodexSession {
841
930
  // unknown outcome would put a false approval in the audit trail
842
931
  // (QA-100 MINOR-3).
843
932
  const resolved = String(params['requestId'] ?? '');
933
+ // A question resolved elsewhere is withdrawn, not answered — the card
934
+ // must stop being clickable and say why.
935
+ for (const pending of [...this.questions.values()]) {
936
+ if (String(pending.rpcId) === resolved) {
937
+ this.invalidateQuestion(pending.askId, 'agent_cancelled');
938
+ }
939
+ }
844
940
  if (this.approvals.delete(resolved)) {
845
941
  const decision = str(params['decision']) ?? str(asRecord(params['response'])['decision']);
846
942
  const allow = decision === 'accept' || decision === 'approved';
@@ -1333,10 +1429,73 @@ function basename(file) {
1333
1429
  function toolNameFromMessage(message) {
1334
1430
  return message?.match(/tool\s+"([^"]+)"/)?.[1];
1335
1431
  }
1336
- /** The dashboard shows "label — description"; the wire wants the label. */
1337
- export function stripOptionHint(text) {
1338
- const separator = text.indexOf(' — ');
1339
- return separator === -1 ? text : text.slice(0, separator);
1432
+ /**
1433
+ * The card shows "label — description"; the wire wants the bare label.
1434
+ *
1435
+ * Only strips when the text really is one of the offered options. The old
1436
+ * version cut every answer at the first « — », which quietly destroyed any
1437
+ * free-text answer containing a dash — "MariaDB — we already pay for it"
1438
+ * reached the agent as "MariaDB".
1439
+ */
1440
+ export function stripOptionHint(text, labels = []) {
1441
+ const trimmed = text.trim();
1442
+ for (const label of labels) {
1443
+ if (trimmed === label)
1444
+ return label;
1445
+ if (trimmed.startsWith(`${label} — `))
1446
+ return label;
1447
+ }
1448
+ return trimmed;
1449
+ }
1450
+ /**
1451
+ * One `item/tool/requestUserInput` elicitation → the normalized question list.
1452
+ *
1453
+ * Codex sends the whole list; before session 12 we kept `questions[0]` and
1454
+ * dropped the rest. `isOther` is a question-level flag meaning "free text is
1455
+ * acceptable here", and `isSecret` means the answer must not be echoed.
1456
+ */
1457
+ export function parseElicitation(params) {
1458
+ const raw = Array.isArray(params['questions']) ? params['questions'] : [];
1459
+ const questions = [];
1460
+ for (const entry of raw.slice(0, MAX_QUESTIONS)) {
1461
+ const row = asRecord(entry);
1462
+ const id = str(row['id']);
1463
+ const text = str(row['question']) ?? str(row['header']);
1464
+ if (!id || !text)
1465
+ continue;
1466
+ const header = str(row['header']);
1467
+ const rawOptions = Array.isArray(row['options']) ? row['options'] : [];
1468
+ const options = rawOptions
1469
+ .map((option) => {
1470
+ const optionRow = asRecord(option);
1471
+ const label = str(optionRow['label']);
1472
+ if (!label)
1473
+ return null;
1474
+ const description = str(optionRow['description']);
1475
+ return {
1476
+ label: truncate(label, OPTION_TEXT_LIMIT),
1477
+ ...(description ? { description: truncate(description, OPTION_TEXT_LIMIT) } : {}),
1478
+ };
1479
+ })
1480
+ .filter((option) => option !== null)
1481
+ .slice(0, MAX_OPTIONS);
1482
+ questions.push({
1483
+ id,
1484
+ text: truncate(text, QUESTION_TEXT_LIMIT),
1485
+ ...(header ? { header: header.slice(0, 24) } : {}),
1486
+ multiSelect: row['multiSelect'] === true,
1487
+ // Free text is offered only when codex says it is acceptable, or when
1488
+ // there is nothing to pick from at all. The flag sits on the question in
1489
+ // 0.135.0; an option-level one is honoured too rather than silently
1490
+ // taking the free-text box away if a later build moves it.
1491
+ allowsCustom: row['isOther'] === true ||
1492
+ rawOptions.some((option) => asRecord(option)['isOther'] === true) ||
1493
+ options.length === 0,
1494
+ ...(row['isSecret'] === true ? { secret: true } : {}),
1495
+ options,
1496
+ });
1497
+ }
1498
+ return questions;
1340
1499
  }
1341
1500
  /**
1342
1501
  * `supportedReasoningEfforts` from `model/list`: a list of
@@ -0,0 +1,42 @@
1
+ import type { AgentQuestion, AgentQuestionAnswer, QuestionInvalidationReason } from './types.js';
2
+ /** Longest question text we put on the wire (the API caps a payload at 128KB). */
3
+ export declare const QUESTION_TEXT_LIMIT = 2000;
4
+ /** Longest option label/description; the card truncates visually beyond that. */
5
+ export declare const OPTION_TEXT_LIMIT = 300;
6
+ /** Options we keep per question — a picker with more is unusable anyway. */
7
+ export declare const MAX_OPTIONS = 8;
8
+ /** Questions we keep per ask (Claude's own tool allows at most 4). */
9
+ export declare const MAX_QUESTIONS = 4;
10
+ /**
11
+ * Local copy of the adapters' `truncate`, on purpose: importing it from
12
+ * `claude.ts` would make this module and the Claude adapter import each other,
13
+ * and a cycle is not worth six lines.
14
+ */
15
+ export declare function clip(text: string, limit: number): string;
16
+ /**
17
+ * Identity of one ask.
18
+ *
19
+ * Deliberately random and NOT derived from the SDK request id or the Codex rpc
20
+ * id: those restart from scratch in a new agent process, so a card left open in
21
+ * a browser tab from a previous life of the session would answer a live
22
+ * question that has nothing to do with it (session-12 plan §4.3).
23
+ */
24
+ export declare function newAskId(): string;
25
+ /** The pre-session-12 `options: string[]` shape — «label — description». */
26
+ export declare function mirrorOptions(questions: AgentQuestion[]): string[];
27
+ /** What one answer says, as a single line: «Postgres», «Auth, Search». */
28
+ export declare function answerValue(answer: AgentQuestionAnswer): string;
29
+ /** One short line for the resolved card: «Postgres · Auth, Search». */
30
+ export declare function answerSummary(answers: AgentQuestionAnswer[]): string;
31
+ /**
32
+ * The «discuss instead» exit.
33
+ *
34
+ * Goes back as a tool DENIAL carrying the user's words. Live-verified against
35
+ * the bundled CLI (session-12 §3): a deny with a message does not end the turn
36
+ * — the text arrives as the tool result and the agent keeps working — while a
37
+ * deny with no message, or `interrupt: true`, does end it.
38
+ */
39
+ export declare function discussMessage(questions: AgentQuestion[], text: string): string;
40
+ /** The question was taken away from the user — say why, never fake an answer. */
41
+ export declare function invalidationMessage(reason: QuestionInvalidationReason): string;
42
+ //# sourceMappingURL=questions.d.ts.map
@@ -0,0 +1,86 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ // Everything both adapters need to speak the session-12 question contract in
3
+ // one voice: the id that identifies an ask, the mirror fields older clients
4
+ // still read, and the three sentences the agent gets back.
5
+ /** Longest question text we put on the wire (the API caps a payload at 128KB). */
6
+ export const QUESTION_TEXT_LIMIT = 2_000;
7
+ /** Longest option label/description; the card truncates visually beyond that. */
8
+ export const OPTION_TEXT_LIMIT = 300;
9
+ /** Options we keep per question — a picker with more is unusable anyway. */
10
+ export const MAX_OPTIONS = 8;
11
+ /** Questions we keep per ask (Claude's own tool allows at most 4). */
12
+ export const MAX_QUESTIONS = 4;
13
+ /**
14
+ * Local copy of the adapters' `truncate`, on purpose: importing it from
15
+ * `claude.ts` would make this module and the Claude adapter import each other,
16
+ * and a cycle is not worth six lines.
17
+ */
18
+ export function clip(text, limit) {
19
+ return text.length > limit
20
+ ? `${text.slice(0, limit)}\n…[truncated ${text.length - limit} chars]`
21
+ : text;
22
+ }
23
+ /**
24
+ * Identity of one ask.
25
+ *
26
+ * Deliberately random and NOT derived from the SDK request id or the Codex rpc
27
+ * id: those restart from scratch in a new agent process, so a card left open in
28
+ * a browser tab from a previous life of the session would answer a live
29
+ * question that has nothing to do with it (session-12 plan §4.3).
30
+ */
31
+ export function newAskId() {
32
+ return `q_${randomBytes(6).toString('hex')}`;
33
+ }
34
+ /** The pre-session-12 `options: string[]` shape — «label — description». */
35
+ export function mirrorOptions(questions) {
36
+ const first = questions[0];
37
+ if (!first)
38
+ return [];
39
+ return first.options
40
+ .map((option) => clip(option.description ? `${option.label} — ${option.description}` : option.label, OPTION_TEXT_LIMIT))
41
+ .slice(0, MAX_OPTIONS);
42
+ }
43
+ /** What one answer says, as a single line: «Postgres», «Auth, Search». */
44
+ export function answerValue(answer) {
45
+ const parts = [...answer.values.map((value) => value.trim()).filter(Boolean)];
46
+ const custom = answer.custom?.trim();
47
+ if (custom)
48
+ parts.push(custom);
49
+ return parts.join(', ');
50
+ }
51
+ /** One short line for the resolved card: «Postgres · Auth, Search». */
52
+ export function answerSummary(answers) {
53
+ return clip(answers.map(answerValue).filter(Boolean).join(' · '), OPTION_TEXT_LIMIT);
54
+ }
55
+ /**
56
+ * The «discuss instead» exit.
57
+ *
58
+ * Goes back as a tool DENIAL carrying the user's words. Live-verified against
59
+ * the bundled CLI (session-12 §3): a deny with a message does not end the turn
60
+ * — the text arrives as the tool result and the agent keeps working — while a
61
+ * deny with no message, or `interrupt: true`, does end it.
62
+ */
63
+ export function discussMessage(questions, text) {
64
+ const asked = questions.map((question) => `"${question.text}"`).join('; ');
65
+ return clip([
66
+ 'The user chose to reply in their own words instead of picking an option.',
67
+ `They wrote: ${text.trim() || '(nothing)'}`,
68
+ asked ? `Still unanswered: ${asked}.` : '',
69
+ 'Continue from what they wrote. Do not answer these questions on their behalf — ask again if you still need a decision.',
70
+ ]
71
+ .filter(Boolean)
72
+ .join('\n'), 8_000);
73
+ }
74
+ const INVALIDATION_PHRASES = {
75
+ session_stopped: 'the session was stopped',
76
+ session_parked: 'the runner parked this session to free a slot for another one',
77
+ runner_restarted: 'the runner restarted',
78
+ turn_aborted: 'the turn was interrupted',
79
+ agent_cancelled: 'the agent withdrew it',
80
+ budget_spent: 'the session ran out of its allowed working time',
81
+ };
82
+ /** The question was taken away from the user — say why, never fake an answer. */
83
+ export function invalidationMessage(reason) {
84
+ return `The question was withdrawn before the user answered — ${INVALIDATION_PHRASES[reason]}. Do not assume an answer.`;
85
+ }
86
+ //# sourceMappingURL=questions.js.map