throughline 0.4.6 → 0.4.8
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 +29 -0
- package/README.ja.md +38 -30
- package/README.md +47 -32
- package/bin/throughline.mjs +15 -2
- package/docs/PUBLIC_RELEASE_PLAN.md +5 -3
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +1 -1
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +4 -3
- package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +19 -0
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +2 -2
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +7 -2
- package/docs/throughline-rollback-context-trim-insight.md +1 -1
- package/package.json +1 -1
- package/src/cli/codex-hook.mjs +258 -29
- package/src/cli/codex-hook.test.mjs +169 -2
- package/src/cli/doctor.mjs +59 -5
- package/src/cli/doctor.test.mjs +34 -2
- package/src/cli/help.test.mjs +2 -0
- package/src/cli/install.mjs +82 -12
- package/src/cli/install.test.mjs +100 -16
- package/src/codex-auto-refresh.mjs +1 -1
- package/src/codex-auto-refresh.test.mjs +4 -4
- package/src/token-monitor.mjs +97 -11
- package/src/token-monitor.test.mjs +84 -3
package/src/cli/codex-hook.mjs
CHANGED
|
@@ -31,7 +31,9 @@ function parseArgs(argv) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
if (!out.event) out.event = 'stop';
|
|
34
|
-
if (
|
|
34
|
+
if (!['stop', 'user-prompt-submit', 'post-tool-use'].includes(out.event)) {
|
|
35
|
+
throw new Error(`unknown Codex hook event: ${out.event}`);
|
|
36
|
+
}
|
|
35
37
|
return out;
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -69,7 +71,39 @@ function suppressExperimentalWarnings() {
|
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
export
|
|
74
|
+
export function codexContextOutput(text, eventName = 'UserPromptSubmit') {
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
hookSpecificOutput: {
|
|
77
|
+
hookEventName: eventName,
|
|
78
|
+
additionalContext: text,
|
|
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');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function captureCodexHookSession({
|
|
73
107
|
args = {},
|
|
74
108
|
payload = {},
|
|
75
109
|
env = process.env,
|
|
@@ -77,7 +111,7 @@ export async function runCodexStopHook({
|
|
|
77
111
|
writeMonitorState = null,
|
|
78
112
|
ensureMonitorTask = null,
|
|
79
113
|
buildMonitorUsage = null,
|
|
80
|
-
|
|
114
|
+
summarize = true,
|
|
81
115
|
} = {}) {
|
|
82
116
|
const [
|
|
83
117
|
{ getDb },
|
|
@@ -87,7 +121,6 @@ export async function runCodexStopHook({
|
|
|
87
121
|
{ writeSessionState },
|
|
88
122
|
{ ensureMonitorTaskFile },
|
|
89
123
|
{ buildCodexMonitorUsage },
|
|
90
|
-
{ runCodexAutoRefresh },
|
|
91
124
|
] = await Promise.all([
|
|
92
125
|
import('../db.mjs'),
|
|
93
126
|
import('../codex-capture.mjs'),
|
|
@@ -96,7 +129,6 @@ export async function runCodexStopHook({
|
|
|
96
129
|
import('../state-file.mjs'),
|
|
97
130
|
import('../vscode-task.mjs'),
|
|
98
131
|
import('../codex-usage.mjs'),
|
|
99
|
-
import('../codex-auto-refresh.mjs'),
|
|
100
132
|
]);
|
|
101
133
|
const actualDb = db ?? getDb();
|
|
102
134
|
const identity = resolveCodexHookThreadIdentity({ args, payload, env, resolveCodexThreadIdentity });
|
|
@@ -105,8 +137,14 @@ export async function runCodexStopHook({
|
|
|
105
137
|
return {
|
|
106
138
|
status: 'skipped',
|
|
107
139
|
reason: 'codex_thread_id_not_available',
|
|
140
|
+
db: actualDb,
|
|
141
|
+
identity,
|
|
142
|
+
projectPath: null,
|
|
143
|
+
codexHome: null,
|
|
108
144
|
captured: null,
|
|
109
145
|
summarized: null,
|
|
146
|
+
monitorState: null,
|
|
147
|
+
usage: null,
|
|
110
148
|
};
|
|
111
149
|
}
|
|
112
150
|
|
|
@@ -136,9 +174,14 @@ export async function runCodexStopHook({
|
|
|
136
174
|
return {
|
|
137
175
|
status: 'skipped',
|
|
138
176
|
reason: captured.reason ?? 'codex_capture_not_available',
|
|
139
|
-
|
|
177
|
+
db: actualDb,
|
|
178
|
+
identity,
|
|
179
|
+
projectPath,
|
|
180
|
+
codexHome,
|
|
140
181
|
captured,
|
|
141
182
|
summarized: null,
|
|
183
|
+
monitorState: null,
|
|
184
|
+
usage: null,
|
|
142
185
|
};
|
|
143
186
|
}
|
|
144
187
|
|
|
@@ -161,23 +204,73 @@ export async function runCodexStopHook({
|
|
|
161
204
|
process.stderr.write(`[codex-hook:monitor-state] ${msg}\n`);
|
|
162
205
|
}
|
|
163
206
|
|
|
164
|
-
const summarized =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
207
|
+
const summarized = summarize
|
|
208
|
+
? summarizeCodexSession(actualDb, {
|
|
209
|
+
sessionId: captured.sessionId,
|
|
210
|
+
projectPath: captured.projectPath ?? projectPath,
|
|
211
|
+
max: 1,
|
|
212
|
+
env,
|
|
213
|
+
})
|
|
214
|
+
: null;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
status: 'ok',
|
|
218
|
+
reason: 'codex_rollout_captured',
|
|
219
|
+
db: actualDb,
|
|
220
|
+
identity,
|
|
221
|
+
projectPath,
|
|
222
|
+
codexHome,
|
|
223
|
+
captured,
|
|
224
|
+
summarized,
|
|
225
|
+
monitorState,
|
|
226
|
+
usage,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function runCodexStopHook({
|
|
231
|
+
args = {},
|
|
232
|
+
payload = {},
|
|
233
|
+
env = process.env,
|
|
234
|
+
db = null,
|
|
235
|
+
writeMonitorState = null,
|
|
236
|
+
ensureMonitorTask = null,
|
|
237
|
+
buildMonitorUsage = null,
|
|
238
|
+
runAutoRefresh = null,
|
|
239
|
+
} = {}) {
|
|
240
|
+
const [{ runCodexAutoRefresh }, capturedState] = await Promise.all([
|
|
241
|
+
import('../codex-auto-refresh.mjs'),
|
|
242
|
+
captureCodexHookSession({
|
|
243
|
+
args,
|
|
244
|
+
payload,
|
|
245
|
+
env,
|
|
246
|
+
db,
|
|
247
|
+
writeMonitorState,
|
|
248
|
+
ensureMonitorTask,
|
|
249
|
+
buildMonitorUsage,
|
|
250
|
+
summarize: true,
|
|
251
|
+
}),
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
if (capturedState.status !== 'ok') {
|
|
255
|
+
return {
|
|
256
|
+
status: capturedState.status,
|
|
257
|
+
reason: capturedState.reason,
|
|
258
|
+
codexThreadIdSource: capturedState.identity?.codexThreadIdSource,
|
|
259
|
+
captured: capturedState.captured,
|
|
260
|
+
summarized: capturedState.summarized,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
170
263
|
|
|
171
264
|
let autoRefresh = null;
|
|
172
265
|
try {
|
|
173
266
|
autoRefresh = await (runAutoRefresh ?? runCodexAutoRefresh)({
|
|
174
|
-
db:
|
|
175
|
-
threadId: identity.codexThreadId,
|
|
176
|
-
codexThreadIdSource: identity.codexThreadIdSource,
|
|
177
|
-
codexHome,
|
|
178
|
-
projectPath: captured.projectPath ?? projectPath,
|
|
179
|
-
sessionId: captured.sessionId,
|
|
180
|
-
usage,
|
|
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,
|
|
181
274
|
command: env.THROUGHLINE_CODEX_APP_SERVER_BIN ?? process.env.THROUGHLINE_CODEX_APP_SERVER_BIN ?? 'codex',
|
|
182
275
|
});
|
|
183
276
|
} catch (err) {
|
|
@@ -193,14 +286,129 @@ export async function runCodexStopHook({
|
|
|
193
286
|
return {
|
|
194
287
|
status: 'ok',
|
|
195
288
|
reason: 'codex_rollout_captured',
|
|
196
|
-
codexThreadIdSource: identity.codexThreadIdSource,
|
|
197
|
-
captured,
|
|
198
|
-
summarized,
|
|
199
|
-
monitorState,
|
|
289
|
+
codexThreadIdSource: capturedState.identity.codexThreadIdSource,
|
|
290
|
+
captured: capturedState.captured,
|
|
291
|
+
summarized: capturedState.summarized,
|
|
292
|
+
monitorState: capturedState.monitorState,
|
|
200
293
|
autoRefresh,
|
|
201
294
|
};
|
|
202
295
|
}
|
|
203
296
|
|
|
297
|
+
export async function runCodexUserPromptSubmitHook({
|
|
298
|
+
args = {},
|
|
299
|
+
payload = {},
|
|
300
|
+
env = process.env,
|
|
301
|
+
db = null,
|
|
302
|
+
writeMonitorState = null,
|
|
303
|
+
ensureMonitorTask = null,
|
|
304
|
+
buildMonitorUsage = null,
|
|
305
|
+
} = {}) {
|
|
306
|
+
return runCodexContextRefreshInstructionHook({
|
|
307
|
+
eventName: 'UserPromptSubmit',
|
|
308
|
+
args,
|
|
309
|
+
payload,
|
|
310
|
+
env,
|
|
311
|
+
db,
|
|
312
|
+
writeMonitorState,
|
|
313
|
+
ensureMonitorTask,
|
|
314
|
+
buildMonitorUsage,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function runCodexPostToolUseHook({
|
|
319
|
+
args = {},
|
|
320
|
+
payload = {},
|
|
321
|
+
env = process.env,
|
|
322
|
+
db = null,
|
|
323
|
+
writeMonitorState = null,
|
|
324
|
+
ensureMonitorTask = null,
|
|
325
|
+
buildMonitorUsage = null,
|
|
326
|
+
} = {}) {
|
|
327
|
+
return runCodexContextRefreshInstructionHook({
|
|
328
|
+
eventName: 'PostToolUse',
|
|
329
|
+
args,
|
|
330
|
+
payload,
|
|
331
|
+
env,
|
|
332
|
+
db,
|
|
333
|
+
writeMonitorState,
|
|
334
|
+
ensureMonitorTask,
|
|
335
|
+
buildMonitorUsage,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function runCodexContextRefreshInstructionHook({
|
|
340
|
+
eventName,
|
|
341
|
+
args = {},
|
|
342
|
+
payload = {},
|
|
343
|
+
env = process.env,
|
|
344
|
+
db = null,
|
|
345
|
+
writeMonitorState = null,
|
|
346
|
+
ensureMonitorTask = null,
|
|
347
|
+
buildMonitorUsage = null,
|
|
348
|
+
} = {}) {
|
|
349
|
+
const [{ evaluateCodexAutoRefreshUsage }, capturedState] = await Promise.all([
|
|
350
|
+
import('../codex-auto-refresh.mjs'),
|
|
351
|
+
captureCodexHookSession({
|
|
352
|
+
args,
|
|
353
|
+
payload,
|
|
354
|
+
env,
|
|
355
|
+
db,
|
|
356
|
+
writeMonitorState,
|
|
357
|
+
ensureMonitorTask,
|
|
358
|
+
buildMonitorUsage,
|
|
359
|
+
summarize: false,
|
|
360
|
+
}),
|
|
361
|
+
]);
|
|
362
|
+
|
|
363
|
+
if (capturedState.status !== 'ok') {
|
|
364
|
+
return {
|
|
365
|
+
status: capturedState.status,
|
|
366
|
+
reason: capturedState.reason,
|
|
367
|
+
codexThreadIdSource: capturedState.identity?.codexThreadIdSource,
|
|
368
|
+
captured: capturedState.captured,
|
|
369
|
+
monitorState: capturedState.monitorState,
|
|
370
|
+
autoRefreshPrompt: null,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
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
|
+
return {
|
|
397
|
+
status: 'ok',
|
|
398
|
+
reason: 'codex_rollout_captured',
|
|
399
|
+
codexThreadIdSource: capturedState.identity.codexThreadIdSource,
|
|
400
|
+
captured: capturedState.captured,
|
|
401
|
+
monitorState: capturedState.monitorState,
|
|
402
|
+
autoRefreshPrompt: {
|
|
403
|
+
status: 'ready',
|
|
404
|
+
reason: 'threshold_reached',
|
|
405
|
+
decision,
|
|
406
|
+
context,
|
|
407
|
+
output: codexContextOutput(context, eventName),
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
204
412
|
export async function run(argv = []) {
|
|
205
413
|
suppressExperimentalWarnings();
|
|
206
414
|
let parsed;
|
|
@@ -215,12 +423,31 @@ export async function run(argv = []) {
|
|
|
215
423
|
}
|
|
216
424
|
|
|
217
425
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
426
|
+
let result;
|
|
427
|
+
if (parsed.event === 'user-prompt-submit') {
|
|
428
|
+
result = await runCodexUserPromptSubmitHook({
|
|
429
|
+
args: parsed,
|
|
430
|
+
payload,
|
|
431
|
+
env: process.env,
|
|
432
|
+
});
|
|
433
|
+
} else if (parsed.event === 'post-tool-use') {
|
|
434
|
+
result = await runCodexPostToolUseHook({
|
|
435
|
+
args: parsed,
|
|
436
|
+
payload,
|
|
437
|
+
env: process.env,
|
|
438
|
+
});
|
|
439
|
+
} else {
|
|
440
|
+
result = await runCodexStopHook({
|
|
441
|
+
args: parsed,
|
|
442
|
+
payload,
|
|
443
|
+
env: process.env,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
if (parsed.json) {
|
|
447
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
448
|
+
} else if (result.autoRefreshPrompt?.output) {
|
|
449
|
+
process.stdout.write(result.autoRefreshPrompt.output + '\n');
|
|
450
|
+
}
|
|
224
451
|
process.exit(result.status === 'ok' || result.status === 'skipped' ? 0 : 1);
|
|
225
452
|
} catch (err) {
|
|
226
453
|
const msg = err instanceof Error ? err.message : 'unknown';
|
|
@@ -248,8 +475,10 @@ export async function run(argv = []) {
|
|
|
248
475
|
|
|
249
476
|
export const _internal = {
|
|
250
477
|
codexHomeFromTranscriptPath,
|
|
478
|
+
codexContextOutput,
|
|
251
479
|
parseArgs,
|
|
252
480
|
parsePayload,
|
|
481
|
+
renderAutoRefreshContext,
|
|
253
482
|
resolveCodexHookThreadIdentity,
|
|
254
483
|
};
|
|
255
484
|
|
|
@@ -5,7 +5,12 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { test } from 'node:test';
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
runCodexPostToolUseHook,
|
|
10
|
+
runCodexStopHook,
|
|
11
|
+
runCodexUserPromptSubmitHook,
|
|
12
|
+
_internal,
|
|
13
|
+
} from './codex-hook.mjs';
|
|
9
14
|
|
|
10
15
|
function makeDb() {
|
|
11
16
|
const db = new DatabaseSync(':memory:');
|
|
@@ -121,7 +126,7 @@ test('codex-hook stop captures rollout using Codex stdin payload fields', async
|
|
|
121
126
|
}
|
|
122
127
|
});
|
|
123
128
|
|
|
124
|
-
test('codex-hook stop runs auto refresh when verified usage reaches
|
|
129
|
+
test('codex-hook stop runs auto refresh when verified usage reaches 80%', async () => {
|
|
125
130
|
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
126
131
|
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
127
132
|
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
@@ -182,6 +187,160 @@ test('codex-hook stop runs auto refresh when verified usage reaches 90%', async
|
|
|
182
187
|
}
|
|
183
188
|
});
|
|
184
189
|
|
|
190
|
+
test('codex-hook user-prompt-submit injects current-session throughline instruction at 80%', async () => {
|
|
191
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
192
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
193
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
194
|
+
const db = makeDb();
|
|
195
|
+
let monitorState = null;
|
|
196
|
+
try {
|
|
197
|
+
const rolloutPath = writeRollout(codexHome, {
|
|
198
|
+
id: threadId,
|
|
199
|
+
cwd: project,
|
|
200
|
+
events: [
|
|
201
|
+
event('user_message', { message: 'hook request' }),
|
|
202
|
+
event('task_started'),
|
|
203
|
+
turnContext({ model: 'gpt-5.5', cwd: project }),
|
|
204
|
+
event('agent_message', { message: 'hook answer' }),
|
|
205
|
+
event('task_complete'),
|
|
206
|
+
tokenCountEvent({
|
|
207
|
+
inputTokens: 240_000,
|
|
208
|
+
outputTokens: 67,
|
|
209
|
+
contextWindow: 258400,
|
|
210
|
+
}),
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const result = await runCodexUserPromptSubmitHook({
|
|
215
|
+
payload: {
|
|
216
|
+
session_id: threadId,
|
|
217
|
+
transcript_path: rolloutPath,
|
|
218
|
+
cwd: project,
|
|
219
|
+
prompt: 'next user prompt',
|
|
220
|
+
},
|
|
221
|
+
env: {},
|
|
222
|
+
db,
|
|
223
|
+
ensureMonitorTask: () => {},
|
|
224
|
+
writeMonitorState: (state) => {
|
|
225
|
+
monitorState = state;
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
assert.equal(result.status, 'ok');
|
|
230
|
+
assert.equal(result.autoRefreshPrompt.status, 'ready');
|
|
231
|
+
assert.equal(result.autoRefreshPrompt.reason, 'threshold_reached');
|
|
232
|
+
assert.match(result.autoRefreshPrompt.context, /Before answering the user prompt/);
|
|
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/);
|
|
238
|
+
assert.equal(result.captured.sessionId, `codex:${threadId}`);
|
|
239
|
+
assert.equal(monitorState.sessionId, `codex:${threadId}`);
|
|
240
|
+
} finally {
|
|
241
|
+
db.close();
|
|
242
|
+
rmSync(codexHome, { recursive: true, force: true });
|
|
243
|
+
rmSync(project, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('codex-hook user-prompt-submit stays quiet below 80%', async () => {
|
|
248
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
249
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
250
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
251
|
+
const db = makeDb();
|
|
252
|
+
try {
|
|
253
|
+
const rolloutPath = writeRollout(codexHome, {
|
|
254
|
+
id: threadId,
|
|
255
|
+
cwd: project,
|
|
256
|
+
events: [
|
|
257
|
+
event('user_message', { message: 'hook request' }),
|
|
258
|
+
event('task_started'),
|
|
259
|
+
event('agent_message', { message: 'hook answer' }),
|
|
260
|
+
event('task_complete'),
|
|
261
|
+
tokenCountEvent({
|
|
262
|
+
inputTokens: 12345,
|
|
263
|
+
outputTokens: 67,
|
|
264
|
+
contextWindow: 258400,
|
|
265
|
+
}),
|
|
266
|
+
],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const result = await runCodexUserPromptSubmitHook({
|
|
270
|
+
payload: {
|
|
271
|
+
session_id: threadId,
|
|
272
|
+
transcript_path: rolloutPath,
|
|
273
|
+
cwd: project,
|
|
274
|
+
prompt: 'next user prompt',
|
|
275
|
+
},
|
|
276
|
+
env: {},
|
|
277
|
+
db,
|
|
278
|
+
ensureMonitorTask: () => {},
|
|
279
|
+
writeMonitorState: () => {},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
assert.equal(result.status, 'ok');
|
|
283
|
+
assert.equal(result.autoRefreshPrompt.status, 'skipped');
|
|
284
|
+
assert.equal(result.autoRefreshPrompt.reason, 'below_threshold');
|
|
285
|
+
assert.equal(result.autoRefreshPrompt.output, undefined);
|
|
286
|
+
} finally {
|
|
287
|
+
db.close();
|
|
288
|
+
rmSync(codexHome, { recursive: true, force: true });
|
|
289
|
+
rmSync(project, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('codex-hook post-tool-use injects current-session throughline instruction at 80%', async () => {
|
|
294
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
295
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
296
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
297
|
+
const db = makeDb();
|
|
298
|
+
try {
|
|
299
|
+
const rolloutPath = writeRollout(codexHome, {
|
|
300
|
+
id: threadId,
|
|
301
|
+
cwd: project,
|
|
302
|
+
events: [
|
|
303
|
+
event('user_message', { message: 'hook request' }),
|
|
304
|
+
event('task_started'),
|
|
305
|
+
turnContext({ model: 'gpt-5.5', cwd: project }),
|
|
306
|
+
event('agent_message', { message: 'hook answer' }),
|
|
307
|
+
tokenCountEvent({
|
|
308
|
+
inputTokens: 240_000,
|
|
309
|
+
outputTokens: 67,
|
|
310
|
+
contextWindow: 258400,
|
|
311
|
+
}),
|
|
312
|
+
],
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = await runCodexPostToolUseHook({
|
|
316
|
+
payload: {
|
|
317
|
+
session_id: threadId,
|
|
318
|
+
transcript_path: rolloutPath,
|
|
319
|
+
cwd: project,
|
|
320
|
+
hook_event_name: 'PostToolUse',
|
|
321
|
+
tool_name: 'exec_command',
|
|
322
|
+
},
|
|
323
|
+
env: {},
|
|
324
|
+
db,
|
|
325
|
+
ensureMonitorTask: () => {},
|
|
326
|
+
writeMonitorState: () => {},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
assert.equal(result.status, 'ok');
|
|
330
|
+
assert.equal(result.autoRefreshPrompt.status, 'ready');
|
|
331
|
+
assert.equal(result.autoRefreshPrompt.reason, 'threshold_reached');
|
|
332
|
+
assert.match(result.autoRefreshPrompt.context, /Before continuing the current tool loop/);
|
|
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/);
|
|
337
|
+
} finally {
|
|
338
|
+
db.close();
|
|
339
|
+
rmSync(codexHome, { recursive: true, force: true });
|
|
340
|
+
rmSync(project, { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
185
344
|
test('codex-hook stop reports camelCase payload thread id source', async () => {
|
|
186
345
|
const codexHome = mkdtempSync(join(tmpdir(), 'tl-codex-hook-home-'));
|
|
187
346
|
const project = mkdtempSync(join(tmpdir(), 'tl-codex-hook-project-'));
|
|
@@ -241,6 +400,14 @@ test('codexHomeFromTranscriptPath infers CODEX_HOME from rollout path', () => {
|
|
|
241
400
|
assert.equal(_internal.codexHomeFromTranscriptPath(path), '/tmp/codex-home');
|
|
242
401
|
});
|
|
243
402
|
|
|
403
|
+
test('parseArgs: accepts user-prompt-submit Codex hook event', () => {
|
|
404
|
+
assert.equal(_internal.parseArgs(['user-prompt-submit']).event, 'user-prompt-submit');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('parseArgs: accepts post-tool-use Codex hook event', () => {
|
|
408
|
+
assert.equal(_internal.parseArgs(['post-tool-use']).event, 'post-tool-use');
|
|
409
|
+
});
|
|
410
|
+
|
|
244
411
|
function writeRollout(home, { id, cwd, events }) {
|
|
245
412
|
const dir = join(home, 'sessions', '2026', '05', '06');
|
|
246
413
|
mkdirSync(dir, { recursive: true });
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -28,7 +28,14 @@ import { resolveCodexThreadIdentity } from '../codex-thread-identity.mjs';
|
|
|
28
28
|
import { defaultCodexHome, listCodexThreadCandidates } from '../codex-thread-index.mjs';
|
|
29
29
|
import { getDb } from '../db.mjs';
|
|
30
30
|
import { detectJsoncFeatures, findMonitorTaskIndex, isMonitorTaskBroken } from '../vscode-task.mjs';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
buildCodexPostToolUseHookCommand,
|
|
33
|
+
buildCodexStopHookCommand,
|
|
34
|
+
buildCodexUserPromptSubmitHookCommand,
|
|
35
|
+
isThroughlineCodexHookCommand,
|
|
36
|
+
isThroughlineCodexPostToolUseCommand,
|
|
37
|
+
isThroughlineCodexStopCommand,
|
|
38
|
+
} from './install.mjs';
|
|
32
39
|
|
|
33
40
|
const GREEN = '\x1b[32m✓\x1b[0m';
|
|
34
41
|
const RED = '\x1b[31m✗\x1b[0m';
|
|
@@ -393,20 +400,33 @@ function countCapturedCodexSessions(db, projectPath) {
|
|
|
393
400
|
function readCodexHookDiagnosis(codexHome) {
|
|
394
401
|
const hooksPath = join(codexHome, 'hooks.json');
|
|
395
402
|
const configPath = join(codexHome, 'config.toml');
|
|
396
|
-
const
|
|
403
|
+
const expectedStopCommand = buildCodexStopHookCommand();
|
|
404
|
+
const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
|
|
405
|
+
const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
|
|
397
406
|
const out = {
|
|
398
407
|
hooksPath,
|
|
399
408
|
configPath,
|
|
400
|
-
|
|
409
|
+
expectedStopCommand,
|
|
410
|
+
expectedPromptCommand,
|
|
411
|
+
expectedPostToolUseCommand,
|
|
401
412
|
hooksReadable: false,
|
|
402
413
|
featureEnabled: false,
|
|
414
|
+
codexHooksFeatureEnabled: false,
|
|
415
|
+
hooksFeatureEnabled: false,
|
|
416
|
+
managedPromptHooks: [],
|
|
417
|
+
legacyManagedPromptHooks: [],
|
|
418
|
+
managedPostToolUseHooks: [],
|
|
419
|
+
legacyManagedPostToolUseHooks: [],
|
|
403
420
|
managedStopHooks: [],
|
|
404
421
|
legacyManagedStopHooks: [],
|
|
405
422
|
};
|
|
406
423
|
|
|
407
424
|
if (existsSync(configPath)) {
|
|
408
425
|
try {
|
|
409
|
-
|
|
426
|
+
const config = readFileSync(configPath, 'utf8');
|
|
427
|
+
out.codexHooksFeatureEnabled = /^\s*codex_hooks\s*=\s*true\s*$/m.test(config);
|
|
428
|
+
out.hooksFeatureEnabled = /^\s*hooks\s*=\s*true\s*$/m.test(config);
|
|
429
|
+
out.featureEnabled = out.codexHooksFeatureEnabled || out.hooksFeatureEnabled;
|
|
410
430
|
} catch {
|
|
411
431
|
out.featureEnabled = false;
|
|
412
432
|
}
|
|
@@ -421,9 +441,17 @@ function readCodexHookDiagnosis(codexHome) {
|
|
|
421
441
|
}
|
|
422
442
|
|
|
423
443
|
out.hooksReadable = true;
|
|
444
|
+
const promptHooks = (parsed.hooks?.UserPromptSubmit ?? []).flatMap(group => group.hooks ?? []);
|
|
445
|
+
const postToolUseHooks = (parsed.hooks?.PostToolUse ?? []).flatMap(group => group.hooks ?? []);
|
|
424
446
|
const stopHooks = (parsed.hooks?.Stop ?? []).flatMap(group => group.hooks ?? []);
|
|
447
|
+
out.managedPromptHooks = promptHooks.filter(h => isThroughlineCodexHookCommand(h.command));
|
|
448
|
+
out.legacyManagedPromptHooks = out.managedPromptHooks.filter(h => h.command !== expectedPromptCommand);
|
|
449
|
+
out.managedPostToolUseHooks = postToolUseHooks.filter(h => isThroughlineCodexPostToolUseCommand(h.command));
|
|
450
|
+
out.legacyManagedPostToolUseHooks = out.managedPostToolUseHooks.filter(
|
|
451
|
+
h => h.command !== expectedPostToolUseCommand,
|
|
452
|
+
);
|
|
425
453
|
out.managedStopHooks = stopHooks.filter(h => isThroughlineCodexStopCommand(h.command));
|
|
426
|
-
out.legacyManagedStopHooks = out.managedStopHooks.filter(h => h.command !==
|
|
454
|
+
out.legacyManagedStopHooks = out.managedStopHooks.filter(h => h.command !== expectedStopCommand);
|
|
427
455
|
return out;
|
|
428
456
|
}
|
|
429
457
|
|
|
@@ -456,6 +484,32 @@ function runCodexDiagnosis({
|
|
|
456
484
|
console.log(` project: ${cwd}`);
|
|
457
485
|
console.log(` CODEX_HOME: ${codexHome}`);
|
|
458
486
|
console.log(` Codex hooks feature: ${hookDiagnosis.featureEnabled ? 'enabled' : 'not enabled'}`);
|
|
487
|
+
console.log(` Codex UserPrompt hook: ${
|
|
488
|
+
hookDiagnosis.managedPromptHooks.length === 0
|
|
489
|
+
? 'not registered'
|
|
490
|
+
: hookDiagnosis.legacyManagedPromptHooks.length > 0
|
|
491
|
+
? 'legacy command needs reinstall'
|
|
492
|
+
: 'registered'
|
|
493
|
+
}`);
|
|
494
|
+
if (hookDiagnosis.managedPromptHooks.length > 0) {
|
|
495
|
+
const h = hookDiagnosis.managedPromptHooks[0];
|
|
496
|
+
console.log(` command: ${h.command}`);
|
|
497
|
+
console.log(` async: ${h.async === false ? 'false' : String(h.async)}`);
|
|
498
|
+
console.log(` timeoutSec: ${h.timeoutSec ?? '(default)'}`);
|
|
499
|
+
}
|
|
500
|
+
console.log(` Codex PostTool hook: ${
|
|
501
|
+
hookDiagnosis.managedPostToolUseHooks.length === 0
|
|
502
|
+
? 'not registered'
|
|
503
|
+
: hookDiagnosis.legacyManagedPostToolUseHooks.length > 0
|
|
504
|
+
? 'legacy command needs reinstall'
|
|
505
|
+
: 'registered'
|
|
506
|
+
}`);
|
|
507
|
+
if (hookDiagnosis.managedPostToolUseHooks.length > 0) {
|
|
508
|
+
const h = hookDiagnosis.managedPostToolUseHooks[0];
|
|
509
|
+
console.log(` command: ${h.command}`);
|
|
510
|
+
console.log(` async: ${h.async === false ? 'false' : String(h.async)}`);
|
|
511
|
+
console.log(` timeoutSec: ${h.timeoutSec ?? '(default)'}`);
|
|
512
|
+
}
|
|
459
513
|
console.log(` Codex Stop hook: ${
|
|
460
514
|
hookDiagnosis.managedStopHooks.length === 0
|
|
461
515
|
? 'not registered'
|