throughline 0.4.10 → 0.4.12
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 +49 -3
- package/README.ja.md +27 -0
- package/README.md +66 -35
- 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_CLEAR_AUTO_HANDOFF_PLAN.md +7 -4
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +10 -10
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +19 -16
- package/docs/throughline-rollback-context-trim-insight.md +11 -7
- 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/codex-app-server.mjs +156 -0
- package/src/codex-app-server.test.mjs +8 -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/resume-context.mjs +58 -2
- package/src/resume-context.test.mjs +142 -6
- package/src/test-env.mjs +4 -0
- package/src/trim-model.mjs +6 -6
package/src/cli/install.test.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
buildCodexPostToolUseHookCommand,
|
|
9
9
|
buildCodexStopHookCommand,
|
|
10
10
|
buildCodexUserPromptSubmitHookCommand,
|
|
11
|
+
resolveCodexHookNodePath,
|
|
11
12
|
run,
|
|
12
13
|
resolveThroughlineOnPath,
|
|
13
14
|
} from './install.mjs';
|
|
@@ -123,13 +124,13 @@ test('global install registers Codex session hooks and enables hooks features',
|
|
|
123
124
|
.flatMap(g => g.hooks ?? [])
|
|
124
125
|
.find(h => h.command === expectedPromptCommand);
|
|
125
126
|
assert.ok(promptHook, 'Codex UserPromptSubmit should have absolute throughline.mjs codex-hook user-prompt-submit');
|
|
126
|
-
assert.equal(promptHook.async, false, 'Codex UserPromptSubmit hook should be synchronous for
|
|
127
|
+
assert.equal(promptHook.async, false, 'Codex UserPromptSubmit hook should be synchronous for capture/monitor state');
|
|
127
128
|
assert.equal(promptHook.timeoutSec, 30, 'Codex UserPromptSubmit hook should be short');
|
|
128
129
|
const postToolUseHook = hooks.hooks.PostToolUse
|
|
129
130
|
.flatMap(g => g.hooks ?? [])
|
|
130
131
|
.find(h => h.command === expectedPostToolUseCommand);
|
|
131
132
|
assert.ok(postToolUseHook, 'Codex PostToolUse should have absolute throughline.mjs codex-hook post-tool-use');
|
|
132
|
-
assert.equal(postToolUseHook.async, false, 'Codex PostToolUse hook should be synchronous for
|
|
133
|
+
assert.equal(postToolUseHook.async, false, 'Codex PostToolUse hook should be synchronous for capture/monitor state');
|
|
133
134
|
assert.equal(postToolUseHook.timeoutSec, 30, 'Codex PostToolUse hook should be short');
|
|
134
135
|
const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
|
|
135
136
|
assert.match(config, /^\[features\]\ncodex_hooks = true/m);
|
|
@@ -157,9 +158,10 @@ test('global install copies Throughline Codex skill to ~/.codex/skills/', async
|
|
|
157
158
|
const metadataBody = readFileSync(metadata, 'utf8');
|
|
158
159
|
assert.match(skillBody, /name: throughline/);
|
|
159
160
|
assert.match(skillBody, /Bare "\$throughline"/);
|
|
160
|
-
assert.match(skillBody, /throughline
|
|
161
|
-
assert.match(skillBody, /do not run doctor \/ dry-run \/
|
|
162
|
-
assert.match(metadataBody, /
|
|
161
|
+
assert.match(skillBody, /throughline codex-handoff-start --execute/);
|
|
162
|
+
assert.match(skillBody, /do not run doctor \/ dry-run \/ preflight first/);
|
|
163
|
+
assert.match(metadataBody, /start a new Codex thread with Throughline handoff memory/);
|
|
164
|
+
assert.doesNotMatch(metadataBody, /scripted current-thread rollback/);
|
|
163
165
|
assert.doesNotMatch(metadataBody, /preview blocked Codex trim/);
|
|
164
166
|
assert.doesNotMatch(metadataBody, /inspect guarded Codex trim/);
|
|
165
167
|
} finally {
|
|
@@ -515,6 +517,38 @@ test('resolveThroughlineOnPath: finds throughline binary in PATH directory', ()
|
|
|
515
517
|
}
|
|
516
518
|
});
|
|
517
519
|
|
|
520
|
+
test('resolveCodexHookNodePath: prefers a stable PATH node symlink when it resolves to current Node', () => {
|
|
521
|
+
const stableNode = join('/opt/homebrew/bin', process.platform === 'win32' ? 'node.exe' : 'node');
|
|
522
|
+
const cellarNode = join('/opt/homebrew/Cellar/node/26.0.0/bin', process.platform === 'win32' ? 'node.exe' : 'node');
|
|
523
|
+
const realNode = join('/opt/homebrew/Cellar/node/26.0.0/bin', process.platform === 'win32' ? 'node.exe' : 'node');
|
|
524
|
+
const result = resolveCodexHookNodePath({
|
|
525
|
+
env: { PATH: '/opt/homebrew/bin' },
|
|
526
|
+
execPath: cellarNode,
|
|
527
|
+
exists: (p) => p === stableNode,
|
|
528
|
+
realpath: (p) => {
|
|
529
|
+
if (p === stableNode || p === cellarNode) return realNode;
|
|
530
|
+
throw new Error(`unexpected path: ${p}`);
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
assert.equal(result, stableNode);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('resolveCodexHookNodePath: falls back to process execPath when PATH node is different', () => {
|
|
537
|
+
const pathNode = join('/usr/local/bin', process.platform === 'win32' ? 'node.exe' : 'node');
|
|
538
|
+
const execPath = join('/opt/homebrew/Cellar/node/26.0.0/bin', process.platform === 'win32' ? 'node.exe' : 'node');
|
|
539
|
+
const result = resolveCodexHookNodePath({
|
|
540
|
+
env: { PATH: '/usr/local/bin' },
|
|
541
|
+
execPath,
|
|
542
|
+
exists: (p) => p === pathNode,
|
|
543
|
+
realpath: (p) => {
|
|
544
|
+
if (p === pathNode) return '/different/node';
|
|
545
|
+
if (p === execPath) return '/current/node';
|
|
546
|
+
throw new Error(`unexpected path: ${p}`);
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
assert.equal(result, execPath);
|
|
550
|
+
});
|
|
551
|
+
|
|
518
552
|
test('install is idempotent: second run keeps exactly one tl.md and one hook entry', async () => {
|
|
519
553
|
const home = makeTempHome();
|
|
520
554
|
if (home.resolved !== home.dir) {
|
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');
|
|
@@ -1406,6 +1433,116 @@ export async function runCodexRollbackModelVisibleVerify({
|
|
|
1406
1433
|
}
|
|
1407
1434
|
}
|
|
1408
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
|
+
|
|
1409
1546
|
function initializeThroughlineAppServerClient(client, { clientName, clientTitle }) {
|
|
1410
1547
|
return client
|
|
1411
1548
|
.request(
|
|
@@ -1427,6 +1564,25 @@ function extractRollbackModelVisibleMarkers(text, markerPrefix) {
|
|
|
1427
1564
|
return [...new Set(text.match(pattern) ?? [])];
|
|
1428
1565
|
}
|
|
1429
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
|
+
|
|
1430
1586
|
async function waitForThreadTurnCount({ client, threadId, expectedTurns, attempts, delayMs }) {
|
|
1431
1587
|
let lastTurns = null;
|
|
1432
1588
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
buildThreadReadRequest,
|
|
15
15
|
buildThreadResumeRequest,
|
|
16
16
|
buildThreadRollbackRequest,
|
|
17
|
+
buildThreadStartRequest,
|
|
17
18
|
buildThreadTurnsListRequest,
|
|
18
19
|
buildTurnStartRequest,
|
|
19
20
|
compareTurnCounts,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
parseAppServerLine,
|
|
22
23
|
resolveRollbackTurnsForAppServer,
|
|
23
24
|
runCodexModelVisibilitySmoke,
|
|
25
|
+
runCodexNewThreadHandoff,
|
|
24
26
|
runCodexRollbackModelVisiblePrepare,
|
|
25
27
|
runCodexRollbackModelVisibleVerify,
|
|
26
28
|
runCodexTrimPreflight,
|
|
@@ -85,6 +87,12 @@ test('buildInitializeRequest opts into the experimental app-server API', () => {
|
|
|
85
87
|
|
|
86
88
|
test('thread request builders encode the verified rollback/inject flow', () => {
|
|
87
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
|
+
|
|
88
96
|
assert.deepEqual(buildThreadReadRequest({ id: 1, threadId }), {
|
|
89
97
|
id: 1,
|
|
90
98
|
method: CODEX_APP_SERVER_METHODS.threadRead,
|