@bridge4dev/runner 0.11.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/adapters/claude.d.ts +19 -0
  4. package/dist/adapters/claude.js +631 -0
  5. package/dist/adapters/codex-home.d.ts +61 -0
  6. package/dist/adapters/codex-home.js +234 -0
  7. package/dist/adapters/codex-protocol.d.ts +59 -0
  8. package/dist/adapters/codex-protocol.js +204 -0
  9. package/dist/adapters/codex.d.ts +61 -0
  10. package/dist/adapters/codex.js +1406 -0
  11. package/dist/adapters/types.d.ts +183 -0
  12. package/dist/adapters/types.js +5 -0
  13. package/dist/async-queue.d.ts +11 -0
  14. package/dist/async-queue.js +50 -0
  15. package/dist/attachments.d.ts +72 -0
  16. package/dist/attachments.js +149 -0
  17. package/dist/auth-relay.d.ts +57 -0
  18. package/dist/auth-relay.js +289 -0
  19. package/dist/config.d.ts +96 -0
  20. package/dist/config.js +73 -0
  21. package/dist/fsview.d.ts +20 -0
  22. package/dist/fsview.js +122 -0
  23. package/dist/git.d.ts +54 -0
  24. package/dist/git.js +168 -0
  25. package/dist/gitops.d.ts +136 -0
  26. package/dist/gitops.js +596 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +352 -0
  29. package/dist/journal.d.ts +118 -0
  30. package/dist/journal.js +300 -0
  31. package/dist/log.d.ts +7 -0
  32. package/dist/log.js +19 -0
  33. package/dist/paths.d.ts +7 -0
  34. package/dist/paths.js +33 -0
  35. package/dist/policy.d.ts +17 -0
  36. package/dist/policy.js +272 -0
  37. package/dist/protocol.d.ts +754 -0
  38. package/dist/protocol.js +154 -0
  39. package/dist/self-update.d.ts +75 -0
  40. package/dist/self-update.js +221 -0
  41. package/dist/status-file.d.ts +14 -0
  42. package/dist/status-file.js +29 -0
  43. package/dist/supervisor.d.ts +216 -0
  44. package/dist/supervisor.js +1648 -0
  45. package/dist/version.d.ts +2 -0
  46. package/dist/version.js +3 -0
  47. package/dist/ws-client.d.ts +30 -0
  48. package/dist/ws-client.js +171 -0
  49. package/package.json +52 -0
