throughline 0.15.1 → 0.15.2

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,25 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.15.2] — 2026-10-06
14
+
15
+ ### 修正
16
+
17
+ - Claude Desktopで「フォルダなし」で始めて、後から作業フォルダへ移した会話では、自動継続(`auto-handoff enable --host claude`)を有効にしていても引き継ぎが始まらない不具合を直す。Desktopは会話を移した後も、hookへ渡す`CLAUDE_PROJECT_DIR`を移る前の場所のままにする。Throughlineはその値で対象のprojectかどうかを決めていたので、自動圧縮の前のhookが`auto_handoff_disabled`で見送り、圧縮がそのまま走っていた。
18
+ - 会話が移った先は、Claude Codeがtranscriptへ書く`relocated`の行(`relocatedCwd`)から読む。移っている会話では、自動継続の有効判定と印(baton)のprojectを、移った先にする。
19
+ - 同じ会話のターン終了では、完了ターンの控えを移った先のprojectへ書き、`sessions.project_path`を移った先へ付け替える。これまでは移る前の場所のまま残り、移った先のprojectの過去の会話(`handoff-context`、`throughline detail`)にも`observer-read`にも、その会話が出なかった。
20
+ - 移っていない会話の扱いは変わらない。`relocated`の行が無ければ、これまでどおり`CLAUDE_PROJECT_DIR`、無ければhookの`cwd`を使う。
21
+
22
+ ### 確認した範囲
23
+
24
+ - macOS(Claude Desktop同梱の2.1.286が書いた本物のtranscriptの写し、移った後に自動圧縮が2回走った会話)。切り離したHOMEでhookを手で呼び、0.14.2は`pre-compact`が終了コード0・`auto_handoff_disabled`、`sessions.project_path`と控えは移る前の場所。この版は`pre-compact`が終了コード2(圧縮を止める)・`requested`、`sessions.project_path`と控えは移った先、`observer-read`は移った先のprojectで`snapshot`を返す。
25
+ - macOS、端末から始めた移っていない会話(Claude Code 2.1.289、Haiku 4.5)。この版で4回連続で引き継ぎ、14個を番号順に読み切って完了。自動圧縮は0回。
26
+
27
+ ### 確認していない範囲
28
+
29
+ - Claude Desktopの画面で、この版が通しで引き継ぐこと(旧い会話を止める所と、後継を立てる所)。Desktopの画面からの送信が要るため、hookを手で呼ぶ確認までにとどまる。
30
+ - Windows・Linuxで移した会話。`relocated`の行の読み取りは、Windowsの書き方の場所を単体の試験で確かめただけ。
31
+
13
32
  ## [0.15.1] — 2026-10-05
14
33
 
15
34
  ### 修正
@@ -1771,7 +1790,8 @@ two attempts, instrument first instead of patching again.
1771
1790
 
1772
1791
  ---
1773
1792
 
1774
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.1...HEAD
1793
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.2...HEAD
1794
+ [0.15.2]: https://github.com/kitepon/Throughline/compare/v0.15.1...v0.15.2
1775
1795
  [0.15.1]: https://github.com/kitepon/Throughline/compare/v0.15.0...v0.15.1
1776
1796
  [0.15.0]: https://github.com/kitepon/Throughline/compare/v0.14.3...v0.15.0
1777
1797
  [0.14.3]: https://github.com/kitepon/Throughline/compare/v0.14.2...v0.14.3
@@ -31,6 +31,8 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
31
31
  1. **圧縮を止める**: `PreCompact` hook(`throughline pre-compact`、matcher 無し)が、`trigger: "auto"` で自動継続が
32
32
  有効な project の時だけ、`/tl` と同じ印(baton)と引き継ぎの記録を残し、exit code 2 で圧縮を止める。
33
33
  印の project は、会話を起動した場所(`CLAUDE_PROJECT_DIR`)を、その OS の書き方にそろえたもの(0.14.2)。
34
+ Claude Desktop で会話を別の project へ移している時は、transcript の `relocated` の行(`relocatedCwd`)が示す移った先を使う。
35
+ `CLAUDE_PROJECT_DIR` は移る前の場所のまま届くため(0.15.2)。
34
36
  hook の cwd は Bash の `cd` に追従するので使わない。後継は起動した場所で立ち、その cwd で印を探す。
35
37
  手動の `/compact`、無効な project、subagent の中の圧縮(payload に `agent_id`)、script から起動した会話
