throughline 0.16.24 → 0.16.25

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,32 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.25] — 2026-10-12
14
+
15
+ ### 修正
16
+
17
+ - **macOSのCodex Desktopで、引き継ぎのたびに`handoff_hook_identity_invalid`の失敗が記録されていた**([ADR 0054](docs/adr/0054-codex-compaction-of-thread-without-rollout-is-left-alone.md))。引き継ぎそのものは成功していた。0.16.18で失敗の行へ残すようにした合図の形を、2026-10-11の2回(0.16.23)で読めた。
18
+ - 合図は、引き継ぎ元でも引き継ぎ先でもない会話から来ていた。記録の場所(`transcript_path`)がnullで、会話のIDとターンのIDは正しい形。Codex Desktopのログでは、Desktop自身が立てた、画面に出ない会話(MCPが起動し、ターンが始まって終わる。画面の側は「unknown conversation」として受ける)。旧タスクのターンが止まった直後と、後継の画面を開いた直後に1つずつ出来て、最初のターンで自動圧縮を始めていた。
19
+ - Throughlineは、これを失敗として記録し、その会話のターンを止めていた。
20
+ - 直した後は、`PreCompact(auto)`の合図に記録の場所が無い時、引き継ぎの対象にしない。失敗にせず、圧縮を止める指示も返さない。通した事を`~/.throughline/codex-auto-handoff/unrecorded-compactions.jsonl`へ1行残す(時刻・会話のID・ターンのID・モデル名)。
21
+ - 記録の場所が相対pathの時と、記録を持つ会話の扱いは変えていない。
22
+ - 0.16.14([ADR 0047](docs/adr/0047-codex-internal-compaction-is-stopped-without-error.md))が想定した形(ターンのIDが`auto-compact-N`)は、実機ではまだ来ていない。
23
+
24
+ ### 対応の範囲
25
+
26
+ | | macOS | Linux | Windows |
27
+ |---|---|---|---|
28
+ | Codex | 直した。起きた端末。単体試験と、実機で本物のhookの命令へ起きた形の合図を渡した(入っていた0.16.24は失敗を記録して`continue: false`。この版は何も出力せず、失敗の記録は増えない)。本物のCodex Desktopの引き継ぎでは、まだ通していない | 直した。同じ確認。実機でこの失敗が起きた事は無い | 直した。同じ確認。実機でこの失敗が起きた事は無い |
29
+ | Claude Code | 変更なし(この失敗はCodexの`PreCompact`だけが出す。Claude Codeの会話は必ず記録を持つ) | 同じ | 同じ |
30
+ | Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
31
+ | Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
32
+
33
+ ### 確かめていない事
34
+
35
+ - 本物のCodex Desktopが、記録なしの会話から合図を出す場面での、この版の動き。狙って起こせない。次のmacOSの引き継ぎで、失敗ログが増えず、`unrecorded-compactions.jsonl`に行が出る事で確かめる。
36
+ - Codex Desktopが、その会話を何に使っているか。Throughlineが止めていた間に、Desktopの側で欠けていた物があったかも分かっていない。
37
+ - 2026-10-10の5回(0.16.12と0.16.16)は、失敗の行に形が残っていない。同じ検査・同じ場面だが、同じ形だったとは確かめられない。
38
+
13
39
  ## [0.16.24] — 2026-10-11
14
40
 
15
41
  ### 修正
@@ -2435,7 +2461,8 @@ two attempts, instrument first instead of patching again.
2435
2461
 
2436
2462
  ---
2437
2463
 
2438
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.24...HEAD
2464
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.25...HEAD
2465
+ [0.16.25]: https://github.com/kitepon/Throughline/compare/v0.16.24...v0.16.25
2439
2466
  [0.16.24]: https://github.com/kitepon/Throughline/compare/v0.16.23...v0.16.24
2440
2467
  [0.16.23]: https://github.com/kitepon/Throughline/compare/v0.16.22...v0.16.23
2441
2468
  [0.16.22]: https://github.com/kitepon/Throughline/compare/v0.16.21...v0.16.22
