@bridge4dev/runner 0.26.0 → 0.29.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.
@@ -244,6 +244,42 @@ class ClaudeSession {
244
244
  * about the task — the live set typically precedes it (QA-111 m4).
245
245
  */
246
246
  skipTaskIds = new Set();
247
+ /**
248
+ * Uuid of the last message the CLI put in its transcript (ticket #126).
249
+ *
250
+ * This is the anchor a conversation rewind is measured from: handing it back
251
+ * as `resumeSessionAt` resumes the session with everything after it dropped.
252
+ * Every message type carries one — user echoes included, which is what makes
253
+ * "rewind to just before MY message" expressible at all.
254
+ */
255
+ lastMessageUuid = null;
256
+ /**
257
+ * A Stop the USER asked for is in flight.
258
+ *
259
+ * Aborting a turn mid-tool-use makes the SDK end it with
260
+ * `result{subtype:'error_during_execution'}`, which is indistinguishable
261
+ * from a crash by its shape alone — and `turn_end{ok:false}` puts the whole
262
+ * session in FAILED. So the one thing that CAN tell them apart is whether we
263
+ * asked: this flag is set by `interrupt()` and read by the result that
264
+ * follows it.
265
+ *
266
+ * Codex has always drawn the same distinction (`status: 'interrupted'` is
267
+ * reported `ok: true`); this is Claude catching up, not a new rule.
268
+ */
269
+ aborting = false;
270
+ /**
271
+ * The conversation is moving again — whatever a Stop armed is spent.
272
+ *
273
+ * Called from EVERY path that hands the agent something to act on, not just
274
+ * from `send()`: a Stop with nothing to interrupt produces no result at all,
275
+ * so a flag cleared only on a result stays armed and reports the NEXT turn's
276
+ * genuine failure as a clean stop (QA-120 M2). That is the original defect
277
+ * with the sign flipped, and the sign that loses data is this one — a run
278
+ * that failed would be reported as finished, with the reason dropped.
279
+ */
280
+ resumingTurn() {
281
+ this.aborting = false;
282
+ }
247
283
  events = this.output;
248
284
  constructor(spec, queryFn) {
249
285
  this.spec = spec;
@@ -321,6 +357,15 @@ class ClaudeSession {
321
357
  // `applyFlagSettings` call `setEffort` makes.
322
358
  ...(spec.effort === ULTRACODE ? { settings: { ultracode: true } } : {}),
323
359
  ...(spec.resumeProviderSessionId ? { resume: spec.resumeProviderSessionId } : {}),
360
+ // Ticket #126, conversation rewind. `resumeSessionAt` replays the
361
+ // transcript only up to (and including) this uuid; `forkSession` makes
362
+ // the result a NEW session id instead of truncating the old file, so the
363
+ // conversation the user rewound away from is still on disk afterwards.
364
+ // The SDK's own note applies and is repeated in the UI: a forked session
365
+ // starts without undo history.
366
+ ...(spec.resumeProviderSessionId && spec.resumeAtAnchor
367
+ ? { resumeSessionAt: spec.resumeAtAnchor, forkSession: true }
368
+ : {}),
324
369
  ...(spec.maxBudgetUsd !== undefined ? { maxBudgetUsd: spec.maxBudgetUsd } : {}),
325
370
  // Ticket #119. NOT `mcpServers` — the SDK JSON-stringifies that option
326
371
  // straight into argv (`sdk.mjs`: `H.push("--mcp-config", Re({mcpServers:ke}))`),
@@ -1110,6 +1155,7 @@ class ClaudeSession {
1110
1155
  log.warn('claude: answer for a question that is no longer open', { askId: reply.askId });
1111
1156
  return false;
1112
1157
  }
1158
+ this.resumingTurn();
1113
1159
  // Validated BEFORE the ask is consumed: an answer with nothing in it would
1114
1160
  // otherwise release the tool call with `answers: {}` — the very shape of
1115
1161
  // the auto-answer this session deleted — and leave the card unanswerable.
@@ -1219,6 +1265,7 @@ class ClaudeSession {
1219
1265
  log.warn('claude: permission answer for unknown request', { requestId });
1220
1266
  return;
1221
1267
  }
1268
+ this.resumingTurn();
1222
1269
  this.emit({
1223
1270
  type: 'permission_resolved',
1224
1271
  requestId,
@@ -1246,6 +1293,7 @@ class ClaudeSession {
1246
1293
  this.answerQuestion({ askId: openAsk, action: 'discuss', text });
1247
1294
  return;
1248
1295
  }
1296
+ this.resumingTurn();
1249
1297
  const accepted = this.input.push({
1250
1298
  type: 'user',
1251
1299
  message: { role: 'user', content: text },
@@ -1258,13 +1306,37 @@ class ClaudeSession {
1258
1306
  }
1259
1307
  }
1260
1308
  async interrupt() {
1309
+ this.aborting = true;
1261
1310
  try {
1262
1311
  await this.q.interrupt();
1263
1312
  }
1264
1313
  catch (error) {
1314
+ // The abort never reached the SDK, so any failure that arrives now is the
1315
+ // agent's own and must be reported as one.
1316
+ this.aborting = false;
1265
1317
  log.warn('claude: interrupt failed', { error: String(error) });
1266
1318
  }
1267
1319
  }
1320
+ conversationAnchor() {
1321
+ return this.lastMessageUuid;
1322
+ }
1323
+ /**
1324
+ * `/compact` — the CLI's own command, delivered as ordinary user input.
1325
+ *
1326
+ * Deliberately not routed through `send()`: that one diverts text into an
1327
+ * open question card, and a question is exactly the state in which the user
1328
+ * is most likely to look at a full context meter and press the button.
1329
+ */
1330
+ async compact() {
1331
+ if (this.stopped)
1332
+ return false;
1333
+ this.resumingTurn();
1334
+ return this.input.push({
1335
+ type: 'user',
1336
+ message: { role: 'user', content: '/compact' },
1337
+ parent_tool_use_id: null,
1338
+ });
1339
+ }
1268
1340
  stop(reason = 'session_stopped') {
1269
1341
  if (this.stopped)
1270
1342
  return;
@@ -1305,6 +1377,26 @@ class ClaudeSession {
1305
1377
  async consume() {
1306
1378
  try {
1307
1379
  for await (const msg of this.q) {
1380
+ // Ticket #126: where we are in the transcript, remembered BEFORE the
1381
+ // message is interpreted.
1382
+ //
1383
+ // ONLY a finished assistant message of the MAIN conversation. The SDK
1384
+ // stamps a uuid on roughly thirty message kinds — `system:init`, hook
1385
+ // notifications, rate-limit events, tool progress, the closing
1386
+ // `result` — and none of them is something the CLI can resume at. Its
1387
+ // own contract is explicit: «The message ID should be from
1388
+ // `SDKAssistantMessage.uuid`».
1389
+ //
1390
+ // Taking whichever came last handed the CLI, on a fresh session, the
1391
+ // uuid of `system:init` — and the next launch died with «No message
1392
+ // found with message.uuid of …», which is a FAILED session and a
1393
+ // conversation that was never actually rewound.
1394
+ //
1395
+ // `parent_tool_use_id` excludes a subagent's own messages: they live
1396
+ // inside a Task tool call, not in the conversation being resumed.
1397
+ if (msg.type === 'assistant' && msg.parent_tool_use_id === null && msg.uuid) {
1398
+ this.lastMessageUuid = msg.uuid;
1399
+ }
1308
1400
  switch (msg.type) {
1309
1401
  case 'system': {
1310
1402
  if (msg.subtype === 'init') {
@@ -1393,16 +1485,32 @@ class ClaudeSession {
1393
1485
  // otherwise, and it is the last thing anybody sees.
1394
1486
  this.endTaskTurn();
1395
1487
  this.refreshContextUsage();
1396
- if (msg.subtype === 'success') {
1397
- this.emit({ type: 'turn_end', ok: true });
1398
- }
1399
- else {
1488
+ // Consumed here whichever way the turn ended: one Stop arms this
1489
+ // once, for exactly one result.
1490
+ const aborted = this.aborting;
1491
+ this.aborting = false;
1492
+ const failure = msg.subtype === 'success' ? '' : classifyError(msg.subtype, msg.errors);
1493
+ if (failure && isRewindError(failure)) {
1494
+ // Not a failed turn — a refused resume. The CLI answers a bad
1495
+ // `resumeSessionAt` with exactly this and nothing else (no
1496
+ // `system:init`, no assistant message), so reporting it as a
1497
+ // turn failure moved the session to FAILED for a rewind that had
1498
+ // simply not been possible.
1400
1499
  this.emit({
1401
- type: 'turn_end',
1402
- ok: false,
1403
- errorMessage: classifyError(msg.subtype, msg.errors),
1500
+ type: 'error',
1501
+ message: classifyRunError(failure),
1502
+ code: 'rewind_failed',
1404
1503
  });
1405
1504
  }
1505
+ else if (msg.subtype === 'success' || aborted) {
1506
+ // A turn the user stopped is not a failed turn. Reporting it as
1507
+ // one moved the session to FAILED, which is terminal — pressing
1508
+ // Stop cost people the session they meant to keep.
1509
+ this.emit({ type: 'turn_end', ok: true, ...(aborted ? { aborted: true } : {}) });
1510
+ }
1511
+ else {
1512
+ this.emit({ type: 'turn_end', ok: false, errorMessage: failure });
1513
+ }
1406
1514
  break;
1407
1515
  }
1408
1516
  default:
@@ -1526,7 +1634,11 @@ function stringifyContent(content) {
1526
1634
  return content === undefined ? '' : JSON.stringify(content);
1527
1635
  }
1528
1636
  function classifyError(subtype, errors) {
1529
- const detail = errors.length ? `: ${maskString(errors.join('; ').slice(0, 500))}` : '';
1637
+ // Optional on purpose despite the SDK's type: a `result` without `errors`
1638
+ // threw from inside the event pump, which the loop's catch turned into a
1639
+ // plain `error` event — so the turn that failed never ENDED, and the session
1640
+ // sat at RUNNING with no way to tell.
1641
+ const detail = errors?.length ? `: ${maskString(errors.join('; ').slice(0, 500))}` : '';
1530
1642
  switch (subtype) {
1531
1643
  case 'error_max_budget_usd':
1532
1644
  return 'Session budget (USD) exceeded — the run was stopped';
@@ -1556,14 +1668,30 @@ function isAuthError(message) {
1556
1668
  function isResumeError(message) {
1557
1669
  return /no conversation found|session .{0,40}not found|could not resume|invalid session id/i.test(message);
1558
1670
  }
1671
+ /**
1672
+ * The CLI refused the point we asked it to resume at.
1673
+ *
1674
+ * Its exact wording, captured from the CLI itself: «No message found with
1675
+ * message.uuid of: <uuid>». It arrives as a `result` with no `system:init`
1676
+ * before it, and the query then throws with the same sentence — so this is
1677
+ * matched on both paths.
1678
+ */
1679
+ function isRewindError(message) {
1680
+ return /no message found with message\.uuid/i.test(message);
1681
+ }
1559
1682
  function errorCode(message) {
1560
1683
  if (isAuthError(message))
1561
1684
  return 'auth_expired';
1685
+ if (isRewindError(message))
1686
+ return 'rewind_failed';
1562
1687
  if (isResumeError(message))
1563
1688
  return 'resume_failed';
1564
1689
  return undefined;
1565
1690
  }
1566
1691
  export function classifyRunError(message) {
1692
+ if (isRewindError(message)) {
1693
+ return 'The conversation could not be rewound to that point — the agent still remembers everything after it';
1694
+ }
1567
1695
  if (isAuthError(message)) {
1568
1696
  return 'Claude authentication expired on this server — re-login is required (claude setup-token)';
1569
1697
  }
@@ -135,6 +135,20 @@ class CodexSession {
135
135
  threadId = null;
136
136
  threadModel = null;
137
137
  activeTurnId = null;
138
+ /**
139
+ * Last turn this thread finished (ticket #126).
140
+ *
141
+ * `thread/fork { lastTurnId }` forks THROUGH this turn inclusive, dropping
142
+ * everything after it — which is exactly "rewind the conversation to here".
143
+ * Verified against the JSON schema the installed codex-cli 0.145.0 generates
144
+ * itself (`codex app-server generate-json-schema`): `lastTurnId` is part of
145
+ * the STABLE surface, not the experimental one.
146
+ *
147
+ * `thread/rollback` is deliberately not used: its own schema marks it
148
+ * "DEPRECATED: will be removed soon", it counts turns from the end rather
149
+ * than naming one, and it mutates the thread in place instead of forking.
150
+ */
151
+ lastCompletedTurnId = null;
138
152
  /** Plan card waiting for the user; approving it starts the real work. */
139
153
  heldPlan = null;
140
154
  lastCollabMode = null;
@@ -256,6 +270,19 @@ class CodexSession {
256
270
  * second; losing the conversation costs the whole context.
257
271
  */
258
272
  static RESUME_RETRY_DELAY_MS = 1_500;
273
+ /** Branch the thread at `lastTurnId`, dropping every later turn. */
274
+ async forkThread(threadId, lastTurnId) {
275
+ const result = asRecord(await this.client.request('thread/fork', {
276
+ threadId,
277
+ lastTurnId,
278
+ ...this.threadParams(),
279
+ }));
280
+ const thread = asRecord(result['thread']);
281
+ const id = str(thread['id']);
282
+ if (!id)
283
+ throw new Error('thread/fork returned no thread id');
284
+ return { id, model: str(result['model']) ?? null };
285
+ }
259
286
  async resumeThread(resumeId) {
260
287
  // Resume takes the SAME overrides as start, and it must: the per-thread MCP
261
288
  // overlay lives only in memory, so resuming with just a thread id brings the
@@ -273,6 +300,34 @@ class CodexSession {
273
300
  }
274
301
  async openThread() {
275
302
  const resumeId = this.spec.resumeProviderSessionId;
303
+ // Ticket #126, conversation rewind. Forking rather than resuming: the
304
+ // thread the user rewound away from stays on disk untouched, and the fork
305
+ // carries the same overrides every other entry point passes (the per-thread
306
+ // MCP overlay lives only in memory, so a thread opened without it comes
307
+ // back with no DevBridge access at all).
308
+ if (resumeId && this.spec.resumeAtAnchor) {
309
+ try {
310
+ return await this.forkThread(resumeId, this.spec.resumeAtAnchor);
311
+ }
312
+ catch (error) {
313
+ log.warn('codex: thread/fork failed — resuming the whole conversation', {
314
+ sessionId: this.spec.sessionId,
315
+ error: describe(error),
316
+ });
317
+ // Reported through the SAME channel as Claude's refused resume, so the
318
+ // supervisor withdraws the pending feed cut here too. A notice alone
319
+ // left the page believing a rewind had happened: it had already thrown
320
+ // away messages this thread still holds.
321
+ this.emit({
322
+ type: 'error',
323
+ message: 'The conversation could not be rewound to that point — the agent still remembers everything after it',
324
+ code: 'rewind_failed',
325
+ // This thread carries on below with a plain resume — there is
326
+ // nothing for the supervisor to relaunch.
327
+ recovered: true,
328
+ });
329
+ }
330
+ }
276
331
  if (resumeId) {
277
332
  try {
278
333
  return await this.resumeThread(resumeId);
@@ -567,6 +622,33 @@ class CodexSession {
567
622
  log.warn('codex: interrupt failed', { error: describe(error) });
568
623
  }
569
624
  }
625
+ conversationAnchor() {
626
+ return this.lastCompletedTurnId;
627
+ }
628
+ /**
629
+ * `thread/compact/start` — a stable method of app-server 0.145.0, taking only
630
+ * the thread id and answering with an empty object; the summary itself
631
+ * arrives later as a `contextCompaction` item, which this adapter already
632
+ * turns into a notice.
633
+ *
634
+ * Until now compaction on this agent was entirely the agent's own business
635
+ * and the dashboard hid `/compact` from Codex sessions on the grounds that
636
+ * "that agent does not have it". It does.
637
+ */
638
+ async compact() {
639
+ if (!this.threadId || this.stopped)
640
+ return false;
641
+ if (this.activeTurnId)
642
+ return false;
643
+ try {
644
+ await this.client.request('thread/compact/start', { threadId: this.threadId });
645
+ return true;
646
+ }
647
+ catch (error) {
648
+ log.warn('codex: compaction refused', { error: describe(error) });
649
+ return false;
650
+ }
651
+ }
570
652
  stop(reason = 'session_stopped') {
571
653
  if (this.stopped)
572
654
  return;
@@ -1140,6 +1222,12 @@ class CodexSession {
1140
1222
  onTurnCompleted(params) {
1141
1223
  const turn = asRecord(params['turn']);
1142
1224
  const status = str(turn['status']);
1225
+ // Ticket #126: the anchor a conversation rewind is measured from. Only a
1226
+ // COMPLETED turn may be one — `thread/fork` refuses a turn that is still in
1227
+ // progress, and the id is taken from the notification rather than from
1228
+ // `activeTurnId` so a steered turn anchors on what the thread actually
1229
+ // recorded.
1230
+ this.lastCompletedTurnId = str(turn['id']) ?? this.activeTurnId ?? this.lastCompletedTurnId;
1143
1231
  this.activeTurnId = null;
1144
1232
  // A held plan means the turn ended by proposing, not by finishing the work.
1145
1233
  if (this.heldPlan)
@@ -1153,8 +1241,14 @@ class CodexSession {
1153
1241
  });
1154
1242
  return;
1155
1243
  }
1156
- // `interrupted` is a user-initiated Stop: the session stays usable.
1157
- this.emit({ type: 'turn_end', ok: true });
1244
+ // `interrupted` is a user-initiated Stop: the session stays usable. Named
1245
+ // rather than merely tolerated, so the feed can tell "stopped" from
1246
+ // "finished" — the same distinction the Claude adapter now draws.
1247
+ this.emit({
1248
+ type: 'turn_end',
1249
+ ok: true,
1250
+ ...(status === 'interrupted' ? { aborted: true } : {}),
1251
+ });
1158
1252
  this.flushQueued();
1159
1253
  }
1160
1254
  onStderr(text) {
@@ -146,6 +146,21 @@ export interface SessionSpec {
146
146
  model?: string;
147
147
  effort?: string;
148
148
  resumeProviderSessionId?: string;
149
+ /**
150
+ * Resume the conversation only up to this anchor, dropping everything after
151
+ * it (ticket #126, conversation rewind).
152
+ *
153
+ * The value is whatever `AgentSession.conversationAnchor()` returned earlier
154
+ * in this session's life — a Claude message uuid or a Codex turn id. It is
155
+ * opaque to everything above the adapter, and it is only ever meaningful
156
+ * together with `resumeProviderSessionId`.
157
+ *
158
+ * Both agents answer this by FORKING rather than by truncating in place, so
159
+ * the original conversation is never destroyed: the adapter reports the new
160
+ * provider session id through `provider_session`, and the old one stays on
161
+ * disk exactly as it was.
162
+ */
163
+ resumeAtAnchor?: string;
149
164
  mcp?: McpConfig;
150
165
  maxBudgetUsd?: number;
151
166
  }
@@ -313,6 +328,14 @@ export type AgentEvent = {
313
328
  type: 'turn_end';
314
329
  ok: boolean;
315
330
  errorMessage?: string;
331
+ /**
332
+ * The turn ended because the user pressed Stop, not because it finished.
333
+ *
334
+ * Reported `ok: true` — a stop is not a failure — but named, so the feed
335
+ * can say «stopped» rather than «done» and so a stopped turn never reads
336
+ * as work the agent completed.
337
+ */
338
+ aborted?: boolean;
316
339
  } | {
317
340
  type: 'error';
318
341
  message: string;
@@ -323,7 +346,23 @@ export type AgentEvent = {
323
346
  * never signed in "expired" sent people looking for a problem that did
324
347
  * not exist.
325
348
  */
326
- code?: 'resume_failed' | 'auth_expired' | 'auth_missing';
349
+ /**
350
+ * `rewind_failed` — the CLI refused the point we asked it to resume at
351
+ * (ticket #126). Recoverable and NOT a failed session: the conversation
352
+ * is intact, only the rewind did not happen, and the caller must say so
353
+ * rather than pretending it did.
354
+ */
355
+ code?: 'resume_failed' | 'auth_expired' | 'auth_missing' | 'rewind_failed';
356
+ /**
357
+ * The adapter has already dealt with it and the session is still running.
358
+ *
359
+ * Only meaningful with `rewind_failed`, and it is the difference between
360
+ * the two agents: Claude's refused resume kills the query, so the
361
+ * supervisor must bring the process back without the anchor; Codex falls
362
+ * back to a plain resume inside `openThread` and needs no relaunch at
363
+ * all. Both still have to withdraw the feed cut and say what happened.
364
+ */
365
+ recovered?: boolean;
327
366
  };
328
367
  export interface AgentSession {
329
368
  /** Ends when the underlying agent process is gone. */
@@ -365,6 +404,26 @@ export interface AgentSession {
365
404
  }): void;
366
405
  /** Interrupt the current turn (session stays resumable). */
367
406
  interrupt(): Promise<void>;
407
+ /**
408
+ * An opaque id naming the conversation as it stands RIGHT NOW (ticket #126).
409
+ *
410
+ * Handed back later as `SessionSpec.resumeAtAnchor` to rewind the agent's
411
+ * memory to this exact point. `null` means the agent has nothing to anchor to
412
+ * yet — a session whose first turn has not finished — and the caller must
413
+ * then treat "rewind to here" as "start the conversation over".
414
+ *
415
+ * Claude: the uuid of the last message in its transcript.
416
+ * Codex: the id of the last completed turn.
417
+ */
418
+ conversationAnchor(): string | null;
419
+ /**
420
+ * Ask the agent to summarise the conversation so far and continue from the
421
+ * summary — `/compact` in either CLI.
422
+ *
423
+ * Returns false when the agent is in no state to do it (mid-turn, or the
424
+ * call was refused); the caller says so rather than pretending it happened.
425
+ */
426
+ compact(): Promise<boolean>;
368
427
  /**
369
428
  * Tear the session down (kills the agent process). The reason travels so
370
429
  * anything the user was still being asked is withdrawn with a cause they can
@@ -0,0 +1,29 @@
1
+ export declare function agentAuthPath(): string;
2
+ /** The token we hold for Claude, or null once it is too old to trust. */
3
+ export declare function storedClaudeToken(): string | null;
4
+ export declare function storeClaudeToken(token: string): void;
5
+ export declare function clearStoredClaudeToken(): void;
6
+ /**
7
+ * Put a stored token into this process's environment, unless the operator
8
+ * already set one.
9
+ *
10
+ * Their variable wins on purpose: an explicitly exported
11
+ * `CLAUDE_CODE_OAUTH_TOKEN` in the unit or in `environment.d` is a deliberate
12
+ * choice, and silently overriding it with something we captured months ago is
13
+ * the kind of surprise this file exists to prevent.
14
+ *
15
+ * Returns true when it applied one.
16
+ */
17
+ export declare function applyStoredClaudeToken(): boolean;
18
+ /**
19
+ * The OAuth token `claude setup-token` printed, reassembled out of pty output.
20
+ *
21
+ * Written against how the output actually arrives rather than how it looks in a
22
+ * terminal: the pty wraps at its width, so a ~100-character token is routinely
23
+ * split across lines. Continuation lines are joined only while they consist
24
+ * ENTIRELY of token characters — prose ("Store this token securely") contains
25
+ * spaces or punctuation and stops the join, which is what keeps the reassembly
26
+ * from swallowing the sentence after it.
27
+ */
28
+ export declare function extractOauthToken(text: string): string | null;
29
+ //# sourceMappingURL=agent-auth.d.ts.map
@@ -0,0 +1,136 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { configDir } from './paths.js';
4
+ /**
5
+ * A Claude login this runner owns, for the one CLI that cannot store one.
6
+ *
7
+ * Background, because this file only makes sense with it. The dashboard's
8
+ * sign-in button relays a real OAuth flow to the machine — and until 0.27.0 it
9
+ * relayed `claude setup-token`, which is not a login at all. Measured against
10
+ * the 2.1.220 binary and confirmed by Anthropic's own docs: `setup-token` mints
11
+ * a long-lived INFERENCE-ONLY token, prints it, and «does not save the token
12
+ * anywhere». The runner threw the printed token away, then judged the login by
13
+ * reading `~/.claude/.credentials.json` — a file that command never writes. So
14
+ * the flow completed, the panel re-probed within a second, and answered «No
15
+ * Claude login on this server» underneath a success screen. That is the bug
16
+ * behind every «I signed in and it still says signed out».
17
+ *
18
+ * The main path is now `claude auth login`, which persists properly and needs
19
+ * nothing from this module. This exists for the fallback: a CLI old enough not
20
+ * to have `auth login` still has `setup-token`, and rather than leave that
21
+ * machine with no way in, we capture the token it prints and keep it ourselves.
22
+ * `CLAUDE_CODE_OAUTH_TOKEN` is already on the session env allowlist, so a stored
23
+ * token reaches the agent exactly like an operator-configured one.
24
+ *
25
+ * Stored on the machine, 0600, in the runner's own config directory. DevBridge
26
+ * never receives it: the token travels from the provider into a pty on this
27
+ * host and onto this host's disk.
28
+ */
29
+ const FILE_MODE = 0o600;
30
+ export function agentAuthPath() {
31
+ return path.join(configDir(), 'agent-auth.json');
32
+ }
33
+ /**
34
+ * How long a captured token is believed without further evidence.
35
+ *
36
+ * `claude setup-token` mints a one-year credential and there is no way to ask
37
+ * whether it is still good without spending a request. A stored token that
38
+ * never ages out is a green light nobody can turn off: the panel would keep
39
+ * saying «signed in» about a revoked account for as long as the file exists.
40
+ * Eleven months keeps it inside the token's own life while still expiring.
41
+ */
42
+ const TOKEN_MAX_AGE_MS = 334 * 24 * 60 * 60 * 1000;
43
+ function read() {
44
+ try {
45
+ const parsed = JSON.parse(fs.readFileSync(agentAuthPath(), 'utf8'));
46
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
47
+ return {};
48
+ return parsed;
49
+ }
50
+ catch {
51
+ return {};
52
+ }
53
+ }
54
+ /** The token we hold for Claude, or null once it is too old to trust. */
55
+ export function storedClaudeToken() {
56
+ const file = read();
57
+ const value = file.claudeOauthToken;
58
+ if (typeof value !== 'string' || value.length === 0)
59
+ return null;
60
+ const stamped = typeof file.updatedAt === 'string' ? Date.parse(file.updatedAt) : NaN;
61
+ if (Number.isFinite(stamped) && Date.now() - stamped > TOKEN_MAX_AGE_MS)
62
+ return null;
63
+ return value;
64
+ }
65
+ export function storeClaudeToken(token) {
66
+ const dir = configDir();
67
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
68
+ const file = agentAuthPath();
69
+ // Same write-then-rename as the config: a torn file here reads as «signed
70
+ // out» and sends somebody through the whole login again.
71
+ const tmp = path.join(dir, `.agent-auth.${process.pid}.tmp`);
72
+ fs.writeFileSync(tmp, `${JSON.stringify({ claudeOauthToken: token, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: FILE_MODE });
73
+ fs.renameSync(tmp, file);
74
+ fs.chmodSync(file, FILE_MODE);
75
+ }
76
+ export function clearStoredClaudeToken() {
77
+ try {
78
+ fs.rmSync(agentAuthPath(), { force: true });
79
+ }
80
+ catch {
81
+ /* nothing to clear */
82
+ }
83
+ }
84
+ /**
85
+ * Put a stored token into this process's environment, unless the operator
86
+ * already set one.
87
+ *
88
+ * Their variable wins on purpose: an explicitly exported
89
+ * `CLAUDE_CODE_OAUTH_TOKEN` in the unit or in `environment.d` is a deliberate
90
+ * choice, and silently overriding it with something we captured months ago is
91
+ * the kind of surprise this file exists to prevent.
92
+ *
93
+ * Returns true when it applied one.
94
+ */
95
+ export function applyStoredClaudeToken() {
96
+ if (process.env['CLAUDE_CODE_OAUTH_TOKEN'])
97
+ return false;
98
+ const token = storedClaudeToken();
99
+ if (!token)
100
+ return false;
101
+ process.env['CLAUDE_CODE_OAUTH_TOKEN'] = token;
102
+ return true;
103
+ }
104
+ /**
105
+ * The OAuth token `claude setup-token` printed, reassembled out of pty output.
106
+ *
107
+ * Written against how the output actually arrives rather than how it looks in a
108
+ * terminal: the pty wraps at its width, so a ~100-character token is routinely
109
+ * split across lines. Continuation lines are joined only while they consist
110
+ * ENTIRELY of token characters — prose ("Store this token securely") contains
111
+ * spaces or punctuation and stops the join, which is what keeps the reassembly
112
+ * from swallowing the sentence after it.
113
+ */
114
+ export function extractOauthToken(text) {
115
+ const lines = text.split('\n').map((line) => line.trim());
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const line = lines[i] ?? '';
118
+ const start = line.match(/sk-ant-oat[0-9]*-[A-Za-z0-9_-]*$/);
119
+ // The token must run to the end of its line; anything after it on the same
120
+ // line means this is prose mentioning a token, not the token itself.
121
+ if (!start)
122
+ continue;
123
+ let token = start[0];
124
+ for (let j = i + 1; j < lines.length; j++) {
125
+ const next = lines[j] ?? '';
126
+ if (!/^[A-Za-z0-9_-]+$/.test(next))
127
+ break;
128
+ token += next;
129
+ }
130
+ // Long enough to be real, short enough not to be a runaway join.
131
+ if (token.length >= 40 && token.length <= 400)
132
+ return token;
133
+ }
134
+ return null;
135
+ }
136
+ //# sourceMappingURL=agent-auth.js.map
@@ -67,6 +67,17 @@ export declare function saveAttachments(input: {
67
67
  saved: SavedAttachment[];
68
68
  failed: string[];
69
69
  }>;
70
+ /**
71
+ * Delete old attachments from a session worktree.
72
+ *
73
+ * These files are invisible to git by design, which also means nothing else
74
+ * will ever clean them up: a long-lived workspace would accumulate every
75
+ * screenshot and archive anyone attached to it, on the owner's own disk. Age
76
+ * first, then a size budget for the case where age alone is not enough.
77
+ *
78
+ * Best effort — a folder we cannot prune is not a reason to lose the message.
79
+ */
80
+ export declare function pruneAttachmentDir(dir: string, now?: number): void;
70
81
  /**
71
82
  * Turn the user's message plus the files into one prompt.
72
83
  *
@@ -74,6 +85,22 @@ export declare function saveAttachments(input: {
74
85
  * files with their own tools, and an agent-specific encoding (image blocks for
75
86
  * Claude, `localImage` items for Codex) would be two code paths that drift.
76
87
  * The user's own words stay first — the files are context, not the request.
88
+ *
89
+ * Each line NAMES the file and stops there (#128). The old version ended every
90
+ * list with «Open them before answering — images included», which was false for
91
+ * a `.zip` — there is nothing to look at — and false for a `.docx`. An
92
+ * instruction that is wrong for the file in front of the agent is worse than no
93
+ * instruction: it produces a confident answer about a document nobody opened.
94
+ *
95
+ * What we deliberately do NOT do is decide for the agent. Unpacking an archive,
96
+ * or extracting a document's text and handing over our version of it, would put
97
+ * DevBridge in charge of a job the agent does better with the whole file in
98
+ * front of it — and would mean the agent answers about what WE chose to show,
99
+ * not about what the person actually attached.
100
+ *
101
+ * The closing line is a trust frame, not decoration. These files come from a
102
+ * person through a web form; anything inside one that reads like an order to
103
+ * the agent is data, not authority (react-security-standards AI.2/AI.3).
77
104
  */
78
105
  export declare function composeMessageWithAttachments(text: string, saved: SavedAttachment[]): string;
79
106
  //# sourceMappingURL=attachments.d.ts.map