throughline 0.16.16 → 0.16.18

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,49 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.18] — 2026-10-11
14
+
15
+ ### 変更
16
+
17
+ - Codexの`PreCompact(auto)`のhookが`handoff_hook_identity_invalid`で失敗した時、失敗ログ(`~/.throughline/logs/hook-failures.log`)の行へ、渡された合図の形(`identity`)を残す。0.16.14で内部のID(`auto-compact-N`)を失敗から外したが、0.16.16のmacOSの実機で同じ失敗が2回出た(2026-10-10 16:55:08と16:55:15、引き継ぎ先のタスクを作った直後)。内部の圧縮の記録は0行で、0.16.14が想定した形ではなかった。失敗ログには文面と位置しか無く、どの値が不正だったかを決められなかった。
18
+ - 残すのは、会話のID・ターンのID・agentのID・agentの種類・モデル名・`trigger`と、`transcript_path`・`cwd`が絶対pathかどうか。pathの中身と、記号の混じる値は残さない(長さだけ)。会話の本文は入らない。
19
+ - 失敗の扱い(圧縮を止める・失敗として数える)は変えていない。原因が決まるまで、この失敗は今までどおり記録される。
20
+
21
+ ### 対応の範囲
22
+
23
+ | | macOS | Linux | Windows |
24
+ |---|---|---|---|
25
+ | Codex(Desktop) | 足した。単体試験と、切り離した置き場での本物のhookの命令(不正な合図を渡して、失敗ログの行に`identity`が出る事)。**本物のCodex Desktopがこの失敗を起こす場面は、狙って起こせないので未確認。次に起きた行で原因を決める** | 足した。単体試験と、切り離した置き場での本物のhookの命令。実機(同梱0.162.0-alpha.2)では、この失敗が起きていない | 足した。単体試験と、切り離した置き場での本物のhookの命令。実機では、この失敗の記録が無い |
26
+ | Codex(CLI・席) | 自動継続の対象外(Desktop以外は、この検査の前後で何もしない)。対象外の呼び出しがこの検査で失敗した時は、同じ形が残る | 同じ。BellTeamのコンテナとmain-serverは自動継続が無効で、この処理へ入らない | 同じ |
27
+ | Claude Code | 変更なし。この失敗はCodexの`PreCompact`だけが出す。失敗ログの部品は共通だが、`identity`を付けるのはCodexのこの失敗だけ | 同じ | 同じ |
28
+ | Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
29
+ | Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
30
+
31
+
32
+ ## [0.16.17] — 2026-10-10
33
+
34
+ ### 修正
35
+
36
+ - Windowsで、Claude Codeの自動継続の後、Windows Terminalの窓が約2秒おきに開いては消えた。後継をClaude Desktopへ移す処理は、後継が手すきになるまで`claude agents --json --all`を繰り返し呼ぶ。この処理はconsoleを持たない切り離したprocessで動き、子の`pwsh.exe`を窓を隠さずに起こしていたので、呼ぶたびに新しいconsole(既定のターミナルの窓)が出ていた。後継が手すきにならない間、最長2分、ターンが終わるたびに続いた。
37
+ - 子processを起こす共通の部品(`src/os/portable-spawn-sync.mjs`)が、窓を隠す指定(`windowsHide`)を既定で付ける。`claude agents`・`claude stop`・後継の起動・L1の要約(`claude -p`・`codex exec`)・更新の処理が対象。
38
+ - URLを開く`cmd.exe /c start`と、手動の引き継ぎのapp-serverの起動にも付けた。開く先のアプリの窓は隠れない。
39
+ - 端末が要る`claude --desktop --resume`は、今までどおり最小化した新しいconsoleの中で呼ぶ(1回だけ)。
40
+ - 手すきを待つ間隔を1秒から3秒へ広げた。
41
+
42
+ ### 対応の範囲
43
+
44
+ | | macOS | Linux | Windows |
45
+ |---|---|---|---|
46
+ | Claude Code | 窓の指定は効かないOS。手すきを待つ間隔だけ変わる(3秒)。単体試験とCI | 窓の指定は効かないOS。単体試験とCI。Desktopへ移す処理はLinuxでは動かない | 直した。実機の画面のあるsessionで、consoleを持たない切り離したprocessから`claude agents`を呼び、現れた窓を30msおきに数えた。0.16.16は3回の呼び出しでWindows Terminalの窓が3回、直した版は5回の呼び出しで0回。呼び出しの結果(一覧)は同じに読めた |
47
+ | Codex | 同じ(効かないOS) | 同じ | 直した。URLを開く所は、実機で窓を隠した`cmd.exe`からアプリが開く事を確かめた(電卓のURLで代用)。自動継続のworkerが起こすL1の要約(`codex exec`)は同じ部品を通るが、個別には確かめていない |
48
+ | Grok | 同じ(効かないOS) | 同じ | 直した。Stopのhookが起こすL1の要約が同じ部品を通る。個別には確かめていない |
49
+ | Cursor | 同じ(効かないOS) | 同じ | 直した。Grokと同じ |
50
+
51
+ ### 確認していない範囲
52
+
53
+ - 本物の引き継ぎの後に窓が出ない事(公開して端末へ入れた後に確かめる)。
54
+ - 後継が手すきにならない理由(裏で動き続ける処理を持つ会話など)。その間、後継はClaude Desktopへ移らない。
55
+
13
56
  ## [0.16.16] — 2026-10-10
