throughline 0.16.22 → 0.16.23
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
CHANGED
|
@@ -10,6 +10,29 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.23] — 2026-10-11
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- **`install`と`self-update`が、Codexの信用済みのhookを「変わったhook」にしていた**([ADR 0052](docs/adr/0052-install-keeps-registered-codex-hook-command.md))。Codexのhookの命令は`<node> <throughline.mjs> codex-hook <event>`で、`<node>`の表記は入れる側のPATHで決まる(PATHの上に今のnodeと同じ実体があればその表記、無ければ`process.execPath`)。`install`はhookを毎回作り直すので、前と違うPATHで入れると、同じnodeを指したまま文字列だけが変わった。Codexはhookの信用を命令の文字列で決めるので、Throughlineのhookが4つとも`modified`になり、新しい会話が起動の所で「Hooks need review」を出して、人が選ぶまで止まった。
|
|
18
|
+
- Linuxの実機で2026-10-11に起きた。09:20に`~/.local/bin`をPATHの先頭に付けて`self-update`を流し、`~/.local/lib/node-v24/bin/node`が、同じ実体を指すsymlinkの`~/.local/bin/node`に変わった。15:05に、常駐のprocessが立てるCodexの会話がそこで止まっていると知らせを受けた。
|
|
19
|
+
- 直した後は、登録済みの命令が今のnodeと`throughline.mjs`の実体(realpath)を指している時、その文字列のまま書く。`auto-handoff enable`が置く`PreCompact`も同じ。登録が無い時、旧い形の時、nodeかscriptの実体が違う時は、今までどおり書き直す。
|
|
20
|
+
- nodeの実体が変わった時(別の版の場所へ入れ直した時など)は命令が変わり、Codexは確認の画面を出す。これは変えていない。
|
|
21
|
+
|
|
22
|
+
### 対応の範囲
|
|
23
|
+
|
|
24
|
+
| | macOS | Linux | Windows |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| Codex | 直した。単体試験と、実機(Codex Desktop同梱の0.162.0-alpha.17.2)。仮のHOMEとCODEX_HOMEでhookを信用し、PATHだけ変えて入れ直すと、入っていた0.16.22は4つとも`modified`、この版は4つとも`trusted`で`hooks.json`は1バイトも変わらない | 直した。単体試験と、実機(codex-cli 0.162.1)で同じ確認 | 直した。単体試験(表記の違いは大文字と小文字で作る)と、実機(Codex Desktop同梱の0.162.0-alpha.17.2)で同じ確認 |
|
|
27
|
+
| Claude Code | 変更なし(hookの命令は`throughline <subcommand>`で、nodeの場所が入らない) | 同じ | 同じ |
|
|
28
|
+
| Grok | 変更なし。命令には同じ決め方でnodeの表記が入り、入れる側のPATHで変わる。Grokがhookの変更で確認を出すかは確かめていない | 同じ | 同じ |
|
|
29
|
+
| Cursor | 変更なし。Grokと同じ | 同じ | 同じ |
|
|
30
|
+
|
|
31
|
+
### 確かめていない事
|
|
32
|
+
|
|
33
|
+
- 本物の`~/.codex`で、信用済みのhookを持つ端末へこの版を違うPATHから入れる所。確認は、仮のHOMEとCODEX_HOMEで、本物のCodexのApp Server(`hooks/list`)に信用の状態を聞く形で行った。
|
|
34
|
+
- `doctor --codex`の「trusted」は、`trusted_hash`が記録されているかだけを見ている。Codexが今の命令と比べた結果は見ていないので、`modified`でも「trusted」と出る。この版では変えていない。
|
|
35
|
+
|
|
13
36
|
## [0.16.22] — 2026-10-11
|
|
14
37
|
|
|
15
38
|
### 変更
|
|
@@ -2378,7 +2401,8 @@ two attempts, instrument first instead of patching again.
|
|
|
2378
2401
|
|
|
2379
2402
|
---
|
|
2380
2403
|
|
|
2381
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2404
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.23...HEAD
|
|
2405
|
+
[0.16.23]: https://github.com/kitepon/Throughline/compare/v0.16.22...v0.16.23
|
|
2382
2406
|
[0.16.22]: https://github.com/kitepon/Throughline/compare/v0.16.21...v0.16.22
|
|
2383
2407
|
[0.16.21]: https://github.com/kitepon/Throughline/compare/v0.16.20...v0.16.21
|
|
2384
2408
|
[0.16.20]: https://github.com/kitepon/Throughline/compare/v0.16.19...v0.16.20
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# ADR 0052: install は、今の node を指す登録済みの Codex hook の命令を書き換えない
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Codex は、hook を1つずつ信用の対象にする。信用は `config.toml` の `hooks.state."<hooks.json>:<event>:<group>:<hook>".trusted_hash`
|
|
9
|
+
に残り、hook の定義(命令の文字列を含む)から作った hash と比べる。hash が違う hook は「変わった hook」(`modified`)になり、
|
|
10
|
+
新しい会話は起動の所で「Hooks need review」の画面を出して、人が選ぶまで止まる。
|
|
11
|
+
|
|
12
|
+
Throughline の Codex hook の命令は `<node> <throughline.mjs> codex-hook <event>` で、`<node>` は
|
|
13
|
+
`resolveCodexHookNodePath` が決める。PATH の上に今の node と同じ実体があればその表記、無ければ `process.execPath`。
|
|
14
|
+
Homebrew の node を版ごとの場所(Cellar)で登録しないための決め方だが、同じ端末でも、入れる側の PATH で表記が変わる。
|
|
15
|
+
|
|
16
|
+
2026-10-11、Linux の実機で、`~/.local/bin` を PATH の先頭に付けて `self-update` を流した。それまで
|
|
17
|
+
`~/.local/lib/node-v24/bin/node` だった表記が、同じ実体を指す symlink の `~/.local/bin/node` に変わった。
|
|
18
|
+
Throughline の hook は4つとも `modified` になり、新しい Codex の会話が確認の画面で止まった。常駐の process が
|
|
19
|
+
立てる会話も、そこで止まった。
|
|
20
|
+
|
|
21
|
+
`install` は、Throughline の hook を毎回作り直して書いていた。`doctor` と `factory-diagnostics` は、登録済みの命令と
|
|
22
|
+
期待する命令を実体(realpath)で比べるので、表記が違っても「登録済み」と出す。`install` だけが、文字列で置き換えていた。
|
|
23
|
+
|
|
24
|
+
## Decision
|
|
25
|
+
|
|
26
|
+
1. `install` と `auto-handoff enable` は、登録済みの Throughline の Codex hook の命令が、期待する命令と同じ実体
|
|
27
|
+
(node と `throughline.mjs` の realpath、event)を指している時、その文字列のまま書く。
|
|
28
|
+
2. 登録が無い時、旧い形の時、node か script の実体が違う時(realpath を解決できない時を含む)は、今までどおり
|
|
29
|
+
期待する命令で書く。
|
|
30
|
+
3. 表記を「より安定した物」へ寄せるための書き換えはしない。
|
|
31
|
+
|
|
32
|
+
## Consequences
|
|
33
|
+
|
|
34
|
+
- 入れる側の PATH が変わっても、同じ node を使っている間は、Codex の信用が残る。
|
|
35
|
+
- node の実体が変わった時(別の版の場所へ入れ直した時など)は命令が変わり、Codex は確認の画面を出す。これは避けない。
|
|
36
|
+
命令が指す実行ファイルが変わった事を、利用者が確かめる場面だから。
|
|
37
|
+
- 版ごとの場所(Homebrew の Cellar など)で登録された命令は、その場所が残っている間はそのまま残る。場所が消えた後の
|
|
38
|
+
`install` で、期待する命令へ書き直す。その間 hook は失敗する。`doctor` と `factory-diagnostics` は、実体を
|
|
39
|
+
解決できない登録を「登録済み」と見ないので、そこで分かる。
|
|
40
|
+
- `doctor --codex` の「trusted」は、`trusted_hash` が記録されているかだけを見ている。Codex が今の命令と比べた結果
|
|
41
|
+
(`trusted`・`modified`・`untrusted`)は、Codex の App Server の `hooks/list` でしか分からない。この ADR では変えない。
|
|
42
|
+
- Grok と Cursor の hook の命令も同じ決め方で node の表記が入るが、この ADR では変えない。
|
package/package.json
CHANGED
package/src/cli/install.mjs
CHANGED
|
@@ -250,15 +250,25 @@ function isSameExecutablePath(a, b, realpath) {
|
|
|
250
250
|
return left !== null && right !== null && left === right;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
|
|
253
|
+
// 登録済みの命令が今の node と script の実体を指している時は、その文字列のまま返す(ADR 0052)。
|
|
254
|
+
// node の表記は入れる側の PATH で変わる。Codex は hook の信用を命令の文字列で決めるので、表記だけを書き換えると
|
|
255
|
+
// 信用済みの hook が「変わった hook」になり、新しい会話が確認の画面で止まる。
|
|
256
|
+
function registeredCodexHookCommand(expected, groups) {
|
|
257
|
+
const registered = (Array.isArray(groups) ? groups : []).flatMap(group => group?.hooks ?? []).map(hook => hook?.command)
|
|
258
|
+
.find(command => isEquivalentCodexHookCommand(command, expected));
|
|
259
|
+
return registered ?? expected;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function createCodexHooks(registered = {}) {
|
|
263
|
+
const command = (event, expected) => registeredCodexHookCommand(expected, registered?.[event]);
|
|
254
264
|
return {
|
|
255
|
-
PreCompact: { matcher: 'auto', hooks: [{ type: 'command', command: buildCodexPreCompactHookCommand(),
|
|
265
|
+
PreCompact: { matcher: 'auto', hooks: [{ type: 'command', command: command('PreCompact', buildCodexPreCompactHookCommand()),
|
|
256
266
|
timeout: 30, async: false, statusMessage: 'Throughline: 自動継続を準備' }] },
|
|
257
267
|
UserPromptSubmit: {
|
|
258
268
|
hooks: [
|
|
259
269
|
{
|
|
260
270
|
type: 'command',
|
|
261
|
-
command: buildCodexUserPromptSubmitHookCommand(),
|
|
271
|
+
command: command('UserPromptSubmit', buildCodexUserPromptSubmitHookCommand()),
|
|
262
272
|
timeout: 30,
|
|
263
273
|
async: false,
|
|
264
274
|
statusMessage: null,
|
|
@@ -269,7 +279,7 @@ function createCodexHooks() {
|
|
|
269
279
|
hooks: [
|
|
270
280
|
{
|
|
271
281
|
type: 'command',
|
|
272
|
-
command: buildCodexPostToolUseHookCommand(),
|
|
282
|
+
command: command('PostToolUse', buildCodexPostToolUseHookCommand()),
|
|
273
283
|
timeout: 30,
|
|
274
284
|
async: false,
|
|
275
285
|
statusMessage: null,
|
|
@@ -280,7 +290,7 @@ function createCodexHooks() {
|
|
|
280
290
|
hooks: [
|
|
281
291
|
{
|
|
282
292
|
type: 'command',
|
|
283
|
-
command: buildCodexStopHookCommand(),
|
|
293
|
+
command: command('Stop', buildCodexStopHookCommand()),
|
|
284
294
|
timeout: 300,
|
|
285
295
|
async: false,
|
|
286
296
|
statusMessage: null,
|
|
@@ -628,7 +638,7 @@ function installCodexHooks() {
|
|
|
628
638
|
const configPath = resolveCodexConfigPath();
|
|
629
639
|
const current = readSettings(hooksPath);
|
|
630
640
|
const existingHooks = current.hooks ?? {};
|
|
631
|
-
const codexHooks = createCodexHooks();
|
|
641
|
+
const codexHooks = createCodexHooks(existingHooks);
|
|
632
642
|
|
|
633
643
|
for (const [key, entry] of Object.entries(codexHooks)) {
|
|
634
644
|
const list = existingHooks[key] ?? [];
|
|
@@ -705,7 +715,7 @@ export function installCodexAutoHandoffHook(codexHome = join(homedir(), '.codex'
|
|
|
705
715
|
const configPath = join(codexHome, 'config.toml');
|
|
706
716
|
const current = readSettings(hooksPath);
|
|
707
717
|
current.hooks ??= {};
|
|
708
|
-
const entry = createCodexHooks().PreCompact;
|
|
718
|
+
const entry = createCodexHooks(current.hooks).PreCompact;
|
|
709
719
|
const groups = current.hooks.PreCompact ?? [];
|
|
710
720
|
const existing = groups.flatMap(group => group.hooks ?? []).filter(hook => isThroughlineCodexHookCommand(hook.command));
|
|
711
721
|
if (existing.length !== 1 || existing[0].command !== entry.hooks[0].command || existing[0].timeout !== 30) {
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync, symlinkSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { tmpdir, homedir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
buildCodexPostToolUseHookCommand,
|
|
9
|
+
buildCodexPreCompactHookCommand,
|
|
9
10
|
buildCodexStopHookCommand,
|
|
10
11
|
buildCodexUserPromptSubmitHookCommand,
|
|
11
12
|
buildCursorHookCommand,
|
|
12
13
|
buildGrokHookCommand,
|
|
13
14
|
createGrokHooksFile,
|
|
14
15
|
createCursorHookEntries,
|
|
16
|
+
installCodexAutoHandoffHook,
|
|
15
17
|
isEquivalentCodexHookCommand,
|
|
16
18
|
isThroughlineCodexHookCommand,
|
|
17
19
|
isThroughlineCursorHookCommand,
|
|
@@ -855,3 +857,94 @@ test('install is idempotent: second run keeps exactly one tl.md and one hook ent
|
|
|
855
857
|
home.restore();
|
|
856
858
|
}
|
|
857
859
|
});
|
|
860
|
+
|
|
861
|
+
// 今の node を指す、別の表記。POSIX は symlink、Windows は大文字と小文字を入れ替えた path(どちらも realpath が同じになる)。
|
|
862
|
+
function alternateNodeSpelling(dir) {
|
|
863
|
+
if (process.platform === 'win32') {
|
|
864
|
+
const upper = process.execPath.toUpperCase();
|
|
865
|
+
return upper !== process.execPath ? upper : process.execPath.toLowerCase();
|
|
866
|
+
}
|
|
867
|
+
mkdirSync(join(dir, 'altbin'), { recursive: true });
|
|
868
|
+
const link = join(dir, 'altbin', 'node');
|
|
869
|
+
symlinkSync(realpathSync.native(process.execPath), link);
|
|
870
|
+
return link;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
test('install は、今の node を指す登録済みの Codex hook の命令を書き換えない(Codex の信用を保つ)', async () => {
|
|
874
|
+
const home = makeTempHome();
|
|
875
|
+
if (home.resolved !== home.dir) {
|
|
876
|
+
home.restore();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const unsilence = silence();
|
|
880
|
+
try {
|
|
881
|
+
const hooksPath = join(home.dir, '.codex', 'hooks.json');
|
|
882
|
+
await run([]);
|
|
883
|
+
const nodePath = alternateNodeSpelling(home.dir);
|
|
884
|
+
const registered = {
|
|
885
|
+
PreCompact: buildCodexPreCompactHookCommand({ nodePath }),
|
|
886
|
+
UserPromptSubmit: buildCodexUserPromptSubmitHookCommand({ nodePath }),
|
|
887
|
+
PostToolUse: buildCodexPostToolUseHookCommand({ nodePath }),
|
|
888
|
+
Stop: buildCodexStopHookCommand({ nodePath }),
|
|
889
|
+
};
|
|
890
|
+
assert.notEqual(registered.Stop, buildCodexStopHookCommand({ nodePath: process.execPath }));
|
|
891
|
+
// ほかの PATH から入れた時の登録を作る。node の表記だけが違い、指す実体は同じ。
|
|
892
|
+
const file = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
893
|
+
for (const [event, command] of Object.entries(registered)) {
|
|
894
|
+
const own = file.hooks[event].flatMap(group => group.hooks).filter(hook => isThroughlineCodexHookCommand(hook.command));
|
|
895
|
+
assert.equal(own.length, 1);
|
|
896
|
+
own[0].command = command;
|
|
897
|
+
}
|
|
898
|
+
file.hooks.Stop.push({ hooks: [{ type: 'command', command: '/usr/bin/node /opt/other/bin/other codex-hook stop', timeout: 5 }] });
|
|
899
|
+
const before = JSON.stringify(file, null, 2) + '\n';
|
|
900
|
+
writeFileSync(hooksPath, before);
|
|
901
|
+
|
|
902
|
+
await run([]);
|
|
903
|
+
assert.equal(readFileSync(hooksPath, 'utf8'), before);
|
|
904
|
+
const autoHandoff = installCodexAutoHandoffHook(join(home.dir, '.codex'));
|
|
905
|
+
assert.equal(autoHandoff.command, registered.PreCompact);
|
|
906
|
+
assert.equal(readFileSync(hooksPath, 'utf8'), before);
|
|
907
|
+
|
|
908
|
+
// 登録済みの node が今の node でない時は、今までどおり書き直す。
|
|
909
|
+
const moved = JSON.parse(before);
|
|
910
|
+
moved.hooks.Stop[0].hooks[0].command = buildCodexStopHookCommand({ nodePath: join(home.dir, 'gone', 'node') });
|
|
911
|
+
writeFileSync(hooksPath, JSON.stringify(moved, null, 2) + '\n');
|
|
912
|
+
await run([]);
|
|
913
|
+
const after = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
914
|
+
assert.equal(after.hooks.Stop[0].hooks[0].command, buildCodexStopHookCommand());
|
|
915
|
+
assert.equal(after.hooks.UserPromptSubmit[0].hooks[0].command, registered.UserPromptSubmit);
|
|
916
|
+
assert.equal(after.hooks.PostToolUse[0].hooks[0].command, registered.PostToolUse);
|
|
917
|
+
assert.equal(after.hooks.PreCompact.at(-1).hooks[0].command, registered.PreCompact);
|
|
918
|
+
} finally {
|
|
919
|
+
unsilence();
|
|
920
|
+
home.restore();
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test('install は、入れる側の PATH が変わっても Codex hook の命令を変えない', { skip: process.platform === 'win32' }, async () => {
|
|
925
|
+
const home = makeTempHome();
|
|
926
|
+
if (home.resolved !== home.dir) {
|
|
927
|
+
home.restore();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const originalPath = process.env.PATH;
|
|
931
|
+
const unsilence = silence();
|
|
932
|
+
try {
|
|
933
|
+
const hooksPath = join(home.dir, '.codex', 'hooks.json');
|
|
934
|
+
const nodePath = alternateNodeSpelling(home.dir);
|
|
935
|
+
// node の symlink がある場所を PATH の先頭に置いて入れ、次は PATH に node が無い状態で入れる。
|
|
936
|
+
process.env.PATH = join(home.dir, 'altbin');
|
|
937
|
+
await run([]);
|
|
938
|
+
const first = readFileSync(hooksPath, 'utf8');
|
|
939
|
+
assert.equal(JSON.parse(first).hooks.Stop[0].hooks[0].command, buildCodexStopHookCommand({ nodePath }));
|
|
940
|
+
process.env.PATH = '';
|
|
941
|
+
assert.notEqual(buildCodexStopHookCommand(), buildCodexStopHookCommand({ nodePath }));
|
|
942
|
+
await run([]);
|
|
943
|
+
assert.equal(readFileSync(hooksPath, 'utf8'), first);
|
|
944
|
+
} finally {
|
|
945
|
+
if (originalPath === undefined) delete process.env.PATH;
|
|
946
|
+
else process.env.PATH = originalPath;
|
|
947
|
+
unsilence();
|
|
948
|
+
home.restore();
|
|
949
|
+
}
|
|
950
|
+
});
|