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/codex-hook.mjs
CHANGED
|
@@ -71,36 +71,11 @@ function suppressExperimentalWarnings() {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function renderAutoRefreshContext({ usage, decision, threadId, eventName = 'UserPromptSubmit' }) {
|
|
84
|
-
const tokens = Number(usage?.tokens);
|
|
85
|
-
const contextWindowSize = Number(usage?.contextWindowSize);
|
|
86
|
-
const ratio = Number(decision?.ratio);
|
|
87
|
-
const percent = Number.isFinite(ratio) ? `${(ratio * 100).toFixed(1)}%` : 'unknown';
|
|
88
|
-
const timing =
|
|
89
|
-
eventName === 'PostToolUse'
|
|
90
|
-
? 'Before continuing the current tool loop, run the installed $throughline workflow now.'
|
|
91
|
-
: 'Before answering the user prompt, run the installed $throughline workflow now.';
|
|
92
|
-
return [
|
|
93
|
-
'[throughline] Codex context refresh threshold reached for this current session.',
|
|
94
|
-
`Thread: ${threadId}`,
|
|
95
|
-
`Usage: ${Number.isFinite(tokens) ? tokens : 'unknown'} / ${Number.isFinite(contextWindowSize) ? contextWindowSize : 'unknown'} (${percent}); threshold ${(decision.threshold * 100).toFixed(0)}%.`,
|
|
96
|
-
'',
|
|
97
|
-
timing,
|
|
98
|
-
'Execute exactly:',
|
|
99
|
-
'throughline trim --execute --host codex --all --json',
|
|
100
|
-
'',
|
|
101
|
-
'Report only the execution status, whether rollback/inject were sent, durable evidence if present, and the selected memory session; then continue from the refreshed context.',
|
|
102
|
-
'This trigger was computed from the current Codex rollout token_count, not from token-monitor output.',
|
|
103
|
-
].join('\n');
|
|
74
|
+
function codexAutoRefreshDisabledResult() {
|
|
75
|
+
return {
|
|
76
|
+
status: 'skipped',
|
|
77
|
+
reason: 'codex_auto_refresh_disabled',
|
|
78
|
+
};
|
|
104
79
|
}
|
|
105
80
|
|
|
106
81
|
async function captureCodexHookSession({
|
|
@@ -236,20 +211,20 @@ export async function runCodexStopHook({
|
|
|
236
211
|
ensureMonitorTask = null,
|
|
237
212
|
buildMonitorUsage = null,
|
|
238
213
|
runAutoRefresh = null,
|
|
214
|
+
autoRefreshStateStore = null,
|
|
239
215
|
} = {}) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
]);
|
|
216
|
+
void runAutoRefresh;
|
|
217
|
+
void autoRefreshStateStore;
|
|
218
|
+
const capturedState = await captureCodexHookSession({
|
|
219
|
+
args,
|
|
220
|
+
payload,
|
|
221
|
+
env,
|
|
222
|
+
db,
|
|
223
|
+
writeMonitorState,
|
|
224
|
+
ensureMonitorTask,
|
|
225
|
+
buildMonitorUsage,
|
|
226
|
+
summarize: true,
|
|
227
|
+
});
|
|
253
228
|
|
|
254
229
|
if (capturedState.status !== 'ok') {
|
|
255
230
|
return {
|
|
@@ -261,28 +236,6 @@ export async function runCodexStopHook({
|
|
|
261
236
|
};
|
|
262
237
|
}
|
|
263
238
|
|
|
264
|
-
let autoRefresh = null;
|
|
265
|
-
try {
|
|
266
|
-
autoRefresh = await (runAutoRefresh ?? runCodexAutoRefresh)({
|
|
267
|
-
db: capturedState.db,
|
|
268
|
-
threadId: capturedState.identity.codexThreadId,
|
|
269
|
-
codexThreadIdSource: capturedState.identity.codexThreadIdSource,
|
|
270
|
-
codexHome: capturedState.codexHome,
|
|
271
|
-
projectPath: capturedState.captured.projectPath ?? capturedState.projectPath,
|
|
272
|
-
sessionId: capturedState.captured.sessionId,
|
|
273
|
-
usage: capturedState.usage,
|
|
274
|
-
command: env.THROUGHLINE_CODEX_APP_SERVER_BIN ?? process.env.THROUGHLINE_CODEX_APP_SERVER_BIN ?? 'codex',
|
|
275
|
-
});
|
|
276
|
-
} catch (err) {
|
|
277
|
-
const msg = err instanceof Error ? err.message : 'unknown';
|
|
278
|
-
autoRefresh = {
|
|
279
|
-
status: 'error',
|
|
280
|
-
reason: 'auto_refresh_failed',
|
|
281
|
-
message: msg,
|
|
282
|
-
};
|
|
283
|
-
process.stderr.write(`[codex-hook:auto-refresh] ${msg}\n`);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
239
|
return {
|
|
287
240
|
status: 'ok',
|
|
288
241
|
reason: 'codex_rollout_captured',
|
|
@@ -290,7 +243,7 @@ export async function runCodexStopHook({
|
|
|
290
243
|
captured: capturedState.captured,
|
|
291
244
|
summarized: capturedState.summarized,
|
|
292
245
|
monitorState: capturedState.monitorState,
|
|
293
|
-
autoRefresh,
|
|
246
|
+
autoRefresh: codexAutoRefreshDisabledResult(),
|
|
294
247
|
};
|
|
295
248
|
}
|
|
296
249
|
|
|
@@ -302,6 +255,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
302
255
|
writeMonitorState = null,
|
|
303
256
|
ensureMonitorTask = null,
|
|
304
257
|
buildMonitorUsage = null,
|
|
258
|
+
autoRefreshStateStore = null,
|
|
305
259
|
} = {}) {
|
|
306
260
|
return runCodexContextRefreshInstructionHook({
|
|
307
261
|
eventName: 'UserPromptSubmit',
|
|
@@ -312,6 +266,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
312
266
|
writeMonitorState,
|
|
313
267
|
ensureMonitorTask,
|
|
314
268
|
buildMonitorUsage,
|
|
269
|
+
autoRefreshStateStore,
|
|
315
270
|
});
|
|
316
271
|
}
|
|
317
272
|
|
|
@@ -323,6 +278,7 @@ export async function runCodexPostToolUseHook({
|
|
|
323
278
|
writeMonitorState = null,
|
|
324
279
|
ensureMonitorTask = null,
|
|
325
280
|
buildMonitorUsage = null,
|
|
281
|
+
autoRefreshStateStore = null,
|
|
326
282
|
} = {}) {
|
|
327
283
|
return runCodexContextRefreshInstructionHook({
|
|
328
284
|
eventName: 'PostToolUse',
|
|
@@ -333,6 +289,7 @@ export async function runCodexPostToolUseHook({
|
|
|
333
289
|
writeMonitorState,
|
|
334
290
|
ensureMonitorTask,
|
|
335
291
|
buildMonitorUsage,
|
|
292
|
+
autoRefreshStateStore,
|
|
336
293
|
});
|
|
337
294
|
}
|
|
338
295
|
|
|
@@ -345,20 +302,20 @@ async function runCodexContextRefreshInstructionHook({
|
|
|
345
302
|
writeMonitorState = null,
|
|
346
303
|
ensureMonitorTask = null,
|
|
347
304
|
buildMonitorUsage = null,
|
|
305
|
+
autoRefreshStateStore = null,
|
|
348
306
|
} = {}) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
]);
|
|
307
|
+
void eventName;
|
|
308
|
+
void autoRefreshStateStore;
|
|
309
|
+
const capturedState = await captureCodexHookSession({
|
|
310
|
+
args,
|
|
311
|
+
payload,
|
|
312
|
+
env,
|
|
313
|
+
db,
|
|
314
|
+
writeMonitorState,
|
|
315
|
+
ensureMonitorTask,
|
|
316
|
+
buildMonitorUsage,
|
|
317
|
+
summarize: false,
|
|
318
|
+
});
|
|
362
319
|
|
|
363
320
|
if (capturedState.status !== 'ok') {
|
|
364
321
|
return {
|
|
@@ -371,41 +328,13 @@ async function runCodexContextRefreshInstructionHook({
|
|
|
371
328
|
};
|
|
372
329
|
}
|
|
373
330
|
|
|
374
|
-
const decision = evaluateCodexAutoRefreshUsage(capturedState.usage);
|
|
375
|
-
if (!decision.shouldRefresh) {
|
|
376
|
-
return {
|
|
377
|
-
status: 'ok',
|
|
378
|
-
reason: 'codex_rollout_captured',
|
|
379
|
-
codexThreadIdSource: capturedState.identity.codexThreadIdSource,
|
|
380
|
-
captured: capturedState.captured,
|
|
381
|
-
monitorState: capturedState.monitorState,
|
|
382
|
-
autoRefreshPrompt: {
|
|
383
|
-
status: 'skipped',
|
|
384
|
-
reason: decision.reason,
|
|
385
|
-
decision,
|
|
386
|
-
},
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const context = renderAutoRefreshContext({
|
|
391
|
-
usage: capturedState.usage,
|
|
392
|
-
decision,
|
|
393
|
-
threadId: capturedState.identity.codexThreadId,
|
|
394
|
-
eventName,
|
|
395
|
-
});
|
|
396
331
|
return {
|
|
397
332
|
status: 'ok',
|
|
398
333
|
reason: 'codex_rollout_captured',
|
|
399
334
|
codexThreadIdSource: capturedState.identity.codexThreadIdSource,
|
|
400
335
|
captured: capturedState.captured,
|
|
401
336
|
monitorState: capturedState.monitorState,
|
|
402
|
-
autoRefreshPrompt:
|
|
403
|
-
status: 'ready',
|
|
404
|
-
reason: 'threshold_reached',
|
|
405
|
-
decision,
|
|
406
|
-
context,
|
|
407
|
-
output: codexContextOutput(context, eventName),
|
|
408
|
-
},
|
|
337
|
+
autoRefreshPrompt: codexAutoRefreshDisabledResult(),
|
|
409
338
|
};
|
|
410
339
|
}
|
|
411
340
|
|
|
@@ -475,10 +404,8 @@ export async function run(argv = []) {
|
|
|
475
404
|
|
|
476
405
|
export const _internal = {
|
|
477
406
|
codexHomeFromTranscriptPath,
|
|
478
|
-
codexContextOutput,
|
|
479
407
|
parseArgs,
|
|
480
408
|
parsePayload,
|
|
481
|
-
renderAutoRefreshContext,
|
|
482
409
|
resolveCodexHookThreadIdentity,
|
|
483
410
|
};
|
|
484
411
|
|
|
@@ -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('');
|