throughline 0.4.9 → 0.4.11
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/CHANGELOG.md +35 -3
- package/README.ja.md +27 -0
- package/README.md +58 -31
- package/bin/throughline.mjs +4 -2
- package/codex/skills/throughline/SKILL.md +30 -29
- package/codex/skills/throughline/agents/openai.yaml +2 -2
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +1 -1
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +19 -19
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +6 -6
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +28 -17
- package/docs/throughline-rollback-context-trim-insight.md +15 -9
- package/package.json +2 -2
- package/src/cli/codex-handoff-model-smoke.mjs +7 -7
- package/src/cli/codex-handoff-smoke.mjs +7 -7
- package/src/cli/codex-handoff-start.mjs +184 -10
- package/src/cli/codex-handoff-start.test.mjs +80 -1
- package/src/cli/codex-hook.mjs +37 -110
- package/src/cli/codex-hook.test.mjs +147 -32
- package/src/cli/codex-resume.mjs +7 -7
- package/src/cli/codex-summarize.mjs +7 -7
- package/src/cli/handoff-preview.mjs +6 -6
- package/src/cli/help.test.mjs +3 -1
- package/src/cli/install.mjs +42 -6
- package/src/cli/install.test.mjs +39 -5
- package/src/cli/trim.mjs +0 -9
- package/src/codex-app-server.mjs +216 -19
- package/src/codex-app-server.test.mjs +53 -0
- package/src/codex-auto-refresh.mjs +361 -29
- package/src/codex-auto-refresh.test.mjs +357 -0
- package/src/codex-thread-index.mjs +8 -2
- package/src/project-path.mjs +20 -0
- package/src/test-env.mjs +4 -0
- package/src/trim-cli.test.mjs +19 -11
- package/src/trim-model.mjs +6 -6
package/src/codex-app-server.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
export const CODEX_APP_SERVER_METHODS = Object.freeze({
|
|
5
5
|
initialize: 'initialize',
|
|
6
6
|
initialized: 'initialized',
|
|
7
|
+
threadStart: 'thread/start',
|
|
7
8
|
threadRead: 'thread/read',
|
|
8
9
|
threadResume: 'thread/resume',
|
|
9
10
|
threadTurnsList: 'thread/turns/list',
|
|
@@ -125,6 +126,32 @@ export function buildInitializedNotification() {
|
|
|
125
126
|
};
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
export function buildThreadStartRequest({
|
|
130
|
+
id,
|
|
131
|
+
cwd,
|
|
132
|
+
approvalPolicy = null,
|
|
133
|
+
sandbox = null,
|
|
134
|
+
model = null,
|
|
135
|
+
sessionStartSource = 'clear',
|
|
136
|
+
}) {
|
|
137
|
+
assertRequestId(id, 'buildThreadStartRequest');
|
|
138
|
+
assertNonEmptyString(cwd, 'buildThreadStartRequest: cwd');
|
|
139
|
+
if (sessionStartSource !== 'startup' && sessionStartSource !== 'clear') {
|
|
140
|
+
throw new Error('buildThreadStartRequest: sessionStartSource must be startup or clear');
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
id,
|
|
144
|
+
method: CODEX_APP_SERVER_METHODS.threadStart,
|
|
145
|
+
params: compactNullish({
|
|
146
|
+
cwd,
|
|
147
|
+
approvalPolicy,
|
|
148
|
+
sandbox,
|
|
149
|
+
model,
|
|
150
|
+
sessionStartSource,
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
export function buildThreadReadRequest({ id, threadId, includeTurns = true }) {
|
|
129
156
|
assertRequestId(id, 'buildThreadReadRequest');
|
|
130
157
|
assertNonEmptyString(threadId, 'buildThreadReadRequest: threadId');
|
|
@@ -209,6 +236,48 @@ export function buildThreadRollbackRequest({ id, threadId, numTurns }) {
|
|
|
209
236
|
};
|
|
210
237
|
}
|
|
211
238
|
|
|
239
|
+
export function resolveRollbackTurnsForAppServer({
|
|
240
|
+
plannedRollbackTurns,
|
|
241
|
+
expectedTurns = null,
|
|
242
|
+
readTurns = null,
|
|
243
|
+
resumedTurns = null,
|
|
244
|
+
} = {}) {
|
|
245
|
+
if (!Number.isInteger(plannedRollbackTurns) || plannedRollbackTurns < 1) {
|
|
246
|
+
throw new Error('resolveRollbackTurnsForAppServer: plannedRollbackTurns must be an integer >= 1');
|
|
247
|
+
}
|
|
248
|
+
assertOptionalTurnCount(expectedTurns, 'resolveRollbackTurnsForAppServer: expectedTurns');
|
|
249
|
+
|
|
250
|
+
const result = {
|
|
251
|
+
plannedRollbackTurns,
|
|
252
|
+
requestedRollbackTurns: plannedRollbackTurns,
|
|
253
|
+
adjustment: 0,
|
|
254
|
+
basis: 'planned',
|
|
255
|
+
reason: 'using_planned_rollback_turns',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (
|
|
259
|
+
Number.isInteger(expectedTurns) &&
|
|
260
|
+
Number.isInteger(readTurns) &&
|
|
261
|
+
Number.isInteger(resumedTurns) &&
|
|
262
|
+
readTurns === resumedTurns &&
|
|
263
|
+
readTurns !== expectedTurns
|
|
264
|
+
) {
|
|
265
|
+
const adjustment = readTurns - expectedTurns;
|
|
266
|
+
const adjustedTurns = plannedRollbackTurns + adjustment;
|
|
267
|
+
if (adjustedTurns >= 1) {
|
|
268
|
+
return {
|
|
269
|
+
plannedRollbackTurns,
|
|
270
|
+
requestedRollbackTurns: Math.min(adjustedTurns, readTurns),
|
|
271
|
+
adjustment,
|
|
272
|
+
basis: 'app_server_turn_count',
|
|
273
|
+
reason: 'adjusted_by_app_server_turn_delta',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
|
|
212
281
|
export function buildThreadInjectItemsRequest({ id, threadId, items }) {
|
|
213
282
|
assertRequestId(id, 'buildThreadInjectItemsRequest');
|
|
214
283
|
assertNonEmptyString(threadId, 'buildThreadInjectItemsRequest: threadId');
|
|
@@ -313,6 +382,12 @@ export async function runCodexTrimPreflight({
|
|
|
313
382
|
);
|
|
314
383
|
const readTurns = countTurns(beforeRead);
|
|
315
384
|
const resumedTurns = countTurns(resumed);
|
|
385
|
+
const rollbackResolution = resolveRollbackTurnsForAppServer({
|
|
386
|
+
plannedRollbackTurns: rollbackTurns,
|
|
387
|
+
expectedTurns,
|
|
388
|
+
readTurns,
|
|
389
|
+
resumedTurns,
|
|
390
|
+
});
|
|
316
391
|
|
|
317
392
|
return {
|
|
318
393
|
status: 'preflight-ready',
|
|
@@ -321,6 +396,8 @@ export async function runCodexTrimPreflight({
|
|
|
321
396
|
injectSent: false,
|
|
322
397
|
readTurns,
|
|
323
398
|
resumedTurns,
|
|
399
|
+
rollbackRequestedTurns: rollbackResolution.requestedRollbackTurns,
|
|
400
|
+
rollbackResolution,
|
|
324
401
|
turnCountCheck: compareTurnCounts({
|
|
325
402
|
expectedTurns,
|
|
326
403
|
readTurns,
|
|
@@ -329,7 +406,7 @@ export async function runCodexTrimPreflight({
|
|
|
329
406
|
rollbackRequestPreview: buildThreadRollbackRequest({
|
|
330
407
|
id: 'rollback-preview',
|
|
331
408
|
threadId,
|
|
332
|
-
numTurns:
|
|
409
|
+
numTurns: rollbackResolution.requestedRollbackTurns,
|
|
333
410
|
}),
|
|
334
411
|
notifications: [...new Set(client.notifications)],
|
|
335
412
|
stderr: client.stderr,
|
|
@@ -964,27 +1041,17 @@ export async function runCodexTrimExecution({
|
|
|
964
1041
|
readTurns,
|
|
965
1042
|
resumedTurns,
|
|
966
1043
|
});
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
injectSent: false,
|
|
974
|
-
injectedItems: 0,
|
|
975
|
-
readTurns,
|
|
976
|
-
resumedTurns,
|
|
977
|
-
rollbackRequestedTurns: rollbackTurns,
|
|
978
|
-
turnCountCheck,
|
|
979
|
-
notifications: [...new Set(client.notifications)],
|
|
980
|
-
stderr: client.stderr,
|
|
981
|
-
};
|
|
982
|
-
}
|
|
1044
|
+
const rollbackResolution = resolveRollbackTurnsForAppServer({
|
|
1045
|
+
plannedRollbackTurns: rollbackTurns,
|
|
1046
|
+
expectedTurns,
|
|
1047
|
+
readTurns,
|
|
1048
|
+
resumedTurns,
|
|
1049
|
+
});
|
|
983
1050
|
const rollback = await client.request(
|
|
984
1051
|
buildThreadRollbackRequest({
|
|
985
1052
|
id: randomUUID(),
|
|
986
1053
|
threadId,
|
|
987
|
-
numTurns:
|
|
1054
|
+
numTurns: rollbackResolution.requestedRollbackTurns,
|
|
988
1055
|
}),
|
|
989
1056
|
);
|
|
990
1057
|
const inject = await client.request(
|
|
@@ -1016,7 +1083,8 @@ export async function runCodexTrimExecution({
|
|
|
1016
1083
|
injectedItems: 1,
|
|
1017
1084
|
readTurns,
|
|
1018
1085
|
resumedTurns,
|
|
1019
|
-
rollbackRequestedTurns:
|
|
1086
|
+
rollbackRequestedTurns: rollbackResolution.requestedRollbackTurns,
|
|
1087
|
+
rollbackResolution,
|
|
1020
1088
|
rollbackResultTurns,
|
|
1021
1089
|
injectResultTurns,
|
|
1022
1090
|
afterTurns: postInjectRead.turns,
|
|
@@ -1365,6 +1433,116 @@ export async function runCodexRollbackModelVisibleVerify({
|
|
|
1365
1433
|
}
|
|
1366
1434
|
}
|
|
1367
1435
|
|
|
1436
|
+
export async function runCodexNewThreadHandoff({
|
|
1437
|
+
cwd,
|
|
1438
|
+
prompt,
|
|
1439
|
+
command = 'codex',
|
|
1440
|
+
commandArgs = ['app-server', '--listen', 'stdio://'],
|
|
1441
|
+
timeoutMs = 120_000,
|
|
1442
|
+
requestTimeoutMs = 60_000,
|
|
1443
|
+
waitForTurn = true,
|
|
1444
|
+
sessionStartSource = 'clear',
|
|
1445
|
+
delivery = 'user-turn',
|
|
1446
|
+
} = {}) {
|
|
1447
|
+
assertNonEmptyString(cwd, 'runCodexNewThreadHandoff: cwd');
|
|
1448
|
+
assertNonEmptyString(prompt, 'runCodexNewThreadHandoff: prompt');
|
|
1449
|
+
assertNonEmptyString(command, 'runCodexNewThreadHandoff: command');
|
|
1450
|
+
if (!Array.isArray(commandArgs)) {
|
|
1451
|
+
throw new Error('runCodexNewThreadHandoff: commandArgs must be an array');
|
|
1452
|
+
}
|
|
1453
|
+
if (sessionStartSource !== 'startup' && sessionStartSource !== 'clear') {
|
|
1454
|
+
throw new Error('runCodexNewThreadHandoff: sessionStartSource must be startup or clear');
|
|
1455
|
+
}
|
|
1456
|
+
if (delivery !== 'user-turn' && delivery !== 'developer-item') {
|
|
1457
|
+
throw new Error('runCodexNewThreadHandoff: delivery must be user-turn or developer-item');
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const client = startAppServerClient({
|
|
1461
|
+
command,
|
|
1462
|
+
args: commandArgs,
|
|
1463
|
+
cwd,
|
|
1464
|
+
timeoutMs,
|
|
1465
|
+
requestTimeoutMs,
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
try {
|
|
1469
|
+
await initializeThroughlineAppServerClient(client, {
|
|
1470
|
+
clientName: 'throughline-codex-new-thread-handoff',
|
|
1471
|
+
clientTitle: 'Throughline Codex New Thread Handoff',
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
const threadStart = await client.request(
|
|
1475
|
+
buildThreadStartRequest({
|
|
1476
|
+
id: randomUUID(),
|
|
1477
|
+
cwd,
|
|
1478
|
+
sessionStartSource,
|
|
1479
|
+
}),
|
|
1480
|
+
);
|
|
1481
|
+
const threadId = findThreadIdInAppServerPayload(threadStart);
|
|
1482
|
+
if (!isCodexThreadId(threadId)) {
|
|
1483
|
+
throw new Error(`thread/start did not return a Codex thread id: ${JSON.stringify(threadStart).slice(0, 500)}`);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
if (delivery === 'developer-item') {
|
|
1487
|
+
await client.request(
|
|
1488
|
+
buildThreadInjectItemsRequest({
|
|
1489
|
+
id: randomUUID(),
|
|
1490
|
+
threadId,
|
|
1491
|
+
items: [buildDeveloperMessageItem(prompt)],
|
|
1492
|
+
}),
|
|
1493
|
+
);
|
|
1494
|
+
return {
|
|
1495
|
+
status: 'started',
|
|
1496
|
+
reason: 'new_thread_started_and_developer_memory_injected',
|
|
1497
|
+
threadId,
|
|
1498
|
+
delivery,
|
|
1499
|
+
injectSent: true,
|
|
1500
|
+
turnStatus: 'not-started',
|
|
1501
|
+
notifications: [...new Set(client.notifications)],
|
|
1502
|
+
agentText: collectAgentText(client.notificationEvents),
|
|
1503
|
+
stderr: client.stderr,
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
await client.request(buildTurnStartRequest({ id: randomUUID(), threadId, text: prompt }));
|
|
1508
|
+
const observedTurnEvent = waitForTurn
|
|
1509
|
+
? await client.waitForNotification({
|
|
1510
|
+
predicate: (event) => event.method === 'turn/completed' || event.method === 'turn/failed',
|
|
1511
|
+
timeoutMs: requestTimeoutMs,
|
|
1512
|
+
})
|
|
1513
|
+
: null;
|
|
1514
|
+
const turnStatus =
|
|
1515
|
+
observedTurnEvent?.method === 'turn/completed'
|
|
1516
|
+
? 'completed'
|
|
1517
|
+
: observedTurnEvent?.method === 'turn/failed'
|
|
1518
|
+
? 'failed'
|
|
1519
|
+
: waitForTurn
|
|
1520
|
+
? 'timeout'
|
|
1521
|
+
: 'unchecked';
|
|
1522
|
+
|
|
1523
|
+
return {
|
|
1524
|
+
status: turnStatus === 'failed' || turnStatus === 'timeout' ? 'started-unverified' : 'started',
|
|
1525
|
+
reason:
|
|
1526
|
+
turnStatus === 'completed'
|
|
1527
|
+
? 'new_thread_started_and_turn_completed'
|
|
1528
|
+
: turnStatus === 'failed'
|
|
1529
|
+
? 'new_thread_started_but_turn_failed'
|
|
1530
|
+
: turnStatus === 'timeout'
|
|
1531
|
+
? 'new_thread_started_but_turn_completion_not_observed'
|
|
1532
|
+
: 'new_thread_started_without_waiting_for_turn',
|
|
1533
|
+
threadId,
|
|
1534
|
+
delivery,
|
|
1535
|
+
injectSent: false,
|
|
1536
|
+
turnStatus,
|
|
1537
|
+
notifications: [...new Set(client.notifications)],
|
|
1538
|
+
agentText: collectAgentText(client.notificationEvents),
|
|
1539
|
+
stderr: client.stderr,
|
|
1540
|
+
};
|
|
1541
|
+
} finally {
|
|
1542
|
+
await client.close();
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1368
1546
|
function initializeThroughlineAppServerClient(client, { clientName, clientTitle }) {
|
|
1369
1547
|
return client
|
|
1370
1548
|
.request(
|
|
@@ -1386,6 +1564,25 @@ function extractRollbackModelVisibleMarkers(text, markerPrefix) {
|
|
|
1386
1564
|
return [...new Set(text.match(pattern) ?? [])];
|
|
1387
1565
|
}
|
|
1388
1566
|
|
|
1567
|
+
function findThreadIdInAppServerPayload(value) {
|
|
1568
|
+
if (!isRecord(value)) return null;
|
|
1569
|
+
if (isCodexThreadId(value.id)) return value.id;
|
|
1570
|
+
if (isCodexThreadId(value.threadId)) return value.threadId;
|
|
1571
|
+
if (isRecord(value.thread)) {
|
|
1572
|
+
const nested = findThreadIdInAppServerPayload(value.thread);
|
|
1573
|
+
if (nested) return nested;
|
|
1574
|
+
}
|
|
1575
|
+
if (isRecord(value.params)) {
|
|
1576
|
+
const nested = findThreadIdInAppServerPayload(value.params);
|
|
1577
|
+
if (nested) return nested;
|
|
1578
|
+
}
|
|
1579
|
+
return null;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function isCodexThreadId(value) {
|
|
1583
|
+
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1389
1586
|
async function waitForThreadTurnCount({ client, threadId, expectedTurns, attempts, delayMs }) {
|
|
1390
1587
|
let lastTurns = null;
|
|
1391
1588
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
@@ -14,12 +14,15 @@ import {
|
|
|
14
14
|
buildThreadReadRequest,
|
|
15
15
|
buildThreadResumeRequest,
|
|
16
16
|
buildThreadRollbackRequest,
|
|
17
|
+
buildThreadStartRequest,
|
|
17
18
|
buildThreadTurnsListRequest,
|
|
18
19
|
buildTurnStartRequest,
|
|
19
20
|
compareTurnCounts,
|
|
20
21
|
encodeAppServerMessage,
|
|
21
22
|
parseAppServerLine,
|
|
23
|
+
resolveRollbackTurnsForAppServer,
|
|
22
24
|
runCodexModelVisibilitySmoke,
|
|
25
|
+
runCodexNewThreadHandoff,
|
|
23
26
|
runCodexRollbackModelVisiblePrepare,
|
|
24
27
|
runCodexRollbackModelVisibleVerify,
|
|
25
28
|
runCodexTrimPreflight,
|
|
@@ -84,6 +87,12 @@ test('buildInitializeRequest opts into the experimental app-server API', () => {
|
|
|
84
87
|
|
|
85
88
|
test('thread request builders encode the verified rollback/inject flow', () => {
|
|
86
89
|
const threadId = 'thread-1';
|
|
90
|
+
assert.deepEqual(buildThreadStartRequest({ id: 'start-1', cwd: '/repo' }), {
|
|
91
|
+
id: 'start-1',
|
|
92
|
+
method: CODEX_APP_SERVER_METHODS.threadStart,
|
|
93
|
+
params: { cwd: '/repo', sessionStartSource: 'clear' },
|
|
94
|
+
});
|
|
95
|
+
|
|
87
96
|
assert.deepEqual(buildThreadReadRequest({ id: 1, threadId }), {
|
|
88
97
|
id: 1,
|
|
89
98
|
method: CODEX_APP_SERVER_METHODS.threadRead,
|
|
@@ -203,6 +212,50 @@ test('compareTurnCounts: rejects invalid expected turn count', () => {
|
|
|
203
212
|
);
|
|
204
213
|
});
|
|
205
214
|
|
|
215
|
+
test('resolveRollbackTurnsForAppServer adjusts planned rollback by app-server turn delta', () => {
|
|
216
|
+
assert.deepEqual(
|
|
217
|
+
resolveRollbackTurnsForAppServer({
|
|
218
|
+
plannedRollbackTurns: 6,
|
|
219
|
+
expectedTurns: 6,
|
|
220
|
+
readTurns: 7,
|
|
221
|
+
resumedTurns: 7,
|
|
222
|
+
}),
|
|
223
|
+
{
|
|
224
|
+
plannedRollbackTurns: 6,
|
|
225
|
+
requestedRollbackTurns: 7,
|
|
226
|
+
adjustment: 1,
|
|
227
|
+
basis: 'app_server_turn_count',
|
|
228
|
+
reason: 'adjusted_by_app_server_turn_delta',
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
assert.deepEqual(
|
|
233
|
+
resolveRollbackTurnsForAppServer({
|
|
234
|
+
plannedRollbackTurns: 2,
|
|
235
|
+
expectedTurns: 22,
|
|
236
|
+
readTurns: 21,
|
|
237
|
+
resumedTurns: 21,
|
|
238
|
+
}),
|
|
239
|
+
{
|
|
240
|
+
plannedRollbackTurns: 2,
|
|
241
|
+
requestedRollbackTurns: 1,
|
|
242
|
+
adjustment: -1,
|
|
243
|
+
basis: 'app_server_turn_count',
|
|
244
|
+
reason: 'adjusted_by_app_server_turn_delta',
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
assert.equal(
|
|
249
|
+
resolveRollbackTurnsForAppServer({
|
|
250
|
+
plannedRollbackTurns: 6,
|
|
251
|
+
expectedTurns: 6,
|
|
252
|
+
readTurns: 7,
|
|
253
|
+
resumedTurns: 8,
|
|
254
|
+
}).requestedRollbackTurns,
|
|
255
|
+
6,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
206
259
|
test('summarizeAppServerStderr: compacts repeated unknown-turn item warnings', () => {
|
|
207
260
|
const stderr = [
|
|
208
261
|
'2026-05-06T00:00:00Z WARN codex_app_server_protocol::protocol::thread_history: dropping turn-scoped item for unknown turn id `turn-a` item_id="call_1"',
|