14
57
 
15
58
  ### 追加
@@ -2246,7 +2289,9 @@ two attempts, instrument first instead of patching again.
2246
2289
 
2247
2290
  ---
2248
2291
 
2249
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.16...HEAD
2292
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.18...HEAD
2293
+ [0.16.18]: https://github.com/kitepon/Throughline/compare/v0.16.17...v0.16.18
2294
+ [0.16.17]: https://github.com/kitepon/Throughline/compare/v0.16.16...v0.16.17
2250
2295
  [0.16.16]: https://github.com/kitepon/Throughline/compare/v0.16.15...v0.16.16
2251
2296
  [0.16.15]: https://github.com/kitepon/Throughline/compare/v0.16.14...v0.16.15
2252
2297
  [0.16.14]: https://github.com/kitepon/Throughline/compare/v0.16.13...v0.16.14
@@ -38,3 +38,18 @@ Codex は、利用者のターンとは別に、自分で作る内部の文脈
38
38
  - 内部の文脈からの圧縮で、失敗の記録が増えなくなる。圧縮を止める動きは変わらない。
39
39
  - 内部の文脈からの圧縮が何度も起きるタスクでも、圧縮は通らない。利用者のターンの `PreCompact` が来るまで、文脈はそのまま残る。
40
40
  - Codex が内部の ID の形を変えると、また失敗として記録される。その時は記録の message で気づける。
