throughline 0.4.7 → 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 +13 -0
- package/README.ja.md +6 -1
- package/README.md +29 -20
- package/bin/throughline.mjs +15 -2
- package/docs/PUBLIC_RELEASE_PLAN.md +1 -0
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +1 -0
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +5 -0
- package/package.json +1 -1
- package/src/cli/codex-hook.mjs +258 -29
- package/src/cli/codex-hook.test.mjs +168 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,19 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.4.8] — 2026-05-09
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Codex install now registers `UserPromptSubmit` and `PostToolUse` hooks in
|
|
18
|
+
addition to the Stop hook. These hooks read the current Codex rollout
|
|
19
|
+
`token_count` directly and, at the verified 80% threshold, inject a
|
|
20
|
+
current-session `$throughline` instruction before the assistant answers or
|
|
21
|
+
continues a tool loop. This keeps automatic refresh independent of
|
|
22
|
+
token-monitor and available to users who never run the monitor.
|
|
23
|
+
- `throughline install` now enables both `[features].codex_hooks = true` and
|
|
24
|
+
`[features].hooks = true` for Codex hook compatibility.
|
|
25
|
+
|
|
13
26
|
## [0.4.7] — 2026-05-09
|
|
14
27
|
|
|
15
28
|
### Changed
|
package/README.ja.md
CHANGED
|
@@ -26,6 +26,11 @@ throughline install # hook / Codex skill / VS Code monitor task を登録
|
|
|
26
26
|
`/clear` を打てば新セッションはゼロからではなく、**思考の途中から再開** される。
|
|
27
27
|
`/clear` を経由しない新規 chat / VS Code 再起動では `/tl` で前任を指名できる。
|
|
28
28
|
|
|
29
|
+
Codex では `UserPromptSubmit` / `PostToolUse` / `Stop` hook と `$throughline`
|
|
30
|
+
skill も登録する。80% 自動発火は token-monitor 依存ではなく、当該 Codex
|
|
31
|
+
セッションの rollout `token_count` を hook が読み、prompt 送信時または tool loop
|
|
32
|
+
途中の閾値到達時に同じセッションへ `$throughline` 実行指示を注入する。
|
|
33
|
+
|
|
29
34
|
## 他の手段との比較
|
|
30
35
|
|
|
31
36
|
| | Throughline | MemGPT / SummaryBufferMemory | 素の Claude Code |
|
|
@@ -231,7 +236,7 @@ Throughline state をまだ書いていない現在セッションも表示で
|
|
|
231
236
|
|
|
232
237
|
| コマンド | 役割 |
|
|
233
238
|
| --- | --- |
|
|
234
|
-
| `throughline install` | hook / Codex skill を登録し、VS Code 配下なら現プロジェクトの monitor task も配置 |
|
|
239
|
+
| `throughline install` | hook / Codex UserPromptSubmit・PostToolUse・Stop hook / Codex skill を登録し、VS Code 配下なら現プロジェクトの monitor task も配置 |
|
|
235
240
|
| `throughline install --project` | 現リポジトリの `.claude/settings.json` だけに hook を登録 |
|
|
236
241
|
| `throughline uninstall` | hook を削除 |
|
|
237
242
|
| `throughline monitor` | マルチセッション監視を起動 |
|
package/README.md
CHANGED
|
@@ -27,16 +27,20 @@ type `/clear` — the new session resumes mid-thought instead of starting from
|
|
|
27
27
|
zero. (For non-`/clear` boundaries such as a brand-new chat or a VSCode
|
|
28
28
|
restart, type `/tl` first to mark the predecessor.)
|
|
29
29
|
|
|
30
|
-
Global install also registers
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
30
|
+
Global install also registers Codex `UserPromptSubmit`, `PostToolUse`, and
|
|
31
|
+
`Stop` hooks in `~/.codex/hooks.json` and enables both
|
|
32
|
+
`[features].codex_hooks = true` and `[features].hooks = true` in
|
|
33
|
+
`~/.codex/config.toml`. The Codex hooks invoke the installed
|
|
34
|
+
`bin/throughline.mjs` through an absolute Node path, so Codex App Server PATH
|
|
35
|
+
differences do not hide the command. They are registered synchronously
|
|
36
|
+
(`async: false`), matching the Codex hook behavior verified in Caveat. Existing
|
|
37
|
+
non-Throughline Codex hooks are preserved. The prompt and tool-loop hooks read
|
|
38
|
+
the current rollout `token_count` themselves and inject `$throughline` at the
|
|
39
|
+
verified 80% threshold; token-monitor is display-only and is never the trigger
|
|
40
|
+
source. It also installs a global `$throughline` Codex skill. Bare
|
|
41
|
+
`$throughline` runs the scripted current-thread rollback + Throughline DB memory
|
|
42
|
+
injection directly; ask explicitly for status, resume, summarize, diagnostics,
|
|
43
|
+
or fresh-thread handoff when you want those read-only surfaces instead.
|
|
40
44
|
|
|
41
45
|
## How it compares
|
|
42
46
|
|
|
@@ -249,10 +253,12 @@ looked like a rolled-back user prompt could reappear after VS Code restart /
|
|
|
249
253
|
reconnect, but controlled model-visible rollback smokes did not reproduce that
|
|
250
254
|
path. `throughline trim --execute --host codex` now sends the guarded
|
|
251
255
|
rollback + Throughline DB memory injection when app-server turn-count guards and
|
|
252
|
-
injectable DB memory are available. Codex
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
+
injectable DB memory are available. Codex current-session auto-refresh is not a
|
|
257
|
+
token-monitor feature: the Codex `UserPromptSubmit` hook reads the current
|
|
258
|
+
rollout `token_count` and, at the verified 80% threshold, injects a same-session
|
|
259
|
+
instruction to run the installed `$throughline` workflow before answering. The
|
|
260
|
+
Codex Stop hook still attempts the guarded live refresh when it naturally fires,
|
|
261
|
+
so non-monitor users get the same threshold behavior.
|
|
256
262
|
|
|
257
263
|
`throughline codex-host-primitive-audit` can inspect the installed Codex
|
|
258
264
|
app-server schema read-only. On the current tested Codex CLI, it finds
|
|
@@ -529,10 +535,13 @@ Example output:
|
|
|
529
535
|
Codex rollout has no token-count event, Throughline can show an explicit
|
|
530
536
|
estimate with `estimated: true` and the monitor marks it with `est`; it is not
|
|
531
537
|
presented as exact usage.
|
|
532
|
-
- **Codex auto-refresh
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
538
|
+
- **Codex auto-refresh is driven by the current Codex session, not the monitor.**
|
|
539
|
+
The Codex `UserPromptSubmit` and `PostToolUse` hooks capture rollout memory
|
|
540
|
+
and, when verified usage reaches 80%, inject a current-session `$throughline`
|
|
541
|
+
instruction before the assistant answers or continues a tool loop. The Codex
|
|
542
|
+
Stop hook also captures DB memory, writes monitor state, and attempts guarded
|
|
543
|
+
rollback + Throughline DB memory injection when it naturally fires above the
|
|
544
|
+
threshold.
|
|
536
545
|
- **1M-context detection** is automatic. It checks the `[1m]` suffix in the
|
|
537
546
|
transcript, falls back to string matching on `1M context`, and finally
|
|
538
547
|
promotes to 1M if observed usage exceeds 200k.
|
|
@@ -598,7 +607,7 @@ monitor appears in a dedicated terminal panel the next time you open that
|
|
|
598
607
|
folder.
|
|
599
608
|
|
|
600
609
|
**How it works.** `ensureMonitorTaskFile` is called from `throughline install`
|
|
601
|
-
and from **all three hooks (SessionStart, UserPromptSubmit, Stop)**. Whichever
|
|
610
|
+
and from **all three Claude hooks (SessionStart, UserPromptSubmit, Stop)**. Whichever
|
|
602
611
|
one fires first in your environment creates the file; the rest are idempotent
|
|
603
612
|
no-ops. Once per project it inspects `.vscode/tasks.json`:
|
|
604
613
|
|
|
@@ -658,7 +667,7 @@ entry to the `tasks` array yourself:
|
|
|
658
667
|
|
|
659
668
|
| Command | What it does |
|
|
660
669
|
| ---------------------------------------------- | ------------------------------------------------------------ |
|
|
661
|
-
| `throughline install` | Register Claude user hooks/slash commands, the global Codex Stop
|
|
670
|
+
| `throughline install` | Register Claude user hooks/slash commands, the global Codex UserPromptSubmit/PostToolUse/Stop hooks, the global `$throughline` Codex skill, and the current VS Code monitor task when applicable |
|
|
662
671
|
| `throughline install --project` | Register Claude hooks/slash commands in this repo only |
|
|
663
672
|
| `throughline uninstall` | Remove Throughline-managed Claude hooks/slash commands, only the Throughline-managed Codex hook, and the `$throughline` Codex skill |
|
|
664
673
|
| `throughline monitor [--all] [--session <id>]` | Run the multi-session token monitor |
|
package/bin/throughline.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* throughline detail <時刻> # L2+L3 詳細取得 (Claude が Bash 経由で呼ぶ想定)
|
|
12
12
|
* throughline handoff-preview # Codex-facing throughline_handoff JSON preview
|
|
13
13
|
* throughline codex-capture # Capture active Codex rollout turns into Throughline DB
|
|
14
|
+
* throughline codex-hook user-prompt-submit # Codex current-session auto-refresh prompt hook
|
|
15
|
+
* throughline codex-hook post-tool-use # Codex current-session auto-refresh tool-loop hook
|
|
14
16
|
* throughline codex-hook stop # Codex native Stop hook (capture + L1 summarize)
|
|
15
17
|
* throughline codex-summarize # Summarize captured Codex L2 turns into L1 via Codex CLI
|
|
16
18
|
* throughline codex-resume # Render Codex active-work context from DB
|
|
@@ -149,8 +151,17 @@ Usage:
|
|
|
149
151
|
throughline handoff-preview Print Codex-facing throughline_handoff JSON
|
|
150
152
|
throughline codex-capture Capture active Codex rollout turns into DB
|
|
151
153
|
(requires --codex-thread-id or env thread id)
|
|
152
|
-
throughline codex-hook
|
|
153
|
-
|
|
154
|
+
throughline codex-hook user-prompt-submit
|
|
155
|
+
Codex UserPromptSubmit hook: capture rollout and,
|
|
156
|
+
at 80%, inject current-session $throughline
|
|
157
|
+
instruction from verified rollout token_count
|
|
158
|
+
throughline codex-hook post-tool-use
|
|
159
|
+
Codex PostToolUse hook: during tool loops, capture
|
|
160
|
+
rollout and inject the same 80% $throughline
|
|
161
|
+
instruction from verified rollout token_count
|
|
162
|
+
throughline codex-hook stop Codex native Stop hook: capture rollout,
|
|
163
|
+
summarize old L2 turns into L1, and run guarded
|
|
164
|
+
auto-refresh when verified usage reaches 80%
|
|
154
165
|
throughline codex-summarize Summarize captured Codex L2 into L1 via Codex CLI
|
|
155
166
|
(requires a codex:<thread-id> session)
|
|
156
167
|
throughline codex-resume Render Codex active-work context from DB
|
|
@@ -243,6 +254,8 @@ Hook subcommands (called by Claude Code / Codex):
|
|
|
243
254
|
throughline session-start SessionStart hook
|
|
244
255
|
throughline process-turn Stop hook
|
|
245
256
|
throughline prompt-submit UserPromptSubmit hook (/tl & /clear baton writer)
|
|
257
|
+
throughline codex-hook user-prompt-submit Codex current-session refresh prompt hook
|
|
258
|
+
throughline codex-hook post-tool-use Codex current-session refresh tool-loop hook
|
|
246
259
|
throughline codex-hook stop Codex Stop hook
|
|
247
260
|
`);
|
|
248
261
|
}
|
|
@@ -137,6 +137,7 @@ schema v4 で PostToolUse (`capture-tool`) は廃止、L2/L3 は Stop 内で一
|
|
|
137
137
|
| **npm 公開 (v0.4.0): /clear auto-handoff + memo / save-inflight / /tl-trim retire** | 2026-05-08 Claude Code 2.1.128 で `source='clear'` が reliable になったため、`/clear` で自動引継ぎがデフォルト ON になる auto path を追加。`THROUGHLINE_DISABLE_AUTO_HANDOFF=1` で OFF にできる。`/tl` slash command は明示意思マーカーへ簡素化 (memo 4 項目入力廃止、`save-inflight` CLI 削除、`/tl-trim` slash command 廃止、`updateBatonMemo` 関数削除、`handoff_batons.memo_text` を schema v8 で drop)。注入は L1 + L2 + L3 references のみに簡素化し、memo / 中断直前 thinking セクションを削除。Codex 側 trim path は維持。詳細は [CHANGELOG.md](../CHANGELOG.md) と [THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md](THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md) |
|
|
138
138
|
| **npm 公開 (v0.4.1): typed `/clear` も baton を書く + 2 経路の優先順位入れ替え** | 2026-05-09 `/clear` を UserPromptSubmit hook で検出した時点で当該セッションの `session_id` を `handoff_batons` に書き込み、次 SessionStart が確定的にそのセッションを引き継ぐ。これで multi-window で「最新更新セッション ≠ /clear したセッション」になるシナリオで `findLatestClaudePredecessor` heuristic が誤った前任を選ぶ問題を解消。2 経路の優先順位を **baton path = primary、auto path = fallback** に変更 (auto path は VSCode 拡張メニュー由来など UserPromptSubmit に届かない経路のフォールバック)。`THROUGHLINE_DISABLE_AUTO_HANDOFF=1` は fallback path のみに作用するようになった (typed `/clear` / `/tl` は env と無関係に発火する)。あわせて `.vscode/tasks.json` を git 追跡から外し (gitignore)、`ensureMonitorTaskFile` が hook 発火ごとに絶対パスを書き換える挙動による別環境での dirty diff を解消。`src/prompt-submit.test.mjs` を新設し、`isClearCommand` / `isBatonCommand` 判定 14 件と subprocess+DB 実体テスト 3 件を追加。詳細は [CHANGELOG.md](../CHANGELOG.md) |
|
|
139
139
|
| **npm 公開 (v0.4.7): Codex monitor direct discovery + 80% auto-refresh** | 2026-05-09 Codex Stop hook auto-refresh の verified usage threshold を 90% から 80% に変更し、Codex native auto-compact より先に Throughline DB memory refresh を試行する。estimate usage / estimated context window では mutation しない。`throughline monitor` は `~/.throughline/state` に加えて `~/.codex/sessions/**/rollout-*.jsonl` を直接 discovery し、Throughline state が未生成の現在 Codex thread も表示する。既存 state がある場合は state の usage snapshot を保持しつつ discovered rollout path / mtime を合流する。Codex 表示 ID は `codex:01` ではなく raw thread id 先頭 8 桁 (`019e085c`) にした。Codex open turn の transient `output_tokens` は token count に overlay するが、モデル欄の `live+<tokens>` marker は表示しない |
|
|
140
|
+
| **未リリース: Codex current-session 80% trigger** | Codex 自動発火を token-monitor に依存させず、global install が Codex `UserPromptSubmit` / `PostToolUse` hooks も登録する。hook は当該 Codex session の rollout `token_count` を直接読み、verified 80% 以上なら同じ user turn または tool loop 継続前に `$throughline` workflow 実行指示を `additionalContext` で注入する。Stop hook の guarded auto-refresh は残す。`~/.codex/config.toml` は旧 `codex_hooks = true` に加えて現行 `hooks = true` も有効化する |
|
|
140
141
|
| **グローバル E2E 検証** | 2026-04-17 別ディレクトリから `throughline doctor` 全緑を確認 |
|
|
141
142
|
|
|
142
143
|
### ❌ 未完タスク
|
|
@@ -469,6 +469,7 @@ Phase 5 implementation status (2026-05-06):
|
|
|
469
469
|
- monitor adapter 修正: `throughline monitor` は Claude / Codex host-aware state を読む。Codex Stop hook は `codex:<thread_id>` monitor state を書き、Codex rollout path は Claude transcript 用 `transcriptPath` ではなく `rolloutPath` に保存する。2026-05-09 以降、monitor は `~/.throughline/state` だけでなく `~/.codex/sessions/**/rollout-*.jsonl` も直接 discovery するため、state 未生成の現在 Codex thread も表示できる。既存 state がある場合は usage snapshot を保持しつつ discovered rollout path / mtime を合流する。rollout に `event_msg` / `token_count` がある場合は verified usage として表示し、無い場合だけ `estimated: true` / `source = codex-rollout-chars-div-4` の明示 estimate を使う。state filename は URL encode し、`codex:` session id を Windows でも保存可能にした。表示 ID は `codex:` prefix を外した raw thread id 先頭 8 桁。
|
|
470
470
|
- monitor doctor 修正: Codex Stop hook stdout は VSCode chat に必ず見えるとは限らないため、`doctor --codex` に `.vscode/tasks.json` の Throughline Monitor task 診断を追加した。登録済み / 未登録 / JSONC / parse error / broken absolute path と `runOn`、および初回作成後は `Developer: Reload Window` が必要という note を read-only で表示する。
|
|
471
471
|
- historical automatic refresh 実装: Codex Stop hook は capture / L1 summarize / monitor state 書き込み後、verified usage が verified context window の `90%` 以上なら automatic refresh を試行する経路を持っていた。2026-05-06 incident 後、この経路は blocker として扱い、monitor state / warning / diagnostics は残しても rollback + inject は送らない状態にした。2026-05-08 unblock 後は guarded rollback / inject を再有効化し、2026-05-09 以降は Codex native auto-compact より先に Throughline refresh を走らせるため verified usage `80%` 以上を既定閾値にする。
|
|
472
|
+
- current-session trigger 修正: Codex auto-refresh を token-monitor の描画ループに置かず、Codex `UserPromptSubmit` / `PostToolUse` hooks が当該 session の rollout `token_count` を直接読んで verified 80% 以上なら `$throughline` workflow 実行指示を同じ user turn または tool loop 継続前の `additionalContext` として注入する。monitor は表示専用で、自動発火の判定元にしない。
|
|
472
473
|
|
|
473
474
|
## Phase 6: Claude Side Finalization
|
|
474
475
|
|
|
@@ -646,6 +646,11 @@ TODO:
|
|
|
646
646
|
- [x] Codex Rewind-equivalent trim が complete だという roadmap claim を格下げする。
|
|
647
647
|
- [x] Codex auto-refresh は verified usage 80% 以上で guarded rollback / inject を
|
|
648
648
|
実行する。estimate usage では実行しない。
|
|
649
|
+
- [x] Codex 80% trigger は token-monitor 依存にしない。Codex `UserPromptSubmit`
|
|
650
|
+
/ `PostToolUse` hooks が当該 session の rollout `token_count` を直接読み、
|
|
651
|
+
verified 80% 以上なら current session へ `$throughline` workflow 実行指示を
|
|
652
|
+
注入する。`PostToolUse` により、tool loop 中に閾値を超えた場合も次の継続前に
|
|
653
|
+
同じ判定を走らせる。
|
|
649
654
|
- [x] 新セッションが古い「Codex side complete」claim ではなく、この fix plan から
|
|
650
655
|
再開できるように `CLAUDE.md` を更新する。
|
|
651
656
|
|
package/package.json
CHANGED
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:');
|
|
@@ -182,6 +187,160 @@ test('codex-hook stop runs auto refresh when verified usage reaches 80%', 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'
|
package/src/cli/doctor.test.mjs
CHANGED
|
@@ -155,6 +155,8 @@ test('runCodexDiagnosis: reports env thread and captured DB session', () => {
|
|
|
155
155
|
|
|
156
156
|
assert.match(output, /\[Codex primary\]/);
|
|
157
157
|
assert.match(output, /Codex hooks feature:\s+not enabled/);
|
|
158
|
+
assert.match(output, /Codex UserPrompt hook:\s+not registered/);
|
|
159
|
+
assert.match(output, /Codex PostTool hook:\s+not registered/);
|
|
158
160
|
assert.match(output, /Codex Stop hook:\s+not registered/);
|
|
159
161
|
assert.match(output, /VSCode monitor task:\s+not registered/);
|
|
160
162
|
assert.match(output, /created by the next VSCode hook event/);
|
|
@@ -328,7 +330,7 @@ test('buildCodexContextRefreshDiagnosis keeps ready label when restore safety is
|
|
|
328
330
|
}
|
|
329
331
|
});
|
|
330
332
|
|
|
331
|
-
test('readCodexHookDiagnosis detects
|
|
333
|
+
test('readCodexHookDiagnosis detects Codex prompt and Stop hooks', () => {
|
|
332
334
|
const codexHome = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-home-'));
|
|
333
335
|
try {
|
|
334
336
|
mkdirSync(join(codexHome), { recursive: true });
|
|
@@ -337,6 +339,30 @@ test('readCodexHookDiagnosis detects legacy bare Throughline Codex Stop hook', (
|
|
|
337
339
|
JSON.stringify(
|
|
338
340
|
{
|
|
339
341
|
hooks: {
|
|
342
|
+
UserPromptSubmit: [
|
|
343
|
+
{
|
|
344
|
+
hooks: [
|
|
345
|
+
{
|
|
346
|
+
type: 'command',
|
|
347
|
+
command: '/usr/bin/node /pkg/bin/throughline.mjs codex-hook user-prompt-submit',
|
|
348
|
+
timeoutSec: 30,
|
|
349
|
+
async: false,
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
PostToolUse: [
|
|
355
|
+
{
|
|
356
|
+
hooks: [
|
|
357
|
+
{
|
|
358
|
+
type: 'command',
|
|
359
|
+
command: '/usr/bin/node /pkg/bin/throughline.mjs codex-hook post-tool-use',
|
|
360
|
+
timeoutSec: 30,
|
|
361
|
+
async: false,
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
],
|
|
340
366
|
Stop: [
|
|
341
367
|
{
|
|
342
368
|
hooks: [
|
|
@@ -355,10 +381,16 @@ test('readCodexHookDiagnosis detects legacy bare Throughline Codex Stop hook', (
|
|
|
355
381
|
2,
|
|
356
382
|
) + '\n',
|
|
357
383
|
);
|
|
358
|
-
writeFileSync(join(codexHome, 'config.toml'), '[features]\ncodex_hooks = true\n');
|
|
384
|
+
writeFileSync(join(codexHome, 'config.toml'), '[features]\ncodex_hooks = true\nhooks = true\n');
|
|
359
385
|
|
|
360
386
|
const diagnosis = readCodexHookDiagnosis(codexHome);
|
|
361
387
|
assert.equal(diagnosis.featureEnabled, true);
|
|
388
|
+
assert.equal(diagnosis.codexHooksFeatureEnabled, true);
|
|
389
|
+
assert.equal(diagnosis.hooksFeatureEnabled, true);
|
|
390
|
+
assert.equal(diagnosis.managedPromptHooks.length, 1);
|
|
391
|
+
assert.equal(diagnosis.legacyManagedPromptHooks.length, 1);
|
|
392
|
+
assert.equal(diagnosis.managedPostToolUseHooks.length, 1);
|
|
393
|
+
assert.equal(diagnosis.legacyManagedPostToolUseHooks.length, 1);
|
|
362
394
|
assert.equal(diagnosis.managedStopHooks.length, 1);
|
|
363
395
|
assert.equal(diagnosis.legacyManagedStopHooks.length, 1);
|
|
364
396
|
} finally {
|
package/src/cli/help.test.mjs
CHANGED
|
@@ -9,6 +9,8 @@ const REPO_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
|
9
9
|
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
10
10
|
const CODEX_HELP_COMMANDS = [
|
|
11
11
|
'throughline codex-capture',
|
|
12
|
+
'throughline codex-hook user-prompt-submit',
|
|
13
|
+
'throughline codex-hook post-tool-use',
|
|
12
14
|
'throughline codex-hook stop',
|
|
13
15
|
'throughline codex-summarize',
|
|
14
16
|
'throughline codex-resume',
|
package/src/cli/install.mjs
CHANGED
|
@@ -54,6 +54,8 @@ const SC_HOOKS = {
|
|
|
54
54
|
|
|
55
55
|
const CODEX_COMMANDS = [
|
|
56
56
|
'throughline codex-hook stop',
|
|
57
|
+
'throughline codex-hook user-prompt-submit',
|
|
58
|
+
'throughline codex-hook post-tool-use',
|
|
57
59
|
];
|
|
58
60
|
|
|
59
61
|
function quoteCommandPath(p) {
|
|
@@ -67,6 +69,36 @@ export function buildCodexStopHookCommand({
|
|
|
67
69
|
return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook stop`;
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
export function buildCodexUserPromptSubmitHookCommand({
|
|
73
|
+
nodePath = process.execPath,
|
|
74
|
+
cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
|
|
75
|
+
} = {}) {
|
|
76
|
+
return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook user-prompt-submit`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildCodexPostToolUseHookCommand({
|
|
80
|
+
nodePath = process.execPath,
|
|
81
|
+
cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
|
|
82
|
+
} = {}) {
|
|
83
|
+
return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} codex-hook post-tool-use`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isThroughlineCodexHookCommand(command) {
|
|
87
|
+
if (typeof command !== 'string') return false;
|
|
88
|
+
const normalized = command.replace(/["']/g, '');
|
|
89
|
+
return (
|
|
90
|
+
normalized === 'throughline codex-hook stop' ||
|
|
91
|
+
normalized === 'throughline codex-hook user-prompt-submit' ||
|
|
92
|
+
normalized === 'throughline codex-hook post-tool-use' ||
|
|
93
|
+
normalized.includes('throughline codex-hook stop') ||
|
|
94
|
+
normalized.includes('throughline codex-hook user-prompt-submit') ||
|
|
95
|
+
normalized.includes('throughline codex-hook post-tool-use') ||
|
|
96
|
+
normalized.includes('throughline.mjs codex-hook stop') ||
|
|
97
|
+
normalized.includes('throughline.mjs codex-hook user-prompt-submit') ||
|
|
98
|
+
normalized.includes('throughline.mjs codex-hook post-tool-use')
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
export function isThroughlineCodexStopCommand(command) {
|
|
71
103
|
if (typeof command !== 'string') return false;
|
|
72
104
|
const normalized = command.replace(/["']/g, '');
|
|
@@ -77,8 +109,40 @@ export function isThroughlineCodexStopCommand(command) {
|
|
|
77
109
|
);
|
|
78
110
|
}
|
|
79
111
|
|
|
112
|
+
export function isThroughlineCodexPostToolUseCommand(command) {
|
|
113
|
+
if (typeof command !== 'string') return false;
|
|
114
|
+
const normalized = command.replace(/["']/g, '');
|
|
115
|
+
return (
|
|
116
|
+
normalized === 'throughline codex-hook post-tool-use' ||
|
|
117
|
+
normalized.includes('throughline codex-hook post-tool-use') ||
|
|
118
|
+
normalized.includes('throughline.mjs codex-hook post-tool-use')
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
80
122
|
function createCodexHooks() {
|
|
81
123
|
return {
|
|
124
|
+
UserPromptSubmit: {
|
|
125
|
+
hooks: [
|
|
126
|
+
{
|
|
127
|
+
type: 'command',
|
|
128
|
+
command: buildCodexUserPromptSubmitHookCommand(),
|
|
129
|
+
timeoutSec: 30,
|
|
130
|
+
async: false,
|
|
131
|
+
statusMessage: null,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
PostToolUse: {
|
|
136
|
+
hooks: [
|
|
137
|
+
{
|
|
138
|
+
type: 'command',
|
|
139
|
+
command: buildCodexPostToolUseHookCommand(),
|
|
140
|
+
timeoutSec: 30,
|
|
141
|
+
async: false,
|
|
142
|
+
statusMessage: null,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
82
146
|
Stop: {
|
|
83
147
|
hooks: [
|
|
84
148
|
{
|
|
@@ -253,11 +317,19 @@ function ensureCodexHooksFeature(configPath) {
|
|
|
253
317
|
const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
254
318
|
const lines = existing.split(/\r?\n/);
|
|
255
319
|
const sectionStart = lines.findIndex((line) => line.trim() === '[features]');
|
|
320
|
+
const ensureFeatureLine = (featureLines, name) => {
|
|
321
|
+
const idx = featureLines.findIndex((line) => new RegExp(`^\\s*${name}\\s*=`).test(line));
|
|
322
|
+
if (idx === -1) {
|
|
323
|
+
featureLines.push(`${name} = true`);
|
|
324
|
+
} else {
|
|
325
|
+
featureLines[idx] = `${name} = true`;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
256
328
|
let updated;
|
|
257
329
|
|
|
258
330
|
if (sectionStart === -1) {
|
|
259
331
|
const prefix = existing.trimEnd();
|
|
260
|
-
updated = `${prefix}${prefix ? '\n\n' : ''}[features]\ncodex_hooks = true\n`;
|
|
332
|
+
updated = `${prefix}${prefix ? '\n\n' : ''}[features]\ncodex_hooks = true\nhooks = true\n`;
|
|
261
333
|
} else {
|
|
262
334
|
let sectionEnd = lines.length;
|
|
263
335
|
for (let i = sectionStart + 1; i < lines.length; i++) {
|
|
@@ -267,14 +339,10 @@ function ensureCodexHooksFeature(configPath) {
|
|
|
267
339
|
}
|
|
268
340
|
}
|
|
269
341
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
lines.splice(sectionStart + 1, 0, 'codex_hooks = true');
|
|
275
|
-
} else {
|
|
276
|
-
lines[sectionStart + 1 + codexHooksLine] = 'codex_hooks = true';
|
|
277
|
-
}
|
|
342
|
+
const featureLines = lines.slice(sectionStart + 1, sectionEnd);
|
|
343
|
+
ensureFeatureLine(featureLines, 'codex_hooks');
|
|
344
|
+
ensureFeatureLine(featureLines, 'hooks');
|
|
345
|
+
lines.splice(sectionStart + 1, sectionEnd - sectionStart - 1, ...featureLines);
|
|
278
346
|
updated = lines.join('\n').replace(/\n*$/, '\n');
|
|
279
347
|
}
|
|
280
348
|
|
|
@@ -294,7 +362,7 @@ function installCodexHooks() {
|
|
|
294
362
|
const list = existingHooks[key] ?? [];
|
|
295
363
|
const preserved = [];
|
|
296
364
|
for (const group of list) {
|
|
297
|
-
const hooks = (group.hooks ?? []).filter(h => !
|
|
365
|
+
const hooks = (group.hooks ?? []).filter(h => !isThroughlineCodexHookCommand(h.command));
|
|
298
366
|
if (hooks.length > 0) preserved.push({ ...group, hooks });
|
|
299
367
|
}
|
|
300
368
|
existingHooks[key] = [entry, ...preserved];
|
|
@@ -323,7 +391,7 @@ function uninstallCodexHooks() {
|
|
|
323
391
|
const hooks = (group.hooks ?? []).filter((hook) => {
|
|
324
392
|
const shouldRemove =
|
|
325
393
|
CODEX_COMMANDS.includes(hook.command) ||
|
|
326
|
-
|
|
394
|
+
isThroughlineCodexHookCommand(hook.command);
|
|
327
395
|
if (shouldRemove) removed++;
|
|
328
396
|
return !shouldRemove;
|
|
329
397
|
});
|
|
@@ -422,7 +490,9 @@ export async function run(args = []) {
|
|
|
422
490
|
console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
|
|
423
491
|
console.log(' UserPromptSubmit → throughline prompt-submit (/tl & /clear バトン書き込み)');
|
|
424
492
|
if (codex) {
|
|
425
|
-
console.log(` Codex
|
|
493
|
+
console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (80% 到達時に current session へ $throughline 指示を注入)`);
|
|
494
|
+
console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (tool loop 中も 80% 到達時に $throughline 指示を注入)`);
|
|
495
|
+
console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
|
|
426
496
|
}
|
|
427
497
|
console.log('');
|
|
428
498
|
if (installedCommands.length > 0) {
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -4,7 +4,13 @@ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync
|
|
|
4
4
|
import { tmpdir, homedir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
buildCodexPostToolUseHookCommand,
|
|
9
|
+
buildCodexStopHookCommand,
|
|
10
|
+
buildCodexUserPromptSubmitHookCommand,
|
|
11
|
+
run,
|
|
12
|
+
resolveThroughlineOnPath,
|
|
13
|
+
} from './install.mjs';
|
|
8
14
|
|
|
9
15
|
function makeTempHome() {
|
|
10
16
|
const dir = mkdtempSync(join(tmpdir(), 'tl-install-test-'));
|
|
@@ -94,7 +100,7 @@ test('project install copies commands to cwd/.claude/commands/', async () => {
|
|
|
94
100
|
}
|
|
95
101
|
});
|
|
96
102
|
|
|
97
|
-
test('global install registers Codex
|
|
103
|
+
test('global install registers Codex session hooks and enables hooks features', async () => {
|
|
98
104
|
const home = makeTempHome();
|
|
99
105
|
if (home.resolved !== home.dir) {
|
|
100
106
|
home.restore();
|
|
@@ -104,15 +110,30 @@ test('global install registers Codex Stop hook and enables codex_hooks feature',
|
|
|
104
110
|
try {
|
|
105
111
|
await run([]);
|
|
106
112
|
const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
|
|
107
|
-
const
|
|
113
|
+
const expectedStopCommand = buildCodexStopHookCommand();
|
|
114
|
+
const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
|
|
115
|
+
const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
|
|
108
116
|
const codexHook = hooks.hooks.Stop
|
|
109
117
|
.flatMap(g => g.hooks ?? [])
|
|
110
|
-
.find(h => h.command ===
|
|
118
|
+
.find(h => h.command === expectedStopCommand);
|
|
111
119
|
assert.ok(codexHook, 'Codex Stop should have absolute throughline.mjs codex-hook stop');
|
|
112
120
|
assert.equal(codexHook.async, false, 'Codex Stop hook should be synchronous for Codex');
|
|
113
121
|
assert.equal(codexHook.timeoutSec, 300, 'Codex Stop hook should allow summarizer time');
|
|
122
|
+
const promptHook = hooks.hooks.UserPromptSubmit
|
|
123
|
+
.flatMap(g => g.hooks ?? [])
|
|
124
|
+
.find(h => h.command === expectedPromptCommand);
|
|
125
|
+
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 context injection');
|
|
127
|
+
assert.equal(promptHook.timeoutSec, 30, 'Codex UserPromptSubmit hook should be short');
|
|
128
|
+
const postToolUseHook = hooks.hooks.PostToolUse
|
|
129
|
+
.flatMap(g => g.hooks ?? [])
|
|
130
|
+
.find(h => h.command === expectedPostToolUseCommand);
|
|
131
|
+
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 context injection');
|
|
133
|
+
assert.equal(postToolUseHook.timeoutSec, 30, 'Codex PostToolUse hook should be short');
|
|
114
134
|
const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
|
|
115
135
|
assert.match(config, /^\[features\]\ncodex_hooks = true/m);
|
|
136
|
+
assert.match(config, /^hooks = true/m);
|
|
116
137
|
} finally {
|
|
117
138
|
unsilence();
|
|
118
139
|
home.restore();
|
|
@@ -215,21 +236,30 @@ test('global install preserves existing Codex hooks and is idempotent', async ()
|
|
|
215
236
|
await run([]);
|
|
216
237
|
await run([]);
|
|
217
238
|
const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
239
|
+
const stopCommands = hooks.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
|
|
240
|
+
const promptCommands = hooks.hooks.UserPromptSubmit.flatMap(g => g.hooks ?? []).map(h => h.command);
|
|
241
|
+
const postToolUseCommands = hooks.hooks.PostToolUse.flatMap(g => g.hooks ?? []).map(h => h.command);
|
|
242
|
+
const expectedStopCommand = buildCodexStopHookCommand();
|
|
243
|
+
const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
|
|
244
|
+
const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
|
|
245
|
+
assert.ok(stopCommands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
|
|
246
|
+
assert.equal(stopCommands.filter(c => c === expectedStopCommand).length, 1);
|
|
247
|
+
assert.equal(promptCommands.filter(c => c === expectedPromptCommand).length, 1);
|
|
248
|
+
assert.equal(postToolUseCommands.filter(c => c === expectedPostToolUseCommand).length, 1);
|
|
249
|
+
assert.ok(!stopCommands.includes('throughline codex-hook stop'));
|
|
250
|
+
assert.ok(!promptCommands.includes('throughline codex-hook user-prompt-submit'));
|
|
251
|
+
assert.ok(!postToolUseCommands.includes('throughline codex-hook post-tool-use'));
|
|
223
252
|
const config = readFileSync(join(home.dir, '.codex', 'config.toml'), 'utf8');
|
|
224
253
|
assert.match(config, /other = true/);
|
|
225
254
|
assert.match(config, /codex_hooks = true/);
|
|
255
|
+
assert.match(config, /hooks = true/);
|
|
226
256
|
} finally {
|
|
227
257
|
unsilence();
|
|
228
258
|
home.restore();
|
|
229
259
|
}
|
|
230
260
|
});
|
|
231
261
|
|
|
232
|
-
test('global install updates existing Throughline Codex
|
|
262
|
+
test('global install updates existing Throughline Codex hook shapes', async () => {
|
|
233
263
|
const home = makeTempHome();
|
|
234
264
|
if (home.resolved !== home.dir) {
|
|
235
265
|
home.restore();
|
|
@@ -241,6 +271,32 @@ test('global install updates existing Throughline Codex Stop hook shape', async
|
|
|
241
271
|
JSON.stringify(
|
|
242
272
|
{
|
|
243
273
|
hooks: {
|
|
274
|
+
UserPromptSubmit: [
|
|
275
|
+
{
|
|
276
|
+
hooks: [
|
|
277
|
+
{
|
|
278
|
+
type: 'command',
|
|
279
|
+
command: 'throughline codex-hook user-prompt-submit',
|
|
280
|
+
timeoutSec: 300,
|
|
281
|
+
async: true,
|
|
282
|
+
statusMessage: null,
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
PostToolUse: [
|
|
288
|
+
{
|
|
289
|
+
hooks: [
|
|
290
|
+
{
|
|
291
|
+
type: 'command',
|
|
292
|
+
command: 'throughline codex-hook post-tool-use',
|
|
293
|
+
timeoutSec: 300,
|
|
294
|
+
async: true,
|
|
295
|
+
statusMessage: null,
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
],
|
|
244
300
|
Stop: [
|
|
245
301
|
{
|
|
246
302
|
hooks: [
|
|
@@ -264,17 +320,39 @@ test('global install updates existing Throughline Codex Stop hook shape', async
|
|
|
264
320
|
try {
|
|
265
321
|
await run([]);
|
|
266
322
|
const hooks = JSON.parse(readFileSync(join(home.dir, '.codex', 'hooks.json'), 'utf8'));
|
|
267
|
-
const
|
|
323
|
+
const expectedStopCommand = buildCodexStopHookCommand();
|
|
324
|
+
const expectedPromptCommand = buildCodexUserPromptSubmitHookCommand();
|
|
325
|
+
const expectedPostToolUseCommand = buildCodexPostToolUseHookCommand();
|
|
268
326
|
const codexHooks = hooks.hooks.Stop
|
|
269
327
|
.flatMap(g => g.hooks ?? [])
|
|
270
|
-
.filter(h => h.command ===
|
|
328
|
+
.filter(h => h.command === expectedStopCommand);
|
|
271
329
|
assert.equal(codexHooks.length, 1);
|
|
272
330
|
assert.equal(codexHooks[0].async, false);
|
|
273
331
|
assert.equal(codexHooks[0].timeoutSec, 300);
|
|
332
|
+
const promptHooks = hooks.hooks.UserPromptSubmit
|
|
333
|
+
.flatMap(g => g.hooks ?? [])
|
|
334
|
+
.filter(h => h.command === expectedPromptCommand);
|
|
335
|
+
assert.equal(promptHooks.length, 1);
|
|
336
|
+
assert.equal(promptHooks[0].async, false);
|
|
337
|
+
assert.equal(promptHooks[0].timeoutSec, 30);
|
|
338
|
+
const postToolUseHooks = hooks.hooks.PostToolUse
|
|
339
|
+
.flatMap(g => g.hooks ?? [])
|
|
340
|
+
.filter(h => h.command === expectedPostToolUseCommand);
|
|
341
|
+
assert.equal(postToolUseHooks.length, 1);
|
|
342
|
+
assert.equal(postToolUseHooks[0].async, false);
|
|
343
|
+
assert.equal(postToolUseHooks[0].timeoutSec, 30);
|
|
274
344
|
assert.equal(
|
|
275
345
|
hooks.hooks.Stop.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook stop').length,
|
|
276
346
|
0,
|
|
277
347
|
);
|
|
348
|
+
assert.equal(
|
|
349
|
+
hooks.hooks.UserPromptSubmit.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook user-prompt-submit').length,
|
|
350
|
+
0,
|
|
351
|
+
);
|
|
352
|
+
assert.equal(
|
|
353
|
+
hooks.hooks.PostToolUse.flatMap(g => g.hooks ?? []).filter(h => h.command === 'throughline codex-hook post-tool-use').length,
|
|
354
|
+
0,
|
|
355
|
+
);
|
|
278
356
|
} finally {
|
|
279
357
|
unsilence();
|
|
280
358
|
home.restore();
|
|
@@ -330,10 +408,16 @@ test('global uninstall removes only Throughline-managed Codex hook', async () =>
|
|
|
330
408
|
|
|
331
409
|
await run(['--uninstall']);
|
|
332
410
|
const after = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
assert.ok(
|
|
411
|
+
const stopCommands = after.hooks.Stop.flatMap(g => g.hooks ?? []).map(h => h.command);
|
|
412
|
+
const promptCommands = after.hooks.UserPromptSubmit?.flatMap(g => g.hooks ?? []).map(h => h.command) ?? [];
|
|
413
|
+
const postToolUseCommands = after.hooks.PostToolUse?.flatMap(g => g.hooks ?? []).map(h => h.command) ?? [];
|
|
414
|
+
assert.ok(stopCommands.includes('/usr/bin/node /home/kite/.npm-global/bin/caveat codex-hook stop'));
|
|
415
|
+
assert.ok(!stopCommands.includes('throughline codex-hook stop'));
|
|
416
|
+
assert.ok(!stopCommands.some(c => c.includes('throughline.mjs codex-hook stop')));
|
|
417
|
+
assert.ok(!promptCommands.includes('throughline codex-hook user-prompt-submit'));
|
|
418
|
+
assert.ok(!promptCommands.some(c => c.includes('throughline.mjs codex-hook user-prompt-submit')));
|
|
419
|
+
assert.ok(!postToolUseCommands.includes('throughline codex-hook post-tool-use'));
|
|
420
|
+
assert.ok(!postToolUseCommands.some(c => c.includes('throughline.mjs codex-hook post-tool-use')));
|
|
337
421
|
} finally {
|
|
338
422
|
unsilence();
|
|
339
423
|
home.restore();
|