@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.
- package/dist/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +435 -32
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +7 -0
- package/dist/self-update.js +171 -23
- package/dist/service-unit.d.ts +79 -0
- package/dist/service-unit.js +211 -0
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
47
|
-
|
|
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
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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 =
|
|
732
|
-
|
|
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
|
|
742
|
-
|
|
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
|
-
|
|
767
|
-
|
|
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
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
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(
|
|
777
|
-
const pending = this.
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
/**
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|