@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.
- package/dist/adapters/claude.js +136 -8
- package/dist/adapters/codex.js +96 -2
- package/dist/adapters/types.d.ts +60 -1
- package/dist/agent-auth.d.ts +29 -0
- package/dist/agent-auth.js +136 -0
- package/dist/attachments.d.ts +27 -0
- package/dist/attachments.js +150 -8
- package/dist/auth-relay.d.ts +29 -1
- package/dist/auth-relay.js +228 -13
- package/dist/checkpoints.d.ts +175 -0
- package/dist/checkpoints.js +816 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +17 -0
- package/dist/environment.d.ts +15 -0
- package/dist/environment.js +25 -1
- package/dist/index.js +176 -10
- package/dist/journal.d.ts +34 -1
- package/dist/journal.js +51 -2
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +12 -0
- package/dist/policy.js +15 -0
- package/dist/protocol.d.ts +10 -10
- package/dist/recipe-schema.d.ts +1 -1
- package/dist/self-update.d.ts +43 -2
- package/dist/self-update.js +137 -43
- package/dist/supervisor.d.ts +86 -0
- package/dist/supervisor.js +555 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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: '
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/attachments.d.ts
CHANGED
|
@@ -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
|