36
38
  (`CLAUDE_CODE_ENTRYPOINT` が `sdk-` で始まる)では止めない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -197,8 +197,10 @@ export function requestClaudeAutoHandoff({
197
197
  const path = recordPath(sessionId, dir);
198
198
  const existing = readClaudeAutoHandoff(sessionId, dir);
199
199
  // 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
200
+ // 会話が別の project へ移っている時(Claude Desktop)は、移った先で行う。
200
201
  const projectPath = nativeProjectPath(
201
- claudeHostAdapter.completionProjectPath({ cwd: payload.cwd ?? process.cwd(), env }), platform);
202
+ claudeHostAdapter.completionProjectPath({
203
+ cwd: payload.cwd ?? process.cwd(), env, transcriptPath: payload.transcript_path }), platform);
202
204
 
203
205
  if (payload.trigger !== 'auto') {
204
206
  // 人が /compact を選んだ。まだ止めていない記録は取り下げる。
@@ -63,6 +63,33 @@ function request(dir, overrides = {}) {
63
63
  return { result, db };
64
64
  }
65
65
 
66
+ test('PreCompact: 会話が別の project へ移っている時は、移った先で有効かどうかを決める(Claude Desktop)', () => withDir(dir => {
67
+ // Desktop は「フォルダなし」で始めた会話を後から project へ移す。CLAUDE_PROJECT_DIR は移る前のまま届く。
68
+ const scratch = '/Users/kite/Library/Application Support/Claude/scratch-workspaces/a/b/scratch-2026-10-05-ac3fa8';
69
+ const moved = '/Users/kite/tl-claude-probe';
70
+ const transcriptPath = join(dir, 'moved.jsonl');
71
+ writeFileSync(transcriptPath, jsonl([user('読んで', '2026-10-05T22:38:17.050Z'), assistant('読みました', '2026-10-05T22:39:18.926Z'),
72
+ { type: 'relocated', sessionId: SESSION, relocatedCwd: moved }]));
73
+ const onlyMoved = { enabled: true, projects: [moved] };
74
+
75
+ const { result, db } = request(dir, { config: onlyMoved, transcriptPath, env: { CLAUDE_PROJECT_DIR: scratch }, payload: { cwd: moved } });
76
+ assert.equal(result.status, 'requested');
77
+ assert.equal(result.block, true);
78
+ assert.equal(result.projectPath, moved);
79
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, moved);
80
+ assert.equal(db.prepare('SELECT session_id FROM handoff_batons WHERE project_path = ?').get(moved).session_id, SESSION);
81
+ }));
82
+
83
+ test('PreCompact: 移っていない会話は、これまでどおり起動した project で決める', () => withDir(dir => {
84
+ const transcriptPath = join(dir, 'plain.jsonl');
85
+ writeFileSync(transcriptPath, jsonl([user('読んで', '2026-10-05T22:38:17.050Z'), assistant('読みました', '2026-10-05T22:39:18.926Z')]));
86
+ const { result } = request(dir, { config: { enabled: true, projects: ['/work/app/sub'] }, transcriptPath,
87
+ env: { CLAUDE_PROJECT_DIR: '/work/app' }, payload: { cwd: '/work/app/sub' } });
88
+ assert.equal(result.status, 'skipped');
89
+ assert.equal(result.reason, 'auto_handoff_disabled');
90
+ assert.equal(result.projectPath, '/work/app');
91
+ }));
92
+
66
93
  test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して圧縮を止める', () => withDir(dir => {
67
94
  const { result, db } = request(dir);
68
95
  assert.equal(result.status, 'requested');
@@ -863,6 +863,57 @@ test('process-turn subprocess backfills all completed logical turns from a multi
863
863
  }
864
864
  });
865
865
 