@@ -0,0 +1,631 @@
1
+ import { query, } from '@anthropic-ai/claude-agent-sdk';
2
+ import { AsyncQueue } from '../async-queue.js';
3
+ import { log } from '../log.js';
4
+ import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
5
+ import { AGENT_MODES, } from './types.js';
6
+ // Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
7
+ // 1. Bare tool names in `allowedTools` auto-approve BEFORE canUseTool — we
8
+ // never populate allowedTools with gated tools (we don't set it at all).
9
+ // 2. Sandbox-safe commands run without a canUseTool callback — audit relies
10
+ // on tool events, not on permission requests.
11
+ // 3. The bundled CLI inherits host env/settings (IS_SANDBOX=1 silently
12
+ // disabled permissions on our own dev host) — env is scrubbed and
13
+ // settingSources is empty.
14
+ const TEXT_LIMIT = 16_000; // events are capped at 128KB server-side; stay far below
15
+ export function truncate(text, limit = TEXT_LIMIT) {
16
+ return text.length > limit
17
+ ? `${text.slice(0, limit)}\n…[truncated ${text.length - limit} chars]`
18
+ : text;
19
+ }
20
+ // Allowlist, not denylist (QA-96 F12): whatever secrets live in the daemon's
21
+ // environment (CI creds, DATABASE_URL, …) must not reach the agent process.
22
+ // Notably absent by design: ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN (silently
23
+ // override subscription auth) and IS_SANDBOX (kills permission prompts).
24
+ const ENV_ALLOWLIST = [
25
+ 'PATH',
26
+ 'HOME',
27
+ 'USER',
28
+ 'LOGNAME',
29
+ 'SHELL',
30
+ 'LANG',
31
+ 'LANGUAGE',
32
+ 'LC_ALL',
33
+ 'LC_CTYPE',
34
+ 'TERM',
35
+ 'TMPDIR',
36
+ 'TZ',
37
+ 'COLORTERM',
38
+ 'XDG_RUNTIME_DIR',
39
+ 'XDG_DATA_HOME',
40
+ 'XDG_CONFIG_HOME',
41
+ 'XDG_CACHE_HOME',
42
+ 'HTTP_PROXY',
43
+ 'HTTPS_PROXY',
44
+ 'NO_PROXY',
45
+ 'http_proxy',
46
+ 'https_proxy',
47
+ 'no_proxy',
48
+ 'SSL_CERT_FILE',
49
+ 'SSL_CERT_DIR',
50
+ 'NODE_EXTRA_CA_CERTS',
51
+ // Subscription token — legitimate agent auth when the user configured it.
52
+ 'CLAUDE_CODE_OAUTH_TOKEN',
53
+ ];
54
+ function scrubbedEnv() {
55
+ const env = {};
56
+ for (const key of ENV_ALLOWLIST) {
57
+ const value = process.env[key];
58
+ if (value !== undefined)
59
+ env[key] = value;
60
+ }
61
+ return env;
62
+ }
63
+ // Normalized mode → Claude permission mode (session-5 plan §2). `full` is the
64
+ // owner's explicit call (2026-07-24): "same as Claude works now, we don't
65
+ // restrict anything" — in that mode the SDK stops calling canUseTool at all,
66
+ // so the layer-1 policy cannot gate tools either; the dashboard says so.
67
+ const MODE_TO_PERMISSION = {
68
+ ask: 'default',
69
+ plan: 'plan',
70
+ auto: 'acceptEdits',
71
+ full: 'bypassPermissions',
72
+ };
73
+ const SYSTEM_APPEND = [
74
+ 'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
75
+ 'Rules:',
76
+ '- Work ONLY inside the current working directory (a dedicated git worktree on a session branch).',
77
+ '- Commit your work in the current branch with clear messages. NEVER push to main/master and never force-push.',
78
+ '- If DevBridge MCP tools (mcp__devbridge__*) 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.',
79
+ '- The user is not in a terminal: if you need a decision or information, ask the question in plain text and end your turn — the answer arrives as the next user message.',
80
+ '- Never print secrets (tokens, API keys, private keys) in your output.',
81
+ ].join('\n');
82
+ class ClaudeSession {
83
+ spec;
84
+ input = new AsyncQueue();
85
+ output = new AsyncQueue();
86
+ pending = new Map();
87
+ q;
88
+ stopped = false;
89
+ /** Guards against overlapping capability probes. */
90
+ capabilitiesInFlight = false;
91
+ mode;
92
+ model;
93
+ events = this.output;
94
+ constructor(spec, queryFn) {
95
+ this.spec = spec;
96
+ this.mode = spec.mode ?? 'ask';
97
+ if (spec.model)
98
+ this.model = spec.model;
99
+ // Free CHAT sessions start with no prompt: the process boots, reports its
100
+ // capabilities and waits for the first message (live-verified 2026-07-24).
101
+ if (spec.prompt && spec.prompt.trim())
102
+ this.pushUserText(spec.prompt);
103
+ const options = {
104
+ cwd: spec.cwd,
105
+ env: scrubbedEnv(),
106
+ settingSources: [],
107
+ permissionMode: MODE_TO_PERMISSION[this.mode],
108
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: SYSTEM_APPEND },
109
+ canUseTool: (toolName, input, opts) => this.onCanUseTool(toolName, input, opts),
110
+ ...(spec.model ? { model: spec.model } : {}),
111
+ ...(spec.resumeProviderSessionId ? { resume: spec.resumeProviderSessionId } : {}),
112
+ ...(spec.maxBudgetUsd !== undefined ? { maxBudgetUsd: spec.maxBudgetUsd } : {}),
113
+ ...(spec.mcp
114
+ ? {
115
+ mcpServers: {
116
+ devbridge: {
117
+ type: 'http',
118
+ url: spec.mcp.url,
119
+ headers: { Authorization: `Bearer ${spec.mcp.token}` },
120
+ },
121
+ },
122
+ }
123
+ : {}),
124
+ };
125
+ this.q = queryFn({ prompt: this.input, options });
126
+ void this.consume();
127
+ // Report what the agent can do right away. `system:init` only arrives with
128
+ // the first turn (verified live), so a free session waiting for its first
129
+ // message would otherwise show no model list at all — while the control
130
+ // requests themselves work as soon as the CLI is up.
131
+ this.refreshCapabilities();
132
+ }
133
+ pushUserText(text) {
134
+ this.input.push({
135
+ type: 'user',
136
+ message: { role: 'user', content: text },
137
+ parent_tool_use_id: null,
138
+ });
139
+ }
140
+ emit(event) {
141
+ this.output.push(maskSecrets(event));
142
+ }
143
+ /**
144
+ * How full the context window is, after a finished turn. Fire-and-forget with
145
+ * a timeout on purpose: this is one more control round-trip on a channel that
146
+ * can hang, and it must never be able to stall the event loop that carries the
147
+ * conversation.
148
+ */
149
+ refreshContextUsage() {
150
+ if (this.stopped)
151
+ return;
152
+ // The SDK marks this API experimental; an older or newer CLI may not have
153
+ // it at all, and a throw here would tear down the whole event loop.
154
+ if (typeof this.q.getContextUsage !== 'function')
155
+ return;
156
+ let usage;
157
+ try {
158
+ usage = this.q.getContextUsage();
159
+ }
160
+ catch (error) {
161
+ log.warn('claude: context usage unavailable', { error: String(error) });
162
+ return;
163
+ }
164
+ void Promise.race([
165
+ usage,
166
+ new Promise((resolve) => setTimeout(() => resolve(null), 5_000).unref()),
167
+ ])
168
+ .then((result) => {
169
+ if (!result || this.stopped)
170
+ return;
171
+ if (typeof result.totalTokens !== 'number' || typeof result.maxTokens !== 'number')
172
+ return;
173
+ if (result.maxTokens <= 0)
174
+ return;
175
+ this.emit({
176
+ type: 'context_usage',
177
+ usedTokens: result.totalTokens,
178
+ maxTokens: result.maxTokens,
179
+ });
180
+ })
181
+ .catch((error) => log.warn('claude: context usage probe failed', { error: String(error) }));
182
+ }
183
+ /** Fire-and-forget capability refresh — never throws into the caller. */
184
+ refreshCapabilities() {
185
+ if (this.capabilitiesInFlight || this.stopped)
186
+ return;
187
+ this.capabilitiesInFlight = true;
188
+ void this.publishCapabilities()
189
+ .catch((error) => log.warn('claude: capabilities probe failed', { error: String(error) }))
190
+ .finally(() => {
191
+ this.capabilitiesInFlight = false;
192
+ });
193
+ }
194
+ /**
195
+ * Ask the live CLI what it can do (models, commands, account, MCP) and push
196
+ * it to the dashboard. Everything is best-effort: a control request that
197
+ * fails must never take the session down.
198
+ */
199
+ async publishCapabilities() {
200
+ const [models, commands, account, mcpServers] = await Promise.all([
201
+ this.q.supportedModels().catch((error) => {
202
+ log.warn('claude: supportedModels failed', { error: String(error) });
203
+ return [];
204
+ }),
205
+ this.q.supportedCommands().catch(() => []),
206
+ this.q.accountInfo().catch(() => null),
207
+ this.q.mcpServerStatus().catch(() => []),
208
+ ]);
209
+ const capabilities = {
210
+ // `default` is the alias row the CLI itself resolves to the account's
211
+ // recommended model — this SDK version has no isDefault flag.
212
+ models: models.map((m) => ({
213
+ id: m.value,
214
+ label: m.displayName,
215
+ ...(m.description ? { description: truncate(m.description, 160) } : {}),
216
+ ...(m.value === 'default' ? { isDefault: true } : {}),
217
+ })),
218
+ modes: [...AGENT_MODES],
219
+ // Cap the list: it lands in an event payload with a hard size limit.
220
+ commands: commands.slice(0, 150).map((c) => ({
221
+ name: c.name,
222
+ ...(c.description ? { description: truncate(c.description, 160) } : {}),
223
+ ...(c.argumentHint ? { argumentHint: truncate(c.argumentHint, 60) } : {}),
224
+ })),
225
+ currentMode: this.mode,
226
+ ...(this.model ? { currentModel: this.model } : {}),
227
+ ...(account
228
+ ? {
229
+ account: {
230
+ ...(account.email ? { email: account.email } : {}),
231
+ ...(account.organization ? { organization: account.organization } : {}),
232
+ ...(account.subscriptionType ? { plan: account.subscriptionType } : {}),
233
+ },
234
+ }
235
+ : {}),
236
+ mcpServers: mcpServers.map((s) => ({ name: s.name, status: String(s.status) })),
237
+ interrupt: true,
238
+ };
239
+ this.emit({ type: 'capabilities', capabilities });
240
+ }
241
+ async setModel(model) {
242
+ await this.q.setModel(model);
243
+ this.model = model;
244
+ this.emit({ type: 'settings', model });
245
+ // Claude's models differ in context size (200k vs 1M), and the meter's
246
+ // denominator only refreshes at the end of a turn — so switching model mid
247
+ // session left the gauge reporting the OLD model's window, off by 5x in the
248
+ // worst case. Fire-and-forget, same as the end-of-turn call.
249
+ this.refreshContextUsage();
250
+ }
251
+ async setMode(mode) {
252
+ await this.q.setPermissionMode(MODE_TO_PERMISSION[mode]);
253
+ this.mode = mode;
254
+ this.emit({ type: 'settings', mode });
255
+ }
256
+ async setEffort(_effort) {
257
+ // Claude Code has no per-model reasoning dial: its models advertise no
258
+ // `efforts`, so the dashboard never shows the control for this agent.
259
+ }
260
+ async onCanUseTool(toolName, input, opts) {
261
+ // The agent's interactive question tool assumes a terminal picker. There
262
+ // is none here, and its raw JSON is unreadable in a permission card — turn
263
+ // it into a proper question card and let the user answer in the chat.
264
+ if (toolName === 'AskUserQuestion') {
265
+ const question = parseAskUserQuestion(input);
266
+ if (question) {
267
+ this.emit({
268
+ type: 'question',
269
+ text: question.text,
270
+ ...(question.options.length ? { options: question.options } : {}),
271
+ });
272
+ return {
273
+ behavior: 'deny',
274
+ message: 'This session has no interactive picker. Ask the question in plain text and end your turn — the user answers with the next message.',
275
+ };
276
+ }
277
+ }
278
+ // Plan approval is a user decision by definition — never auto-resolved by
279
+ // policy, and rendered as a plan card rather than a raw tool prompt.
280
+ if (toolName === 'ExitPlanMode') {
281
+ const plan = typeof input['plan'] === 'string' ? input['plan'] : '';
282
+ this.emit({
283
+ type: 'permission',
284
+ requestId: opts.requestId,
285
+ toolName,
286
+ title: 'The agent proposes a plan',
287
+ input: {},
288
+ plan: truncate(plan, 12_000),
289
+ });
290
+ return this.waitForAnswer(opts, toolName);
291
+ }
292
+ const verdict = evaluateToolUse(toolName, input, {
293
+ trustMode: this.spec.trustMode,
294
+ worktreePath: this.spec.cwd,
295
+ });
296
+ if (verdict.decision === 'allow') {
297
+ return { behavior: 'allow', updatedInput: input };
298
+ }
299
+ if (verdict.decision === 'deny') {
300
+ this.emit({
301
+ type: 'permission_resolved',
302
+ requestId: opts.requestId,
303
+ allow: false,
304
+ source: 'policy',
305
+ reason: verdict.reason,
306
+ });
307
+ return { behavior: 'deny', message: `Denied by DevBridge policy: ${verdict.reason}` };
308
+ }
309
+ // ask → surface a permission card and wait for the dashboard's answer.
310
+ this.emit({
311
+ type: 'permission',
312
+ requestId: opts.requestId,
313
+ toolName,
314
+ title: opts.title ?? `Allow ${toolName}?`,
315
+ ...(opts.description ? { description: opts.description } : {}),
316
+ input: truncateInput(input),
317
+ });
318
+ return this.waitForAnswer(opts, toolName);
319
+ }
320
+ /** Park the tool call until the dashboard answers (or the request aborts). */
321
+ waitForAnswer(opts, toolName) {
322
+ return new Promise((resolve) => {
323
+ const settle = (result) => {
324
+ if (this.pending.delete(opts.requestId))
325
+ resolve(result);
326
+ };
327
+ this.pending.set(opts.requestId, {
328
+ toolName,
329
+ resolve: settle,
330
+ });
331
+ opts.signal.addEventListener('abort', () => {
332
+ this.emit({
333
+ type: 'permission_resolved',
334
+ requestId: opts.requestId,
335
+ allow: false,
336
+ source: 'abort',
337
+ });
338
+ settle({ behavior: 'deny', message: 'Request aborted' });
339
+ }, { once: true });
340
+ });
341
+ }
342
+ answerPermission(requestId, allow, note) {
343
+ const pending = this.pending.get(requestId);
344
+ if (!pending) {
345
+ log.warn('claude: permission answer for unknown request', { requestId });
346
+ return;
347
+ }
348
+ this.emit({
349
+ type: 'permission_resolved',
350
+ requestId,
351
+ allow,
352
+ source: 'user',
353
+ ...(note ? { reason: note } : {}),
354
+ });
355
+ if (allow) {
356
+ pending.resolve({ behavior: 'allow' });
357
+ }
358
+ else {
359
+ pending.resolve({
360
+ behavior: 'deny',
361
+ message: note ? `Denied from dashboard: ${note}` : 'Denied from dashboard',
362
+ });
363
+ }
364
+ }
365
+ send(text) {
366
+ const accepted = this.input.push({
367
+ type: 'user',
368
+ message: { role: 'user', content: text },
369
+ parent_tool_use_id: null,
370
+ });
371
+ if (!accepted) {
372
+ log.warn('claude: send after session ended — message dropped', {
373
+ sessionId: this.spec.sessionId,
374
+ });
375
+ }
376
+ }
377
+ async interrupt() {
378
+ try {
379
+ await this.q.interrupt();
380
+ }
381
+ catch (error) {
382
+ log.warn('claude: interrupt failed', { error: String(error) });
383
+ }
384
+ }
385
+ stop() {
386
+ if (this.stopped)
387
+ return;
388
+ this.stopped = true;
389
+ this.input.end();
390
+ try {
391
+ this.q.close();
392
+ }
393
+ catch {
394
+ // process already gone
395
+ }
396
+ }
397
+ async consume() {
398
+ try {
399
+ for await (const msg of this.q) {
400
+ switch (msg.type) {
401
+ case 'system': {
402
+ if (msg.subtype === 'init') {
403
+ this.emit({
404
+ type: 'provider_session',
405
+ providerSessionId: msg.session_id,
406
+ model: msg.model,
407
+ });
408
+ // The live model can differ from what we asked for (alias
409
+ // resolution, fallback) — refresh the pickers when it does.
410
+ if (msg.model && msg.model !== this.model) {
411
+ this.model = msg.model;
412
+ this.refreshCapabilities();
413
+ }
414
+ }
415
+ else if (msg.subtype === 'status') {
416
+ const status = msg.status;
417
+ const compactResult = msg.compact_result;
418
+ if (status === 'compacting') {
419
+ this.emit({ type: 'notice', level: 'info', text: 'Compacting the conversation…' });
420
+ }
421
+ else if (compactResult) {
422
+ this.emit({
423
+ type: 'notice',
424
+ level: compactResult === 'failed' ? 'warn' : 'info',
425
+ text: compactResult === 'failed'
426
+ ? `Compaction failed: ${maskString(String(msg.compact_error ?? 'unknown')).slice(0, 300)}`
427
+ : 'Conversation compacted',
428
+ });
429
+ }
430
+ }
431
+ break;
432
+ }
433
+ case 'conversation_reset': {
434
+ this.emit({ type: 'notice', level: 'info', text: 'Context cleared (/clear)' });
435
+ break;
436
+ }
437
+ case 'assistant': {
438
+ for (const block of msg.message.content) {
439
+ if (block.type === 'text' && block.text.trim()) {
440
+ this.emit({ type: 'message', role: 'assistant', text: truncate(block.text) });
441
+ }
442
+ else if (block.type === 'thinking' && block.thinking.trim()) {
443
+ this.emit({ type: 'thinking', text: truncate(block.thinking, 8_000) });
444
+ }
445
+ else if (block.type === 'tool_use') {
446
+ this.emit({
447
+ type: 'tool',
448
+ phase: 'use',
449
+ name: block.name,
450
+ toolUseId: block.id,
451
+ detail: truncateInput(block.input),
452
+ });
453
+ }
454
+ }
455
+ break;
456
+ }
457
+ case 'user': {
458
+ const content = msg.message.content;
459
+ if (Array.isArray(content)) {
460
+ for (const block of content) {
461
+ if (block.type === 'tool_result') {
462
+ this.emit({
463
+ type: 'tool',
464
+ phase: 'result',
465
+ name: 'tool_result',
466
+ toolUseId: block.tool_use_id,
467
+ text: truncate(stringifyContent(block.content), 4_000),
468
+ });
469
+ }
470
+ }
471
+ }
472
+ break;
473
+ }
474
+ case 'result': {
475
+ this.emit({
476
+ type: 'cost',
477
+ costUsd: msg.total_cost_usd,
478
+ numTurns: msg.num_turns,
479
+ durationMs: msg.duration_ms,
480
+ });
481
+ this.refreshContextUsage();
482
+ if (msg.subtype === 'success') {
483
+ this.emit({ type: 'turn_end', ok: true });
484
+ }
485
+ else {
486
+ this.emit({
487
+ type: 'turn_end',
488
+ ok: false,
489
+ errorMessage: classifyError(msg.subtype, msg.errors),
490
+ });
491
+ }
492
+ break;
493
+ }
494
+ default:
495
+ break; // partial/status/etc — not forwarded in v0
496
+ }
497
+ }
498
+ }
499
+ catch (error) {
500
+ const message = maskString(String(error instanceof Error ? error.message : error));
501
+ const code = errorCode(message);
502
+ this.emit({
503
+ type: 'error',
504
+ message: classifyRunError(message),
505
+ ...(code ? { code } : {}),
506
+ });
507
+ }
508
+ finally {
509
+ this.stopped = true;
510
+ this.input.end();
511
+ this.output.end();
512
+ }
513
+ }
514
+ }
515
+ // Recursive: MultiEdit-style inputs nest long strings inside arrays — a
516
+ // shallow pass let them blow past the API's 128KB event cap (QA-96 F2).
517
+ function truncateDeep(value, limit) {
518
+ if (typeof value === 'string')
519
+ return truncate(value, limit);
520
+ if (Array.isArray(value))
521
+ return value.slice(0, 50).map((v) => truncateDeep(v, limit));
522
+ if (value && typeof value === 'object') {
523
+ const out = {};
524
+ for (const [k, v] of Object.entries(value)) {
525
+ out[k] = truncateDeep(v, limit);
526
+ }
527
+ return out;
528
+ }
529
+ return value;
530
+ }
531
+ /**
532
+ * Flatten the AskUserQuestion payload into plain text + option labels.
533
+ * Shape: `{ questions: [{ question, header, options: [{ label, description }] }] }`.
534
+ */
535
+ export function parseAskUserQuestion(input) {
536
+ const questions = input['questions'];
537
+ const first = Array.isArray(questions) ? questions[0] : undefined;
538
+ if (!first || typeof first !== 'object')
539
+ return null;
540
+ const row = first;
541
+ const text = typeof row['question'] === 'string' ? row['question'] : null;
542
+ if (!text)
543
+ return null;
544
+ const rawOptions = Array.isArray(row['options']) ? row['options'] : [];
545
+ const options = rawOptions
546
+ .map((option) => {
547
+ if (typeof option === 'string')
548
+ return option;
549
+ const entry = option;
550
+ const label = typeof entry['label'] === 'string' ? entry['label'] : null;
551
+ if (!label)
552
+ return null;
553
+ const description = typeof entry['description'] === 'string' ? entry['description'] : undefined;
554
+ return description ? `${label} — ${description}` : label;
555
+ })
556
+ .filter((option) => Boolean(option))
557
+ .map((option) => truncate(option, 300))
558
+ .slice(0, 8);
559
+ return { text: truncate(text, 2_000), options };
560
+ }
561
+ function truncateInput(input) {
562
+ return truncateDeep(input, 2_000);
563
+ }
564
+ function stringifyContent(content) {
565
+ if (typeof content === 'string')
566
+ return content;
567
+ if (Array.isArray(content)) {
568
+ return content
569
+ .map((c) => typeof c === 'object' && c && 'text' in c ? String(c.text) : '')
570
+ .join('\n');
571
+ }
572
+ return content === undefined ? '' : JSON.stringify(content);
573
+ }
574
+ function classifyError(subtype, errors) {
575
+ const detail = errors.length ? `: ${maskString(errors.join('; ').slice(0, 500))}` : '';
576
+ switch (subtype) {
577
+ case 'error_max_budget_usd':
578
+ return 'Session budget (USD) exceeded — the run was stopped';
579
+ case 'error_max_turns':
580
+ return 'Maximum turns reached';
581
+ default:
582
+ return `Agent run failed (${subtype})${detail}`;
583
+ }
584
+ }
585
+ /**
586
+ * A bare `401` used to be enough here, which meant a 401 from an MCP server or
587
+ * a proxy was reported to the user as "your Claude login expired". Require the
588
+ * message to actually be about credentials.
589
+ */
590
+ function isAuthError(message) {
591
+ if (/\bmcp\b/i.test(message))
592
+ return false;
593
+ if (/login expired|please run \/login|oauth token.*(expired|revoked)/i.test(message))
594
+ return true;
595
+ // The corroborating word must be a real one: `\bauth\b` and not a bare
596
+ // `auth` substring, because "unauthorized" contains "auth" and would happily
597
+ // vouch for itself.
598
+ return (/\b401\b|unauthorized/i.test(message) &&
599
+ /\btoken\b|\bauth\b|\bauthentication\b|\blogin\b|\bsign ?in\b|\bcredentials?\b/i.test(message));
600
+ }
601
+ /** A resume id the CLI no longer knows — the supervisor retries from scratch. */
602
+ function isResumeError(message) {
603
+ return /no conversation found|session .{0,40}not found|could not resume|invalid session id/i.test(message);
604
+ }
605
+ function errorCode(message) {
606
+ if (isAuthError(message))
607
+ return 'auth_expired';
608
+ if (isResumeError(message))
609
+ return 'resume_failed';
610
+ return undefined;
611
+ }
612
+ export function classifyRunError(message) {
613
+ if (isAuthError(message)) {
614
+ return 'Claude authentication expired on this server — re-login is required (claude setup-token)';
615
+ }
616
+ if (isResumeError(message)) {
617
+ return 'The previous agent conversation could not be resumed — starting a fresh one';
618
+ }
619
+ return `Claude session error: ${message.slice(0, 1_000)}`;
620
+ }
621
+ export class ClaudeAdapter {
622
+ queryFn;
623
+ id = 'claude';
624
+ constructor(queryFn = query) {
625
+ this.queryFn = queryFn;
626
+ }
627
+ startSession(spec) {
628
+ return new ClaudeSession(spec, this.queryFn);
629
+ }
630
+ }
631
+ //# sourceMappingURL=claude.js.map
@@ -0,0 +1,61 @@
1
+ export type CodexAuthMode = 'link' | 'own';
2
+ export interface CodexHome {
3
+ path: string;
4
+ /** How the home is authenticated — reported in notices, not a secret. */
5
+ auth: 'linked' | 'own' | 'missing';
6
+ }
7
+ export declare function codexHomePath(): string;
8
+ /**
9
+ * Throwaway home for a device-code login. The flow runs here and is promoted
10
+ * into the real home only on success, so an abandoned or timed-out sign-in
11
+ * cannot destroy a credential that was working.
12
+ */
13
+ export declare function stagingCodexHomePath(): string;
14
+ /**
15
+ * Create (or refresh) the runner's CODEX_HOME and return it.
16
+ *
17
+ * `auth: 'link'` (default) symlinks the host user's `~/.codex/auth.json` so the
18
+ * runner uses their ChatGPT subscription and — importantly — shares one
19
+ * credential store with their own CLI, so a token refresh on either side keeps
20
+ * both working. `auth: 'own'` leaves the home unauthenticated until a
21
+ * device-code login writes into it, which is the choice for full isolation.
22
+ *
23
+ * A home under the OS temp dir still works, but codex then refuses to install
24
+ * its helper binaries ("Refusing to create helper binaries under temporary
25
+ * dir") — which can quietly break the agent's shell tooling, so it is worth a
26
+ * warning rather than a failure.
27
+ */
28
+ export declare function ensureCodexHome(options?: {
29
+ auth?: CodexAuthMode;
30
+ homedir?: string;
31
+ }): CodexHome;
32
+ /**
33
+ * Cheap re-assert of the home's credential, safe to call on every session start
34
+ * and before every probe. Does NOT rewrite the config or touch codex's caches —
35
+ * it only answers "is the credential still where we left it, and if not, can we
36
+ * put it back". This is what makes a credential disappearing under a running
37
+ * daemon self-healing instead of permanent.
38
+ */
39
+ export declare function repairCodexAuth(options?: {
40
+ auth?: CodexAuthMode;
41
+ homedir?: string;
42
+ }): CodexHome;
43
+ /**
44
+ * Drop a linked auth.json so a device-code login writes our own file instead.
45
+ *
46
+ * Only ever called AFTER a login has actually succeeded in the staging home —
47
+ * never as a pre-step. Detaching first meant an abandoned or timed-out sign-in
48
+ * left the server permanently "not signed in", recoverable only by restarting
49
+ * the daemon.
50
+ */
51
+ export declare function detachLinkedAuth(dir?: string): void;
52
+ /**
53
+ * Promote a credential produced by a staging login into the real home, and
54
+ * record that this home now owns its own login.
55
+ */
56
+ export declare function adoptLoginResult(stagingDir: string, dir?: string): boolean;
57
+ /** Seed a throwaway home for the device-code flow (config only, no credential). */
58
+ export declare function prepareStagingHome(): string;
59
+ /** Remove the staging home whatever the outcome — it may hold a credential. */
60
+ export declare function discardStagingHome(): void;
61
+ //# sourceMappingURL=codex-home.d.ts.map