41
+
42
+ ## 追記(2026-10-11)
43
+
44
+ 0.16.16 の macOS の実機で、同じ `handoff_hook_identity_invalid` が2回出た(2026-10-10 16:55:08 と 16:55:15)。どちらも、
45
+ 引き継ぎ先のタスクを作った直後(作成の 1.4 秒後と、最初のターンの開始の 1.5 秒後)。`internal-compactions.jsonl` は
46
+ 0行だった。この ADR が想定した形(ターンの ID が `auto-compact-N`)の呼び出しではなかった事になる。
47
+
48
+ - Context に書いた「内部の文脈から `PreCompact(auto)` を呼ぶ」は、Codex の source を読んだ推測で、実機では確かめていない。
49
+ `next_internal_sub_id` の ID は、`thread/inject_items` の文脈(`new_inject_items_context`)にも使われる。
50
+ - hook へ渡る `transcript_path` は null になり得る(`codex-rs/hooks/src/schema.rs` の `NullableString`)。ターンの中の
51
+ 圧縮は、ターンの ID(UUID)をそのまま渡す(`with_model` は `sub_id` を引き継ぐ)。どの値が不正だったかは、失敗ログに
52
+ 文面と位置しか無く、決められなかった。
53
+ - 0.16.18 で、この失敗の行へ合図の形(`identity`。ID・種類・絶対 path かどうか)を残す。次に起きた行を読んで、
54
+ 扱いを決める。この ADR の Decision 1〜4 は、それまで変えない。
55
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.16",
3
+ "version": "0.16.18",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -778,7 +778,8 @@ export async function runClaudeDesktopOpen(sourceSessionId, {
778
778
  spawn = spawnPortableSync,
779
779
  pty = runWithPty,
780
780
  idleTimeoutMs = 120_000,
781
- pollMs = 1_000,
781
+ // 一覧を読むたびに `claude agents` を起こす(Windows では pwsh 経由)。手すきは急いで知る必要が無いので、間を空ける。
782
+ pollMs = 3_000,
782
783
  } = {}) {
783
784
  let record = readClaudeAutoHandoff(sourceSessionId, dir);
784
785
  if (!record) throw new ClaudeHandoffError('handoff_not_found');
@@ -1014,7 +1014,7 @@ export async function run(argv = []) {
1014
1014
  await check('throughline コマンドが PATH で見つかるか', () => {
1015
1015
  try {
1016
1016
  const which = process.platform === 'win32' ? 'where throughline' : 'which throughline';
1017
- const result = execSync(which, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
1017
+ const result = execSync(which, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
1018
1018
  return result.split(/\r?\n/)[0];
1019
1019
  } catch {
1020
1020
  throw new Error('見つからない — npm install -g throughline を実行してください');
@@ -1649,6 +1649,7 @@ function startAppServerClient({ command, args, cwd, timeoutMs, requestTimeoutMs
1649
1649
  const child = spawn(command, args, {
1650
1650
  cwd,
1651
1651
  stdio: ['pipe', 'pipe', 'pipe'],
1652
+ windowsHide: true,
1652
1653
  });
1653
1654
  let stdoutBuffer = '';
1654
1655
  let stderr = '';
@@ -106,6 +106,26 @@ function openSuccessor(operation) {
106
106
  // Codex が内部の文脈へ付けるターンの ID(`codex-rs/core/src/session/mod.rs` の `next_internal_sub_id`)。
107
107
  const INTERNAL_COMPACTION_TURN_ID_PATTERN = /^auto-compact-\d+$/;
108
108
 
109
+ const SAFE_IDENTITY_TEXT_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
110
+ const identityText = value => typeof value !== 'string' ? (value === null ? 'null' : typeof value)
111
+ : SAFE_IDENTITY_TEXT_PATTERN.test(value) ? value : `text(${value.length})`;
112
+ const identityPath = value => typeof value !== 'string' ? (value === null ? 'null' : typeof value)
113
+ : isAbsolute(value) ? 'absolute' : `relative(${value.length})`;
114
+
115
+ /**
116
+ * 引き継ぎの起点にできなかった合図の形。失敗ログへ残して、どの値が不正だったかを後から読めるようにする
117
+ * (2026-10-10、macOS で `handoff_hook_identity_invalid` が5回出たが、ログに形が無く原因を決められなかった)。
118
+ * ID・モデル名・種類だけを残す。path は絶対かどうかだけ。記号の混じる値は長さだけ。会話の本文は入らない。
119
+ */
120
+ export function hookIdentityShape(payload = {}) {
121
+ return {
122
+ session_id: identityText(payload.session_id), turn_id: identityText(payload.turn_id),
123
+ transcript_path: identityPath(payload.transcript_path), cwd: identityPath(payload.cwd),
124
+ agent_id: identityText(payload.agent_id), agent_type: identityText(payload.agent_type),
125
+ model: identityText(payload.model), trigger: identityText(payload.trigger),
126
+ };
127
+ }
128
+
109
129
  /** ターンの外の自動圧縮を止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
110
130
  function recordInternalCompactionStop(entry) {
111
131
  mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
@@ -179,7 +199,9 @@ export async function requestCodexAutoHandoff({ payload, db = null,
179
199
  return { status: 'ok', inserted: false, internalCompaction: true, continue: false,
180
200
  stopReason: 'Throughlineが、ターンの外の自動圧縮を止めました。文脈が上限に近づいたターンで、新しいタスクへ引き継ぎます。' };
181
201
  }
182
- if (!located || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '')) throw error('handoff_hook_identity_invalid');
202
+ if (!located || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '')) {
203
+ throw Object.assign(error('handoff_hook_identity_invalid'), { hookIdentity: hookIdentityShape(payload) });
204
+ }
183
205
  // JS実装のrealpathは、WindowsのCodex Desktopが起動したhookの中で `EISDIR: lstat 'C:'` で落ちる。OSのrealpathを使う。
184
206
  const rolloutPath = realpathSync.native(payload.transcript_path);
185
207
  const state = readCodexHandoffState(rolloutPath, { threadId, turnId });
@@ -15,7 +15,7 @@ import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePrepa
15
15
  import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
16
16
  autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
17
17
  markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
18
- untrackedInheritedMemory, resolveAutoHandoffSuccessor } from './codex-auto-handoff.mjs';
18
+ untrackedInheritedMemory, resolveAutoHandoffSuccessor, hookIdentityShape } from './codex-auto-handoff.mjs';
19
19
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
20
20
 
21
21
  async function withDb(fn) {
@@ -364,6 +364,16 @@ test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外に
364
364
  await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, ...broken } }), /handoff_hook_identity_invalid/);
365
365
  }
366
366
  assert.equal(recorded.length, 1);
367
+
368
+ // 失敗には、どの値が不正だったかの形を付ける。path と記号の混じる値は、中身を残さない
369
+ const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path: null,
370
+ agent_type: 'reviewer', model: 'gpt-5.5' } }).catch(error => error);
371
+ assert.equal(shaped.code, 'handoff_hook_identity_invalid');
372
+ assert.deepEqual(shaped.hookIdentity, { session_id: thread, turn_id: 'text(14)', transcript_path: 'null', cwd: 'absolute',
373
+ agent_id: 'undefined', agent_type: 'reviewer', model: 'gpt-5.5', trigger: 'auto' });
374
+ assert.deepEqual(hookIdentityShape({ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative/rollout.jsonl', cwd: 7 }),
375
+ { session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative(22)', cwd: 'number',
376
+ agent_id: 'undefined', agent_type: 'undefined', model: 'undefined', trigger: 'undefined' });
367
377
  }));
368
378
 
369
379
  test('PreCompactの入口は、JS実装のrealpathが使えない環境(WindowsのCodex Desktopのhook)でも引き継ぎを作る', async () => withDb(async (db, home) => {
@@ -62,6 +62,13 @@ export function logHookFailure(code, error, { home, now = Date.now(), stderr = p
62
62
  if (typeof context?.transcript_path === 'string' && context.transcript_path) {
63
63
  entry.transcript_path = context.transcript_path;
64
64
  }
65
+ // 不正な合図の形(errorが `hookIdentity` を持つ時)。文面が同じ失敗でも、どの値が不正だったかを残す。
66
+ // 値は呼び出し側が ID・種類・「絶対 path かどうか」へ直してある。ここでは短い文字列だけを通す。
67
+ const identity = error?.hookIdentity;
68
+ if (identity && typeof identity === 'object') {
69
+ const kept = Object.entries(identity).filter(([, value]) => typeof value === 'string' && value.length <= 80);
70
+ if (kept.length > 0) entry.identity = Object.fromEntries(kept);
71
+ }
65
72
  // 外部 CLI の失敗は、message が終了 code だけになる。理由(reason)と CLI の stderr の末尾も残す
66
73
  // (CLI は先頭に起動情報を出し、失敗の理由は末尾に出す)。
67
74
  if (typeof error?.reason === 'string' && error.reason) entry.reason = error.reason;
@@ -50,6 +50,22 @@ test('logHookFailureは、外部CLIの失敗の理由とstderrの末尾を残す
50
50
  }
51
51
  });
52
52
 
53
+ test('logHookFailureは、不正な合図の形を残す', () => {
54
+ const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
55
+ try {
56
+ const shaped = new Error('handoff_hook_identity_invalid');
57
+ shaped.hookIdentity = { turn_id: 'auto-compact-', transcript_path: 'null', cwd: 'absolute', long: 'x'.repeat(81), count: 3 };
58
+ logHookFailure('HOOK_CODEX_FAILED', shaped, { home });
59
+ logHookFailure('HOOK_CODEX_FAILED', new Error('plain'), { home });
60
+ const [first, second] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
61
+ // 短い文字列だけを通す(長い値と文字列でない値は落とす)。
62
+ assert.deepEqual(first.identity, { turn_id: 'auto-compact-', transcript_path: 'null', cwd: 'absolute' });
63
+ assert.equal('identity' in second, false);
64
+ } finally {
65
+ rmSync(home, { recursive: true, force: true });
66
+ }
67
+ });
68
+
53
69
  test('logHookFailureは、どの会話の失敗かを残す', () => {
54
70
  const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
55
71
  try {
@@ -11,6 +11,7 @@ export function openUrlWithOsHandler(url, {
11
11
  spawnImpl = spawnSync,
12
12
  } = {}) {
13
13
  if (platform === 'darwin') return spawnImpl('open', [url], { encoding: 'utf8' });
14
- if (platform === 'win32') return spawnImpl('cmd.exe', ['/c', 'start', '', url], { encoding: 'utf8' });
14
+ // cmd.exe 自身の窓は隠す(console を持たない worker から呼ぶと、窓が一瞬出る)。start が開く先の窓は、これでは隠れない。
15
+ if (platform === 'win32') return spawnImpl('cmd.exe', ['/c', 'start', '', url], { encoding: 'utf8', windowsHide: true });
15
16
  return spawnImpl('xdg-open', [url], { encoding: 'utf8' });
16
17
  }
@@ -0,0 +1,14 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { openUrlWithOsHandler } from './open-url.mjs';
4
+
5
+ test('openUrlWithOsHandler: OS ごとの命令で開き、Windows では cmd.exe の窓を隠す', () => {
6
+ const calls = [];
7
+ const spawnImpl = (command, args, options) => { calls.push({ command, args, options }); return { status: 0 }; };
8
+ openUrlWithOsHandler('codex://threads/abc', { platform: 'win32', spawnImpl });
9
+ openUrlWithOsHandler('codex://threads/abc', { platform: 'darwin', spawnImpl });
10
+ openUrlWithOsHandler('codex://threads/abc', { platform: 'linux', spawnImpl });
11
+ assert.deepEqual(calls[0], { command: 'cmd.exe', args: ['/c', 'start', '', 'codex://threads/abc'], options: { encoding: 'utf8', windowsHide: true } });
12
+ assert.deepEqual([calls[1].command, calls[1].args], ['open', ['codex://threads/abc']]);
13
+ assert.deepEqual([calls[2].command, calls[2].args], ['xdg-open', ['codex://threads/abc']]);
14
+ });
@@ -28,11 +28,15 @@ function resolveWindowsCommand(command, env) {
28
28
  return command;
29
29
  }
30
30
 
31
- function portableInvocation(command, args, options = {}) {
31
+ export function portableInvocation(command, args, options = {}) {
32
32
  const platform = options.platform ?? process.platform;
33
33
  const spawnOptions = { ...options };
34
34
  delete spawnOptions.platform;
35
35
  spawnOptions.shell = false;
36
+ // Windows では、console を持たない process(切り離した worker など)が console の子を起こすと、新しい console が作られ、
37
+ // 既定のターミナル(Windows Terminal)の窓が一瞬出る。出力は pipe で受けるので、窓は要らない。
38
+ // 窓が要る呼び出し(端末が要る命令)は os/pty-run.mjs が別に持つ。ほかの OS では無視される。
39
+ spawnOptions.windowsHide ??= true;
36
40
 
37
41
  if (platform !== 'win32') return [command, args, spawnOptions];
38
42
 
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { spawnPortable, spawnPortableSync } from './portable-spawn-sync.mjs';
6
+ import { portableInvocation, spawnPortable, spawnPortableSync } from './portable-spawn-sync.mjs';
7
7
  import { createInterface } from 'node:readline';
8
8
  import { once } from 'node:events';
9
9
 
@@ -88,3 +88,24 @@ exit $LASTEXITCODE
88
88
  rmSync(dir, { recursive: true, force: true });
89
89
  }
90
90
  });
91
+
92
+ test('portableInvocation: 窓を隠す指定を既定で付ける。呼び出し側が false を渡した時だけ外す', () => {
93
+ for (const platform of ['win32', 'linux', 'darwin']) {
94
+ const [, , hidden] = portableInvocation('claude', ['agents', '--json', '--all'], { platform, env: { PATH: '' }, encoding: 'utf8' });
95
+ assert.equal(hidden.windowsHide, true, platform);
96
+ assert.equal(hidden.shell, false); assert.equal(hidden.encoding, 'utf8'); assert.equal('platform' in hidden, false);
97
+ const [, , shown] = portableInvocation('claude', ['agents'], { platform, env: { PATH: '' }, windowsHide: false });
98
+ assert.equal(shown.windowsHide, false, platform);
99
+ }
100
+ });
101
+
102
+ test('portableInvocation: Windows の .ps1 shim は pwsh.exe 経由で、窓を隠して起こす', () => {
103
+ const dir = mkdtempSync(join(tmpdir(), 'tl-portable-hide-'));
104
+ try {
105
+ writeFileSync(join(dir, 'claude.ps1'), 'exit 0\n');
106
+ const [command, args, options] = portableInvocation('claude', ['stop', 'abcd1234'], { platform: 'win32', env: { PATH: dir } });
107
+ assert.equal(command, 'pwsh.exe');
108
+ assert.ok(args.at(-1).includes(join(dir, 'claude.ps1')) && args.at(-1).includes("'stop' 'abcd1234'"));
109
+ assert.equal(options.windowsHide, true);
110
+ } finally { rmSync(dir, { recursive: true, force: true }); }
111
+ });