866
+ test('process-turn subprocess files a relocated Claude Desktop conversation under the project it moved to', () => {
867
+ // Desktop は「フォルダなし」で始めた会話を後から project へ移す。hook の CLAUDE_PROJECT_DIR は移る前のまま届く。
868
+ const home = makeTempHome();
869
+ const scratch = makeTempProject();
870
+ const moved = makeTempProject();
871
+ const transcriptPath = join(moved, 'transcript.jsonl');
872
+ const sessionId = '04bf5ab3-4b29-4b35-9b85-02a48eec51a1';
873
+ const entries = [
874
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'first request' }] } },
875
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'first answer' }] } },
876
+ ];
877
+ const stop = (lastAssistantMessage) => runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
878
+ home,
879
+ cwd: moved,
880
+ env: { CLAUDE_PROJECT_DIR: scratch },
881
+ input: JSON.stringify({ session_id: sessionId, cwd: moved, transcript_path: transcriptPath, last_assistant_message: lastAssistantMessage }),
882
+ });
883
+ try {
884
+ // 移る前: SessionStart が移る前の場所で行を作り、最初の Stop も移る前の場所へ受領を書く。
885
+ const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
886
+ home, cwd: scratch, input: JSON.stringify({ session_id: sessionId, cwd: scratch, source: 'startup', transcript_path: transcriptPath }),
887
+ });
888
+ assert.equal(started.status, 0, started.stderr);
889
+ writeFileSync(transcriptPath, entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
890
+ assert.equal(stop('first answer').status, 0);
891
+ let db = openDb(home);
892
+ assert.equal(db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(sessionId).project_path, scratch);
893
+ db.close();
894
+ assert.equal(readCompletedTurnReceiptSnapshot({ projectPath: scratch, env: childEnv(home) }).receipts.length, 1);
895
+
896
+ // 移った後の Stop: sessions の project を付け替え、全ターンの受領を移った先へ出す。
897
+ appendFileSync(transcriptPath, [
898
+ { type: 'relocated', sessionId, relocatedCwd: moved },
899
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'second request' }] } },
900
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'second answer' }] } },
901
+ ].map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
902
+ const second = stop('second answer');
903
+ assert.equal(second.status, 0, second.stderr);
904
+ db = openDb(home);
905
+ assert.equal(db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(sessionId).project_path, moved);
906
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM bodies').get().n, 4);
907
+ db.close();
908
+ const receipts = readCompletedTurnReceiptSnapshot({ projectPath: moved, env: childEnv(home) });
909
+ assert.deepEqual(receipts.receipts.map((entry) => entry.sequence), [1, 2]);
910
+ } finally {
911
+ rmSync(scratch, { recursive: true, force: true });
912
+ rmSync(moved, { recursive: true, force: true });
913
+ rmSync(home, { recursive: true, force: true });
914
+ }
915
+ });
916
+
866
917
  test('process-turn subprocess captures the completed turn when the next user entry lands right after Stop', () => {
867
918
  const home = makeTempHome();
868
919
  const project = makeTempProject();
@@ -4,7 +4,8 @@
4
4
  * Claude は Throughline の基準 host。hook payload は snake_case のまま届き、
5
5
  * 引き継ぎ注入は UserPromptSubmit hook の stdout でモデルへ渡る。
6
6
  */
7
- import { isAbsolute } from 'node:path';
7
+ import { readFileSync } from 'node:fs';
8
+ import { isAbsolute, posix, win32 } from 'node:path';
8
9
  import { CLAUDE_HOST, hostOfSessionId } from './identity.mjs';
9
10
 
10
11
  // Claude Code の背景 task 通知は user 発言として transcript に入る。識別子・出力 path・
@@ -36,6 +37,40 @@ export function compactClaudePeerMessage(text) {
36
37
  return note < 0 ? text : text.slice(0, note);
37
38
  }
38
39
 
40
+ // Claude Desktop は「フォルダなし」で始めた会話を、後から実在の project へ移せる。移すと transcript に
41
+ // `{"type":"relocated","relocatedCwd":"<移った先>"}` の行が入り、transcript 自体も移った先の project の
42
+ // 置き場へ移る。hook へ渡る CLAUDE_PROJECT_DIR は、移る前の場所のまま残る(2.1.286 で実測)。
43
+ const RELOCATED_MARKER = '"relocated"';
44
+
45
+ /**
46
+ * 会話が最後に移った先。移っていない会話、transcript が読めない時は null。
47
+ * @param {string | null | undefined} transcriptPath
48
+ * @returns {string | null}
49
+ */
50
+ export function readClaudeRelocatedCwd(transcriptPath) {
51
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
52
+ let raw;
53
+ try { raw = readFileSync(transcriptPath, 'utf8'); }
54
+ catch { return null; }
55
+ // 道具の出力が同じ文字列を含むことがある。行を JSON として読み、行そのものの type で確かめる。
56
+ for (let end = raw.length; end > 0;) {
57
+ const marker = raw.lastIndexOf(RELOCATED_MARKER, end - 1);
58
+ if (marker < 0) return null;
59
+ const lineStart = raw.lastIndexOf('\n', marker) + 1;
60
+ const newline = raw.indexOf('\n', marker);
61
+ const line = raw.slice(lineStart, newline < 0 ? raw.length : newline);
62
+ try {
63
+ const entry = JSON.parse(line);
64
+ const moved = entry?.type === 'relocated' ? entry.relocatedCwd : null;
65
+ if (typeof moved === 'string' && (posix.isAbsolute(moved) || win32.isAbsolute(moved))) return moved;
66
+ } catch {
67
+ // 書きかけの行。前の行を探す。
68
+ }
69
+ end = lineStart;
70
+ }
71
+ return null;
72
+ }
73
+
39
74
  export const claudeHostAdapter = Object.freeze({
40
75
  host: CLAUDE_HOST,
41
76
  matchesSessionId: (sessionId) => hostOfSessionId(sessionId) === CLAUDE_HOST,
@@ -45,7 +80,10 @@ export const claudeHostAdapter = Object.freeze({
45
80
  // 完了受領はセッションを起動した project に書く。hook payload の cwd は Bash の cd に
46
81
  // 追従するため、作業中に下位ディレクトリへ移ると起動 project の feed から漏れる。
47
82
  // Claude Code は hook の環境変数 CLAUDE_PROJECT_DIR に起動 project を渡す。
48
- completionProjectPath({ cwd, env }) {
83
+ // 会話が別の project へ移っている時は、移った先を使う(CLAUDE_PROJECT_DIR は移る前のまま)。
84
+ completionProjectPath({ cwd, env, transcriptPath }) {
85
+ const relocated = readClaudeRelocatedCwd(transcriptPath);
86
+ if (relocated) return relocated;
49
87
  const projectDir = env?.CLAUDE_PROJECT_DIR;
50
88
  return typeof projectDir === 'string' && isAbsolute(projectDir) ? projectDir : cwd;
51
89
  },
@@ -1,6 +1,9 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { claudeHostAdapter } from './claude.mjs';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { claudeHostAdapter, readClaudeRelocatedCwd } from './claude.mjs';
4
7
  import { codexHostAdapter } from './codex.mjs';
5
8
  import { cursorHostAdapter } from './cursor.mjs';
6
9
  import { grokHostAdapter } from './grok.mjs';
@@ -18,3 +21,57 @@ test('Codex, Grok, and Cursor completion receipts keep the hook cwd', () => {
18
21
  assert.equal(adapter.completionProjectPath({ cwd: '/work/bot', env: { CLAUDE_PROJECT_DIR: '/elsewhere' } }), '/work/bot');
19
22
  }
20
23
  });
24
+
25
+ function withTranscript(lines, fn) {
26
+ const dir = mkdtempSync(join(tmpdir(), 'tl-claude-host-'));
27
+ const transcriptPath = join(dir, 'transcript.jsonl');
28
+ try {
29
+ writeFileSync(transcriptPath, lines.join('\n'), 'utf8');
30
+ return fn(transcriptPath);
31
+ } finally {
32
+ rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ }
35
+
36
+ // Claude Desktop 2.1.286 が書く行の形(Mac で実測)。順序は2通りある。
37
+ const relocatedLine = (cwd) => JSON.stringify({ type: 'relocated', sessionId: '04bf5ab3-4b29-4b35-9b85-02a48eec51a1', relocatedCwd: cwd });
38
+ const userLine = (text) => JSON.stringify({ type: 'user', message: { role: 'user', content: text } });
39
+
40
+ test('readClaudeRelocatedCwd: 移っていない会話と読めない transcript は null', () => {
41
+ assert.equal(readClaudeRelocatedCwd(null), null);
42
+ assert.equal(readClaudeRelocatedCwd(join(tmpdir(), 'tl-claude-host-missing', 'none.jsonl')), null);
43
+ withTranscript([userLine('hello'), ''], (path) => assert.equal(readClaudeRelocatedCwd(path), null));
44
+ });
45
+
46
+ test('readClaudeRelocatedCwd: 最後に移った先を返す', () => {
47
+ withTranscript([userLine('a'), relocatedLine('/Users/kite/first'), userLine('b'), relocatedLine('/Users/kite/tl-claude-probe'),
48
+ JSON.stringify({ type: 'agent-name', agentName: 'x' }), ''], (path) => {
49
+ assert.equal(readClaudeRelocatedCwd(path), '/Users/kite/tl-claude-probe');
50
+ });
51
+ withTranscript([relocatedLine('C:\\Users\\kite_\\proj')], (path) => {
52
+ assert.equal(readClaudeRelocatedCwd(path), 'C:\\Users\\kite_\\proj');
53
+ });
54
+ });
55
+
56
+ test('readClaudeRelocatedCwd: 道具の出力に同じ文字列があっても、行そのものの type だけを見る', () => {
57
+ const quoted = JSON.stringify({ type: 'user', message: { role: 'user', content: [
58
+ { type: 'tool_result', tool_use_id: 't1', content: relocatedLine('/quoted/in/output') }] } });
59
+ withTranscript([relocatedLine('/Users/kite/real'), quoted, '{"type":"relocated","relocatedCwd":"/partial'], (path) => {
60
+ assert.equal(readClaudeRelocatedCwd(path), '/Users/kite/real');
61
+ });
62
+ withTranscript([quoted, relocatedLine('relative/path'), JSON.stringify({ type: 'relocated' })], (path) => {
63
+ assert.equal(readClaudeRelocatedCwd(path), null);
64
+ });
65
+ });
66
+
67
+ test('Claude completion receipts follow the project the conversation moved to (Claude Desktop)', () => {
68
+ const scratch = '/Users/kite/Library/Application Support/Claude/scratch-workspaces/a/b/scratch-2026-10-05-ac3fa8';
69
+ withTranscript([userLine('a'), relocatedLine('/Users/kite/tl-claude-probe'), ''], (transcriptPath) => {
70
+ assert.equal(claudeHostAdapter.completionProjectPath({
71
+ cwd: '/Users/kite/tl-claude-probe/sub', env: { CLAUDE_PROJECT_DIR: scratch }, transcriptPath,
72
+ }), '/Users/kite/tl-claude-probe');
73
+ });
74
+ withTranscript([userLine('a'), ''], (transcriptPath) => {
75
+ assert.equal(claudeHostAdapter.completionProjectPath({ cwd: '/work/bot/sub', env: { CLAUDE_PROJECT_DIR: '/work/bot' }, transcriptPath }), '/work/bot');
76
+ });
77
+ });
@@ -49,6 +49,8 @@ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
49
49
  import { logHookFailure } from './hook-failure-log.mjs';
50
50
  import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
51
51
  import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
52
+ import { readClaudeRelocatedCwd } from './hosts/claude.mjs';
53
+ import { CLAUDE_HOST } from './hosts/identity.mjs';
52
54
  import { completeClaudeTurnWithoutHandoff } from './claude-auto-handoff.mjs';
53
55
 
54
56
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
@@ -316,6 +318,20 @@ async function processStop() {
316
318
  db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(now, target);
317
319
  }
318
320
 
321
+ // 完了受領と sessions の project は、会話を起動した project に合わせる。会話が別の project へ
322
+ // 移っている時(Claude Desktop)は、移った先へ付け替える。付け替えないと、移った先の project の
323
+ // 過去の会話にも Observer feed にも、この会話が出ない。
324
+ const hostAdapter = hostAdapterForSessionId(session_id);
325
+ const relocatedProjectPath = hostAdapter.host === CLAUDE_HOST ? readClaudeRelocatedCwd(transcript_path) : null;
326
+ const completionProjectPath = relocatedProjectPath ?? hostAdapter.completionProjectPath({
327
+ cwd: cwd ?? process.cwd(),
328
+ env: process.env,
329
+ });
330
+ if (relocatedProjectPath) {
331
+ db.prepare('UPDATE sessions SET project_path = ? WHERE session_id IN (?, ?) AND project_path <> ?')
332
+ .run(relocatedProjectPath, session_id, target, relocatedProjectPath);
333
+ }
334
+
319
335
  // L2 = transcript 全体を論理ターン群で走査し、未捕捉の完了ターンを一括回収する。
320
336
  // 従来の「最後の 1 ペアのみ保存」は Stop の空振り・不発が永久穴になった (docs/12 B-1)。
321
337
  // user / assistant は「1 往復 = 1 ターン」として同じ turn_number(= 代表 assistant
@@ -349,10 +365,6 @@ async function processStop() {
349
365
  // 過去のStopでDBだけ回収済みだったpairもreceipt storeの冪等性で穴埋めする。
350
366
  // receipt failure は Stop hook の failure として上位へ伝播させる。L1/L3/usage は
351
367
  // receipt 後の派生処理なので、そこで失敗しても completed pair を取り消さない。
352
- const completionProjectPath = hostAdapterForSessionId(session_id).completionProjectPath({
353
- cwd: cwd ?? process.cwd(),
354
- env: process.env,
355
- });
356
368
  for (const completedTurnNumber of backfill.turnNumbers) {
357
369
  publishCapturedClaudeCompletionReceipt(db, {
358
370
  target,