@@ -53,3 +53,10 @@ Codex は、利用者のターンとは別に、自分で作る内部の文脈
53
53
  - 0.16.18 で、この失敗の行へ合図の形(`identity`。ID・種類・絶対 path かどうか)を残す。次に起きた行を読んで、
54
54
  扱いを決める。この ADR の Decision 1〜4 は、それまで変えない。
55
55
 
56
+ ## 追記(2026-10-12)
57
+
58
+ 0.16.23 の macOS の実機で、形の付いた失敗が2回出た(2026-10-11 16:22:00 と 16:23:01)。不正だったのは `transcript_path`
59
+ (null)で、ターンの ID は UUID だった。合図の会話は、引き継ぎ元でも引き継ぎ先でもない、Codex Desktop が自分で立てた
60
+ 記録なしの会話だった。扱いは [ADR 0054](0054-codex-compaction-of-thread-without-rollout-is-left-alone.md) で決めた。
61
+ この ADR の Decision 1〜4(記録を持つ会話の、ターンの ID が `auto-compact-N` の合図)は変えていない。実機でその形が
62
+ 来た事は、まだ無い(macOS の実機に `internal-compactions.jsonl` は出来ていない)。
@@ -47,3 +47,7 @@ Claude Code の自動継続(ADR 0033)は、`PreCompact(auto)` で圧縮を
47
47
  - 旧い会話は、記録がある間、文を受ければ文で答える。道具は断る(変えていない)。旧い会話あての連絡を今の続きへ渡すのは、
48
48
  連絡を運ぶ側の仕事で、5 の口で今の続きを引ける。
49
49
  - 記憶が入らなかった後継へ、後から記憶を入れる処理は無い。`throughline recall --session <旧い会話の id>` で引ける。
