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
|
@@ -117,7 +117,7 @@ test('codex-hook stop captures rollout using Codex stdin payload fields', async
|
|
|
117
117
|
assert.equal(monitorState.usage.source, 'codex-rollout-token-count');
|
|
118
118
|
assert.equal(result.monitorState.sessionId, `codex:${threadId}`);
|
|
119
119
|
assert.equal(result.autoRefresh.status, 'skipped');
|
|
120
|
-
assert.equal(result.autoRefresh.reason, '
|
|
120
|
+
assert.equal(result.autoRefresh.reason, 'codex_auto_refresh_disabled');
|
|
121
121
|
assert.equal(monitorTaskCwd, project);
|
|
122
122
|
} finally {
|
|
123
123
|
db.close();
|
|
@@ -126,12 +126,12 @@ test('codex-hook stop captures rollout using Codex stdin payload fields', async
|
|
|
126
126
|
}
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
test('codex-hook stop
|
|
129
|
+
test('codex-hook stop captures only and does not auto refresh at 75%', async () => {
|
|
130
130
|
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
131
131
|
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
132
132
|
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
133
133
|
const db = makeDb();
|
|
134
|
-
let
|
|
134
|
+
let runAutoRefreshCalled = false;
|
|
135
135
|
try {
|
|
136
136
|
const rolloutPath = writeRollout(codexHome, {
|
|
137
137
|
id: threadId,
|
|
@@ -161,7 +161,7 @@ test('codex-hook stop runs auto refresh when verified usage reaches 75%', async
|
|
|
161
161
|
ensureMonitorTask: () => {},
|
|
162
162
|
writeMonitorState: () => {},
|
|
163
163
|
runAutoRefresh: async (args) => {
|
|
164
|
-
|
|
164
|
+
runAutoRefreshCalled = true;
|
|
165
165
|
return {
|
|
166
166
|
status: 'refreshed-live',
|
|
167
167
|
reason: 'rollback_and_inject_sent_live',
|
|
@@ -170,16 +170,9 @@ test('codex-hook stop runs auto refresh when verified usage reaches 75%', async
|
|
|
170
170
|
});
|
|
171
171
|
|
|
172
172
|
assert.equal(result.status, 'ok');
|
|
173
|
-
assert.equal(result.autoRefresh.status, '
|
|
174
|
-
assert.equal(
|
|
175
|
-
assert.equal(
|
|
176
|
-
assert.equal(autoRefreshArgs.codexHome, codexHome);
|
|
177
|
-
assert.equal(autoRefreshArgs.projectPath, project);
|
|
178
|
-
assert.equal(autoRefreshArgs.sessionId, `codex:${threadId}`);
|
|
179
|
-
assert.equal(autoRefreshArgs.usage.tokens, 240_000);
|
|
180
|
-
assert.equal(autoRefreshArgs.usage.contextWindowSize, 258400);
|
|
181
|
-
assert.equal(autoRefreshArgs.usage.estimated, false);
|
|
182
|
-
assert.equal(autoRefreshArgs.command, 'codex');
|
|
173
|
+
assert.equal(result.autoRefresh.status, 'skipped');
|
|
174
|
+
assert.equal(result.autoRefresh.reason, 'codex_auto_refresh_disabled');
|
|
175
|
+
assert.equal(runAutoRefreshCalled, false);
|
|
183
176
|
} finally {
|
|
184
177
|
db.close();
|
|
185
178
|
rmSync(codexHome, { recursive: true, force: true });
|
|
@@ -187,7 +180,7 @@ test('codex-hook stop runs auto refresh when verified usage reaches 75%', async
|
|
|
187
180
|
}
|
|
188
181
|
});
|
|
189
182
|
|
|
190
|
-
test('codex-hook user-prompt-submit
|
|
183
|
+
test('codex-hook user-prompt-submit does not inject auto-refresh instruction at 75%', async () => {
|
|
191
184
|
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
192
185
|
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
193
186
|
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
@@ -227,14 +220,9 @@ test('codex-hook user-prompt-submit injects current-session throughline instruct
|
|
|
227
220
|
});
|
|
228
221
|
|
|
229
222
|
assert.equal(result.status, 'ok');
|
|
230
|
-
assert.equal(result.autoRefreshPrompt.status, '
|
|
231
|
-
assert.equal(result.autoRefreshPrompt.reason, '
|
|
232
|
-
assert.
|
|
233
|
-
assert.match(result.autoRefreshPrompt.context, /throughline trim --execute --host codex --all --json/);
|
|
234
|
-
assert.match(result.autoRefreshPrompt.context, /current Codex rollout token_count/);
|
|
235
|
-
const output = JSON.parse(result.autoRefreshPrompt.output);
|
|
236
|
-
assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
|
|
237
|
-
assert.match(output.hookSpecificOutput.additionalContext, /installed \$throughline workflow/);
|
|
223
|
+
assert.equal(result.autoRefreshPrompt.status, 'skipped');
|
|
224
|
+
assert.equal(result.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
|
|
225
|
+
assert.equal(result.autoRefreshPrompt.output, undefined);
|
|
238
226
|
assert.equal(result.captured.sessionId, `codex:${threadId}`);
|
|
239
227
|
assert.equal(monitorState.sessionId, `codex:${threadId}`);
|
|
240
228
|
} finally {
|
|
@@ -244,6 +232,71 @@ test('codex-hook user-prompt-submit injects current-session throughline instruct
|
|
|
244
232
|
}
|
|
245
233
|
});
|
|
246
234
|
|
|
235
|
+
test('codex-hook prompt and tool hooks both stay quiet when auto-refresh is disabled', async () => {
|
|
236
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
237
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
238
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
239
|
+
const db = makeDb();
|
|
240
|
+
const autoRefreshStateStore = makeMemoryAutoRefreshStateStore();
|
|
241
|
+
try {
|
|
242
|
+
const rolloutPath = writeRollout(codexHome, {
|
|
243
|
+
id: threadId,
|
|
244
|
+
cwd: project,
|
|
245
|
+
events: [
|
|
246
|
+
event('user_message', { message: 'hook request' }),
|
|
247
|
+
event('task_started'),
|
|
248
|
+
turnContext({ model: 'gpt-5.5', cwd: project }),
|
|
249
|
+
event('agent_message', { message: 'hook answer' }),
|
|
250
|
+
event('task_complete'),
|
|
251
|
+
tokenCountEvent({
|
|
252
|
+
inputTokens: 240_000,
|
|
253
|
+
outputTokens: 67,
|
|
254
|
+
contextWindow: 258400,
|
|
255
|
+
}),
|
|
256
|
+
],
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const first = await runCodexUserPromptSubmitHook({
|
|
260
|
+
payload: {
|
|
261
|
+
session_id: threadId,
|
|
262
|
+
transcript_path: rolloutPath,
|
|
263
|
+
cwd: project,
|
|
264
|
+
prompt: 'next user prompt',
|
|
265
|
+
},
|
|
266
|
+
env: {},
|
|
267
|
+
db,
|
|
268
|
+
ensureMonitorTask: () => {},
|
|
269
|
+
writeMonitorState: () => {},
|
|
270
|
+
autoRefreshStateStore,
|
|
271
|
+
});
|
|
272
|
+
const second = await runCodexPostToolUseHook({
|
|
273
|
+
payload: {
|
|
274
|
+
session_id: threadId,
|
|
275
|
+
transcript_path: rolloutPath,
|
|
276
|
+
cwd: project,
|
|
277
|
+
hook_event_name: 'PostToolUse',
|
|
278
|
+
tool_name: 'exec_command',
|
|
279
|
+
},
|
|
280
|
+
env: {},
|
|
281
|
+
db,
|
|
282
|
+
ensureMonitorTask: () => {},
|
|
283
|
+
writeMonitorState: () => {},
|
|
284
|
+
autoRefreshStateStore,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
assert.equal(first.autoRefreshPrompt.status, 'skipped');
|
|
288
|
+
assert.equal(first.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
|
|
289
|
+
assert.equal(first.autoRefreshPrompt.output, undefined);
|
|
290
|
+
assert.equal(second.autoRefreshPrompt.status, 'skipped');
|
|
291
|
+
assert.equal(second.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
|
|
292
|
+
assert.equal(second.autoRefreshPrompt.output, undefined);
|
|
293
|
+
} finally {
|
|
294
|
+
db.close();
|
|
295
|
+
rmSync(codexHome, { recursive: true, force: true });
|
|
296
|
+
rmSync(project, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
247
300
|
test('codex-hook user-prompt-submit stays quiet below 75%', async () => {
|
|
248
301
|
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
249
302
|
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
@@ -281,7 +334,7 @@ test('codex-hook user-prompt-submit stays quiet below 75%', async () => {
|
|
|
281
334
|
|
|
282
335
|
assert.equal(result.status, 'ok');
|
|
283
336
|
assert.equal(result.autoRefreshPrompt.status, 'skipped');
|
|
284
|
-
assert.equal(result.autoRefreshPrompt.reason, '
|
|
337
|
+
assert.equal(result.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
|
|
285
338
|
assert.equal(result.autoRefreshPrompt.output, undefined);
|
|
286
339
|
} finally {
|
|
287
340
|
db.close();
|
|
@@ -290,7 +343,7 @@ test('codex-hook user-prompt-submit stays quiet below 75%', async () => {
|
|
|
290
343
|
}
|
|
291
344
|
});
|
|
292
345
|
|
|
293
|
-
test('codex-hook post-tool-use
|
|
346
|
+
test('codex-hook post-tool-use does not inject auto-refresh instruction at 75%', async () => {
|
|
294
347
|
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
295
348
|
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
296
349
|
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
@@ -327,13 +380,9 @@ test('codex-hook post-tool-use injects current-session throughline instruction a
|
|
|
327
380
|
});
|
|
328
381
|
|
|
329
382
|
assert.equal(result.status, 'ok');
|
|
330
|
-
assert.equal(result.autoRefreshPrompt.status, '
|
|
331
|
-
assert.equal(result.autoRefreshPrompt.reason, '
|
|
332
|
-
assert.
|
|
333
|
-
assert.match(result.autoRefreshPrompt.context, /throughline trim --execute --host codex --all --json/);
|
|
334
|
-
const output = JSON.parse(result.autoRefreshPrompt.output);
|
|
335
|
-
assert.equal(output.hookSpecificOutput.hookEventName, 'PostToolUse');
|
|
336
|
-
assert.match(output.hookSpecificOutput.additionalContext, /installed \$throughline workflow/);
|
|
383
|
+
assert.equal(result.autoRefreshPrompt.status, 'skipped');
|
|
384
|
+
assert.equal(result.autoRefreshPrompt.reason, 'codex_auto_refresh_disabled');
|
|
385
|
+
assert.equal(result.autoRefreshPrompt.output, undefined);
|
|
337
386
|
} finally {
|
|
338
387
|
db.close();
|
|
339
388
|
rmSync(codexHome, { recursive: true, force: true });
|
|
@@ -380,6 +429,59 @@ test('codex-hook stop reports camelCase payload thread id source', async () => {
|
|
|
380
429
|
}
|
|
381
430
|
});
|
|
382
431
|
|
|
432
|
+
test('codex-hook stop ignores auto-refresh backoff store while disabled', async () => {
|
|
433
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
434
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
435
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
436
|
+
const db = makeDb();
|
|
437
|
+
const autoRefreshStateStore = makeMemoryAutoRefreshStateStore();
|
|
438
|
+
try {
|
|
439
|
+
const rolloutPath = writeRollout(codexHome, {
|
|
440
|
+
id: threadId,
|
|
441
|
+
cwd: project,
|
|
442
|
+
events: [
|
|
443
|
+
event('user_message', { message: 'hook request' }),
|
|
444
|
+
event('task_started'),
|
|
445
|
+
turnContext({ model: 'gpt-5.5', cwd: project }),
|
|
446
|
+
event('agent_message', { message: 'hook answer' }),
|
|
447
|
+
event('task_complete'),
|
|
448
|
+
tokenCountEvent({
|
|
449
|
+
inputTokens: 240_000,
|
|
450
|
+
outputTokens: 67,
|
|
451
|
+
contextWindow: 258400,
|
|
452
|
+
}),
|
|
453
|
+
],
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const result = await runCodexStopHook({
|
|
457
|
+
payload: {
|
|
458
|
+
session_id: threadId,
|
|
459
|
+
transcript_path: rolloutPath,
|
|
460
|
+
cwd: project,
|
|
461
|
+
},
|
|
462
|
+
env: {},
|
|
463
|
+
db,
|
|
464
|
+
ensureMonitorTask: () => {},
|
|
465
|
+
writeMonitorState: () => {},
|
|
466
|
+
autoRefreshStateStore,
|
|
467
|
+
runAutoRefresh: async (args) => {
|
|
468
|
+
assert.fail('runAutoRefresh should not be called while Codex auto-refresh is disabled');
|
|
469
|
+
return {
|
|
470
|
+
status: 'skipped',
|
|
471
|
+
reason: 'auto_refresh_backoff',
|
|
472
|
+
};
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
assert.equal(result.status, 'ok');
|
|
477
|
+
assert.equal(result.autoRefresh.reason, 'codex_auto_refresh_disabled');
|
|
478
|
+
} finally {
|
|
479
|
+
db.close();
|
|
480
|
+
rmSync(codexHome, { recursive: true, force: true });
|
|
481
|
+
rmSync(project, { recursive: true, force: true });
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
383
485
|
test('codex-hook stop skips cleanly when Codex thread id is unavailable', async () => {
|
|
384
486
|
const db = makeDb();
|
|
385
487
|
try {
|
|
@@ -458,3 +560,16 @@ function tokenCountEvent({ inputTokens, outputTokens, contextWindow }) {
|
|
|
458
560
|
},
|
|
459
561
|
});
|
|
460
562
|
}
|
|
563
|
+
|
|
564
|
+
function makeMemoryAutoRefreshStateStore() {
|
|
565
|
+
const states = new Map();
|
|
566
|
+
return {
|
|
567
|
+
read(threadId) {
|
|
568
|
+
const state = states.get(threadId);
|
|
569
|
+
return state ? structuredClone(state) : null;
|
|
570
|
+
},
|
|
571
|
+
write(threadId, state) {
|
|
572
|
+
states.set(threadId, structuredClone(state));
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|
package/src/cli/codex-resume.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
renderCodexNewThreadHandoff,
|
|
6
6
|
toCodexDeveloperMessageItem,
|
|
7
7
|
} from '../codex-handoff.mjs';
|
|
8
|
+
import { sameProjectPath } from '../project-path.mjs';
|
|
8
9
|
|
|
9
10
|
const FORMATS = new Set(['text', 'handoff', 'item-json']);
|
|
10
11
|
|
|
@@ -75,16 +76,15 @@ function parseArgs(args) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
function findLatestCodexSessionId(db, projectPath) {
|
|
78
|
-
const
|
|
79
|
+
const rows = db
|
|
79
80
|
.prepare(
|
|
80
|
-
`SELECT session_id
|
|
81
|
+
`SELECT session_id, project_path
|
|
81
82
|
FROM sessions
|
|
82
|
-
WHERE
|
|
83
|
-
|
|
84
|
-
ORDER BY updated_at DESC
|
|
85
|
-
LIMIT 1`,
|
|
83
|
+
WHERE session_id LIKE 'codex:%'
|
|
84
|
+
ORDER BY updated_at DESC`,
|
|
86
85
|
)
|
|
87
|
-
.
|
|
86
|
+
.all();
|
|
87
|
+
const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
|
|
88
88
|
return row?.session_id ?? null;
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
pickOldestUnsummarizedTurn,
|
|
6
6
|
} from '../turn-processor.mjs';
|
|
7
7
|
import { summarizeToL1 } from '../haiku-summarizer.mjs';
|
|
8
|
+
import { sameProjectPath } from '../project-path.mjs';
|
|
8
9
|
|
|
9
10
|
function parseArgs(args) {
|
|
10
11
|
const out = {
|
|
@@ -51,16 +52,15 @@ function parseArgs(args) {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
function findLatestCodexSessionId(db, projectPath) {
|
|
54
|
-
const
|
|
55
|
+
const rows = db
|
|
55
56
|
.prepare(
|
|
56
|
-
`SELECT session_id
|
|
57
|
+
`SELECT session_id, project_path
|
|
57
58
|
FROM sessions
|
|
58
|
-
WHERE
|
|
59
|
-
|
|
60
|
-
ORDER BY updated_at DESC
|
|
61
|
-
LIMIT 1`,
|
|
59
|
+
WHERE session_id LIKE 'codex:%'
|
|
60
|
+
ORDER BY updated_at DESC`,
|
|
62
61
|
)
|
|
63
|
-
.
|
|
62
|
+
.all();
|
|
63
|
+
const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
|
|
64
64
|
return row?.session_id ?? null;
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDb } from '../db.mjs';
|
|
2
2
|
import { buildHandoffRecord } from '../handoff-record.mjs';
|
|
3
3
|
import { toThroughlineHandoffBlock } from '../codex-handoff.mjs';
|
|
4
|
+
import { sameProjectPath } from '../project-path.mjs';
|
|
4
5
|
|
|
5
6
|
function parseArgs(args) {
|
|
6
7
|
const out = {
|
|
@@ -33,15 +34,14 @@ function parseArgs(args) {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function findLatestSessionId(db, projectPath) {
|
|
36
|
-
const
|
|
37
|
+
const rows = db
|
|
37
38
|
.prepare(
|
|
38
|
-
`SELECT session_id
|
|
39
|
+
`SELECT session_id, project_path
|
|
39
40
|
FROM sessions
|
|
40
|
-
|
|
41
|
-
ORDER BY updated_at DESC
|
|
42
|
-
LIMIT 1`,
|
|
41
|
+
ORDER BY updated_at DESC`,
|
|
43
42
|
)
|
|
44
|
-
.
|
|
43
|
+
.all();
|
|
44
|
+
const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
|
|
45
45
|
return row?.session_id ?? null;
|
|
46
46
|
}
|
|
47
47
|
|
package/src/cli/help.test.mjs
CHANGED
|
@@ -47,7 +47,9 @@ test('CLI help exposes guided Codex handoff and guarded execute semantics', () =
|
|
|
47
47
|
for (const command of CODEX_HELP_COMMANDS) {
|
|
48
48
|
assert.match(result.stdout, new RegExp(command.replaceAll('-', '\\-')));
|
|
49
49
|
}
|
|
50
|
-
assert.match(result.stdout, /
|
|
50
|
+
assert.match(result.stdout, /Fresh-thread Codex handoff start plan/);
|
|
51
|
+
assert.match(result.stdout, /Use --execute to/);
|
|
52
|
+
assert.match(result.stdout, /--open-host auto\|vscode\|cli\|none/);
|
|
51
53
|
assert.match(result.stdout, /throughline trim --execute/);
|
|
52
54
|
assert.match(result.stdout, /injectable DB memory/);
|
|
53
55
|
assert.match(result.stdout, /matching/);
|
package/src/cli/install.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* script path で登録する。
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, rmSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
|
|
15
15
|
import { join, dirname, resolve, delimiter } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
@@ -62,22 +62,58 @@ function quoteCommandPath(p) {
|
|
|
62
62
|
return /\s/.test(p) ? `"${p.replace(/"/g, '\\"')}"` : p;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function safeRealpath(p, realpath = realpathSync.native) {
|
|
66
|
+
try {
|
|
67
|
+
return realpath(p);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveCodexHookNodePath({
|
|
74
|
+
env = process.env,
|
|
75
|
+
execPath = process.execPath,
|
|
76
|
+
platform = process.platform,
|
|
77
|
+
exists = existsSync,
|
|
78
|
+
realpath = realpathSync.native,
|
|
79
|
+
} = {}) {
|
|
80
|
+
const execRealpath = safeRealpath(execPath, realpath);
|
|
81
|
+
const pathEnv = env.PATH || env.Path || '';
|
|
82
|
+
const dirs = pathEnv.split(delimiter).filter(Boolean);
|
|
83
|
+
const names = platform === 'win32'
|
|
84
|
+
? ['node.exe', 'node.cmd', 'node.bat', 'node']
|
|
85
|
+
: ['node'];
|
|
86
|
+
|
|
87
|
+
for (const dir of dirs) {
|
|
88
|
+
for (const name of names) {
|
|
89
|
+
const candidate = join(dir, name);
|
|
90
|
+
if (!exists(candidate)) continue;
|
|
91
|
+
const candidateRealpath = safeRealpath(candidate, realpath);
|
|
92
|
+
if (execRealpath && candidateRealpath && candidateRealpath === execRealpath) {
|
|
93
|
+
return candidate;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return execPath;
|
|
99
|
+
}
|
|
100
|
+
|
|
65
101
|
export function buildCodexStopHookCommand({
|
|
66
|
-
nodePath =
|
|
102
|
+
nodePath = resolveCodexHookNodePath(),
|
|
67
103
|
cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
|
|
68
104
|
} = {}) {
|
|
69
105
|
return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook stop`;
|
|
70
106
|
}
|
|
71
107
|
|
|
72
108
|
export function buildCodexUserPromptSubmitHookCommand({
|
|
73
|
-
nodePath =
|
|
109
|
+
nodePath = resolveCodexHookNodePath(),
|
|
74
110
|
cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
|
|
75
111
|
} = {}) {
|
|
76
112
|
return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook user-prompt-submit`;
|
|
77
113
|
}
|
|
78
114
|
|
|
79
115
|
export function buildCodexPostToolUseHookCommand({
|
|
80
|
-
nodePath =
|
|
116
|
+
nodePath = resolveCodexHookNodePath(),
|
|
81
117
|
cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
|
|
82
118
|
} = {}) {
|
|
83
119
|
return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook post-tool-use`;
|
|
@@ -490,8 +526,8 @@ export async function run(args = []) {
|
|
|
490
526
|
console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
|
|
491
527
|
console.log(' UserPromptSubmit → throughline prompt-submit (/tl & /clear バトン書き込み)');
|
|
492
528
|
if (codex) {
|
|
493
|
-
console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (
|
|
494
|
-
console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (
|
|
529
|
+
console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (capture / monitor state only; auto refresh disabled)`);
|
|
530
|
+
console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (capture / monitor state only; auto refresh disabled)`);
|
|
495
531
|
console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
|
|
496
532
|
}
|
|
497
533
|
console.log('');
|
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/cli/trim.mjs
CHANGED
|
@@ -366,15 +366,6 @@ async function runPreflight(parsed, plan) {
|
|
|
366
366
|
expectedTurns: expectedCodexAppServerTurns(plan),
|
|
367
367
|
command,
|
|
368
368
|
});
|
|
369
|
-
const turnCountStatus = preflight.turnCountCheck?.status;
|
|
370
|
-
if (turnCountStatus === 'mismatch' || turnCountStatus === 'unknown') {
|
|
371
|
-
return {
|
|
372
|
-
status: 'preflight-refused',
|
|
373
|
-
reason: preflight.turnCountCheck.reason,
|
|
374
|
-
plan,
|
|
375
|
-
preflight,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
369
|
|
|
379
370
|
return {
|
|
380
371
|
status: 'preflight-ready',
|