50
+ - ほかの製品が、次の2つの形に頼っている(通話MCP `grokbot-bridge` 0.3.6 の `UserPromptSubmit` の hook。後継へ、前任が持っていた
51
+ 通話の引き取りを知らせる)。変える時は、先にその製品の担当へ知らせる。
52
+ - 後継の最初の指示の頭: `Throughline自動継続 ` に続けて、36字の引き継ぎ ID。
53
+ - `auto-handoff status --host claude --operation <引き継ぎID> --json` の `handoffs[0].source_session_id`。
@@ -0,0 +1,62 @@
1
+ # ADR 0054: 記録(rollout)を持たない Codex の会話の自動圧縮には、手を出さない
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-12
5
+
6
+ ## Context
7
+
8
+ Codex の自動継続(ADR 0034)は、`PreCompact(auto)` の hook で圧縮を止めて、新しいタスクへ引き継ぐ。hook は、合図の
9
+ 会話の ID・記録の場所(`transcript_path`)・ターンの ID を検査し、合わなければ `handoff_hook_identity_invalid` で失敗にして、
10
+ 圧縮を止める指示(`continue: false`)を返していた。
11
+
12
+ macOS の実機で、この失敗が7回出た(2026-10-10 に5回、10-11 に2回)。どれも Codex の引き継ぎの直後で、引き継ぎそのものは
13
+ 成功している。0.16.14(ADR 0047)は「ターンの ID が `auto-compact-N`」の形を想定して直したが、外れていた。0.16.18 で
14
+ 失敗の行へ合図の形を残し、10-11 の2回で形が分かった。
15
+
16
+ | | 1回目 | 2回目 |
17
+ |---|---|---|
18
+ | 失敗の時刻(JST) | 16:22:00.396 | 16:23:01.422 |
19
+ | 会話の ID | `01a129d6-fe64…`(作られたのは 16:21:58.116) | `01a129d7-e996…`(16:22:58.326) |
20
+ | ターンの ID | UUID(16:21:58.225) | UUID(16:22:58.611) |
21
+ | `transcript_path` | null | null |
22
+ | `cwd`・モデル・`trigger` | 絶対 path・`gpt-6.1-sol`・`auto` | 同じ |
23
+ | agent の ID・種類 | 無し | 無し |
24
+
25
+ 引き継ぎは 16:21:57.996 に予約(旧タスク `01a124ce-f984…`)、後継 `01a129d7-de33…` が 16:22:55.411 に出来て、
26
+ 16:23:06 に済んだ。失敗した合図の会話は、旧タスクでも後継でもない。
27
+
28
+ Codex Desktop のログ(アプリの行だけを読んだ)では、この2つの会話は Desktop 自身の App Server の中にあり、MCP が
29
+ 起動し、`turn/started` と `turn/completed` が出ている。画面の側は「unknown conversation」として受けている。1つ目は
30
+ 旧タスクのターンが止まって通知を出した直後、2つ目は後継の画面を開いた直後に出来ている。Throughline の worker は、
31
+ 後継のほかに会話を作らない(`thread/start` は1か所)。要約の `codex exec --ephemeral` は別のモデル(既定
32
+ `gpt-5.6-luna`)で動く。
33
+
34
+ つまり、Codex Desktop が自分で立てた、画面に出ない・記録を持たない会話が、最初のターンで自動圧縮を始め、その合図が
35
+ Throughline の hook へ来ていた。Throughline は失敗として記録し、その会話のターンを止めていた(2回とも、hook の失敗の
36
+ 0.3〜0.5 秒後に `turn/completed`)。Desktop がその会話を何に使っているかは、分かっていない。
37
+
38
+ Throughline は、会話の記録(rollout)から発言を取り込む。記録を持たない会話は取り込んでいないので、引き継ぐ記憶が無く、
39
+ 後継も作れない。
40
+
41
+ ## Decision
42
+
43
+ 1. `PreCompact(auto)` の合図の `transcript_path` が null か、項目が無い時は、引き継ぎの対象にしない。失敗にせず、
44
+ `continue: false` も返さない(hook は何も出力せず、終了 code 0)。圧縮は Codex がそのまま行う。
45
+ 2. 通した事を `~/.throughline/codex-auto-handoff/unrecorded-compactions.jsonl` に1行残す(時刻・会話の ID・ターンの ID・
46
+ モデル名。記号の混じる値は長さだけ)。会話の本文と path は書かない。
47
+ 3. `transcript_path` が文字列で、絶対 path でない時は、今までどおり失敗にする(記録を持つはずの会話で、場所だけが不正)。
48
+ 4. 記録を持つ会話の扱い(ADR 0034・0047)は変えない。
49
+
50
+ ## Consequences
51
+
52
+ - macOS の Codex Desktop の引き継ぎのたびに出ていた失敗の記録が、出なくなる見込み。次の実機の引き継ぎで、失敗ログが
53
+ 増えず、`unrecorded-compactions.jsonl` に行が出る事で確かめる。
54
+ - Desktop が立てる画面に出ない会話は、Throughline に止められなくなり、Codex の圧縮が通る。自動継続を入れる前
55
+ (0.11.0 より前)と同じ動きに戻る。
56
+ - 「host の自動圧縮を通さない」という決め(ADR 0034)は、Throughline が記録している会話についての物として読む。
57
+ 記録を持たない会話は、その外に置く。
58
+ - 利用者が自分で起こす記録なしの会話(`codex exec --ephemeral` など)も、同じ扱いになる。今までは失敗として止めていた。
59
+ どちらでも引き継ぎは出来ない。
60
+ - Codex が、記録を持つ会話の最初のターンで、記録を作る前に `transcript_path` を null で渡す事があれば、その圧縮も通る。
61
+ 実機では見ていない。起きた時は `unrecorded-compactions.jsonl` の会話の ID が、後で記録を持つ会話と一致する事で気づける。
62
+ - 10-10 の5回は、失敗の行に形が残っていない。同じ検査・同じ場面(引き継ぎの直後)だが、同じ形だったとは確かめられない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.24",
3
+ "version": "0.16.25",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -689,7 +689,10 @@ export function completeClaudeTurnWithoutHandoff({ sessionId, dir = claudeAutoHa
689
689
  return true;
690
690
  }
691
691
 
692
- /** 後継へ送る継続の指示。引き継ぎIDを含め、後継の最初の指示がこの配送のものかを照合できるようにする。 */
692
+ /**
693
+ * 後継へ送る継続の指示。引き継ぎIDを含め、後継の最初の指示がこの配送のものかを照合できるようにする。
694
+ * 文の頭の形(`Throughline自動継続 ` + 36字の引き継ぎID)は、ほかの製品の hook も読んでいる(ADR 0053)。変える時は先に知らせる。
695
+ */
693
696
  export function claudeContinuationInput(record) {
694
697
  // この文は後継の最初の指示(利用者の発言)として届く。止めた道具をやり直さずに「済んだ」と書いて
695
698
  // 先へ進む後継が出たので(Haiku 4.5、macOS で実測)、実行されていない道具から始めることを明記する。
@@ -132,6 +132,12 @@ function recordInternalCompactionStop(entry) {
132
132
  appendFileSync(join(stateRoot(), 'internal-compactions.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
133
133
  }
134
134
 
135
+ /** 記録(rollout)を持たない会話の自動圧縮を通した記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
136
+ function recordUnrecordedCompactionPass(entry) {
137
+ mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
138
+ appendFileSync(join(stateRoot(), 'unrecorded-compactions.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
139
+ }
140
+
135
141
  /** 引き継ぎ済みの旧タスクを止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
136
142
  function recordAutoHandoffRedirect(entry) {
137
143
  mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
@@ -186,10 +192,18 @@ function settleUndeliveredAutoHandoff(db, threadId, { rolloutPath, processes, re
186
192
  export async function requestCodexAutoHandoff({ payload, db = null,
187
193
  config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
188
194
  findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect,
189
- recordInternalCompaction = recordInternalCompactionStop,
195
+ recordInternalCompaction = recordInternalCompactionStop, recordUnrecordedCompaction = recordUnrecordedCompactionPass,
190
196
  processes = readRuntimeProcesses, readTarget = targetState, now = Date.now() } = {}) {
191
197
  if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
192
198
  const threadId = payload.session_id, turnId = payload.turn_id;
199
+ // 記録(rollout)を持たない会話からの合図。Codex Desktop は、ターンの終わりや会話を開いた時に、画面に出ない会話を
200
+ // 自分で立てる(2026-10-11、macOS。`transcript_path` が null、会話の ID は引き継ぎ元でも引き継ぎ先でもない)。
201
+ // Throughline はその会話を記録していないので、引き継ぐ記憶が無く、後継も作れない。失敗にせず、圧縮にも手を出さない (ADR 0054)。
202
+ if (payload.transcript_path === null || payload.transcript_path === undefined) {
203
+ recordUnrecordedCompaction({ at: new Date(now).toISOString(), thread_id: identityText(threadId), turn_id: identityText(turnId),
204
+ model: identityText(payload.model) });
205
+ return { status: 'skipped', unrecordedThread: true };
206
+ }
193
207
  const located = CODEX_NATIVE_ID_PATTERN.test(threadId ?? '') &&
194
208
  typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) && isAbsolute(payload.cwd ?? '');
195
209
  // Codex が自分で作った内部の文脈(ターンの ID が `auto-compact-N`)からの圧縮。利用者のターンではないので、
@@ -339,6 +339,34 @@ test('タスクの今の続きを引く。先までたどり、消えた後継
339
339
  void Z;
340
340
  }));
341
341
 
342
+ test('記録(rollout)を持たない会話からの圧縮の合図は、失敗にせず、圧縮にも手を出さない。引き継ぎは作らない', async () => withDb(async (db, home) => {
343
+ // 2026-10-11 16:22 JST、macOS の Codex Desktop。会話の ID は引き継ぎ元でも引き継ぎ先でもなく、`transcript_path` が null だった
344
+ const payload = { trigger: 'auto', cwd: home, session_id: '01a129d6-fe64-7010-9c79-19fe3b82c64d', turn_id: '01a129d6-fed1-7662-8286-280519f8d9bf',
345
+ transcript_path: null, model: 'gpt-6.1-sol' };
346
+ const config = { enabled: true, projects: [home], openHost: 'desktop' };
347
+ let launched = 0; const passed = [], stopped = [];
348
+ const args = { db, config, payload, launchWorker: async () => { launched++; }, recordUnrecordedCompaction: entry => passed.push(entry),
349
+ recordInternalCompaction: entry => stopped.push(entry), now: Date.parse('2026-10-11T07:22:00.396Z') };
350
+ const result = await requestCodexAutoHandoff(args);
351
+ assert.deepEqual(result, { status: 'skipped', unrecordedThread: true }, '圧縮を止める指示(continue: false)を返さない');
352
+ assert.equal(launched, 0);
353
+ assert.equal(db.prepare('SELECT COUNT(*) n FROM codex_handoffs').get().n, 0, '引き継ぎの行は作らない');
354
+ assert.deepEqual(passed, [{ at: '2026-10-11T07:22:00.396Z', thread_id: payload.session_id, turn_id: payload.turn_id, model: 'gpt-6.1-sol' }]);
355
+ assert.deepEqual(stopped, []);
356
+
357
+ // 項目そのものが無い時と、ターンの ID が内部の形の時も同じ。記号の混じる値は、記録へ中身を残さない
358
+ await requestCodexAutoHandoff({ ...args, payload: { trigger: 'auto', cwd: home, session_id: payload.session_id, turn_id: 'auto-compact-1', model: 'a model/with spaces' } });
359
+ assert.deepEqual(passed[1], { at: '2026-10-11T07:22:00.396Z', thread_id: payload.session_id, turn_id: 'auto-compact-1', model: 'text(19)' });
360
+ assert.deepEqual(stopped, []);
361
+
362
+ // 記録の場所が相対の時は、今までどおり失敗(記録を持つはずの会話で、場所だけが不正)
363
+ await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, transcript_path: 'relative/rollout.jsonl' } }), /handoff_hook_identity_invalid/);
364
+ // 無効な project と手動の圧縮は、記録も残さない
365
+ assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
366
+ assert.equal((await requestCodexAutoHandoff({ ...args, payload: { ...payload, trigger: 'manual' } })).status, 'skipped');
367
+ assert.equal(passed.length, 2);
368
+ }));
369
+
342
370
  test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外にせず止めて記録する。引き継ぎは作らない', async () => withDb(async (db, home) => {
343
371
  const thread = '11111111-1111-1111-1111-111111111111';
344
372
  const file = join(home, 'rollout.jsonl'); writeFileSync(file, '');
@@ -360,16 +388,16 @@ test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外に
360
388
 
361
389
  // 形の決まっていない ID と、場所の分からない呼び出しは、今までどおり失敗として扱う
362
390
  for (const broken of [{ turn_id: 'something-else' }, { turn_id: undefined }, { turn_id: 'auto-compact-' }, { session_id: 'not-a-uuid' },
363
- { transcript_path: null }, { transcript_path: 'relative/rollout.jsonl' }]) {
391
+ { transcript_path: 'relative/rollout.jsonl' }]) {
364
392
  await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, ...broken } }), /handoff_hook_identity_invalid/);
365
393
  }
366
394
  assert.equal(recorded.length, 1);
367
395
 
368
396
  // 失敗には、どの値が不正だったかの形を付ける。path と記号の混じる値は、中身を残さない
369
- const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path: null,
397
+ const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path: 'relative/rollout.jsonl',
370
398
  agent_type: 'reviewer', model: 'gpt-5.5' } }).catch(error => error);
371
399
  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',
400
+ assert.deepEqual(shaped.hookIdentity, { session_id: thread, turn_id: 'text(14)', transcript_path: 'relative(22)', cwd: 'absolute',
373
401
  agent_id: 'undefined', agent_type: 'reviewer', model: 'gpt-5.5', trigger: 'auto' });
374
402
  assert.deepEqual(hookIdentityShape({ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative/rollout.jsonl', cwd: 7 }),
375
403
  { session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative(22)', cwd: 'number',