throughline 0.16.31 → 0.16.33

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.
@@ -39,6 +39,7 @@ const SC_COMMANDS = [
39
39
  'throughline prompt-submit',
40
40
  'throughline pre-compact',
41
41
  'throughline pre-tool-use',
42
+ 'throughline claude-forward-wait',
42
43
  // 旧コマンド(アンインストール時に除去する)
43
44
  'throughline inject-context',
44
45
  'throughline capture-tool',
@@ -63,6 +64,8 @@ const SC_HOOKS = {
63
64
  // Claude の自動継続 (ADR 0033) の hook。install は登録しない。`auto-handoff enable --host claude` が登録し、
64
65
  // `disable` が外す。PreToolUse は道具の呼び出しのたびに走るので、使わない端末には置かない。
65
66
  // PreCompact に matcher は付けない: 手動の /compact でも呼ばれ、止める前の記録を取り下げる。
67
+ // Stop の待つ hook (ADR 0063) は、引き継ぎ前の会話へ届いた入力を、手すきの後継へ届ける。`asyncRewake` なので番の終わりを
68
+ // 待たせない。timeout は待てる長さで、src/claude-forward-wait.mjs の値と合わせる。
66
69
  const CLAUDE_AUTO_HANDOFF_HOOKS = {
67
70
  PreCompact: {
68
71
  hooks: [{ type: 'command', command: 'throughline pre-compact' }],
@@ -70,6 +73,9 @@ const CLAUDE_AUTO_HANDOFF_HOOKS = {
70
73
  PreToolUse: {
71
74
  hooks: [{ type: 'command', command: 'throughline pre-tool-use', timeout: 15 }],
72
75
  },
76
+ Stop: {
77
+ hooks: [{ type: 'command', command: 'throughline claude-forward-wait', asyncRewake: true, timeout: 86400 }],
78
+ },
73
79
  };
74
80
  const CLAUDE_AUTO_HANDOFF_COMMANDS = new Set(
75
81
  Object.values(CLAUDE_AUTO_HANDOFF_HOOKS).map(entry => entry.hooks[0].command),
@@ -662,41 +668,53 @@ const defaultClaudeSettingsPath = () => join(homedir(), '.claude', 'settings.jso
662
668
  function isClaudeAutoHandoffEntry(group, event) {
663
669
  const entry = CLAUDE_AUTO_HANDOFF_HOOKS[event];
664
670
  return !group.matcher && (group.hooks ?? []).length === 1 &&
665
- group.hooks[0].command === entry.hooks[0].command && group.hooks[0].timeout === entry.hooks[0].timeout;
671
+ group.hooks[0].command === entry.hooks[0].command && group.hooks[0].timeout === entry.hooks[0].timeout &&
672
+ (group.hooks[0].asyncRewake ?? false) === (entry.hooks[0].asyncRewake ?? false);
673
+ }
674
+
675
+ /** その event に、自動継続の hook が今の形で1つだけ登録されているか。 */
676
+ function claudeAutoHandoffEventRegistered(hooks, event) {
677
+ return (hooks[event] ?? []).filter(group => isClaudeAutoHandoffEntry(group, event)).length === 1 &&
678
+ (hooks[event] ?? []).flatMap(group => group.hooks ?? [])
679
+ .filter(hook => hook.command === CLAUDE_AUTO_HANDOFF_HOOKS[event].hooks[0].command).length === 1;
666
680
  }
667
681
 
668
- /** 自動継続の hook が、今の形で2つとも登録されているか。 */
682
+ /** 自動継続の hook が、今の形で全部登録されているか。 */
669
683
  export function claudeAutoHandoffHooksRegistered(settingsPath = defaultClaudeSettingsPath()) {
670
684
  const hooks = readSettings(settingsPath).hooks ?? {};
671
- return Object.keys(CLAUDE_AUTO_HANDOFF_HOOKS).every(event =>
672
- (hooks[event] ?? []).filter(group => isClaudeAutoHandoffEntry(group, event)).length === 1 &&
673
- (hooks[event] ?? []).flatMap(group => group.hooks ?? [])
674
- .filter(hook => CLAUDE_AUTO_HANDOFF_COMMANDS.has(hook.command)).length === 1);
685
+ return Object.keys(CLAUDE_AUTO_HANDOFF_HOOKS).every(event => claudeAutoHandoffEventRegistered(hooks, event));
675
686
  }
676
687
 
677
- function withoutClaudeAutoHandoffHooks(hooks) {
688
+ function withoutClaudeAutoHandoffHooks(hooks, commands = CLAUDE_AUTO_HANDOFF_COMMANDS) {
678
689
  const next = {};
679
690
  for (const [event, groups] of Object.entries(hooks)) {
680
691
  const kept = groups.map(group => ({ ...group,
681
- hooks: (group.hooks ?? []).filter(hook => !CLAUDE_AUTO_HANDOFF_COMMANDS.has(hook.command)) }))
692
+ hooks: (group.hooks ?? []).filter(hook => !commands.has(hook.command)) }))
682
693
  .filter(group => group.hooks.length);
683
694
  if (kept.length) next[event] = kept;
684
695
  }
685
696
  return next;
686
697
  }
687
698
 
688
- /** `auto-handoff enable --host claude` 用。他製品の hook とその並びは保ち、自分の2つを今の形で置く。 */
699
+ /**
700
+ * `auto-handoff enable --host claude` 用。他製品の hook とその並びは保ち、自分の hook を今の形で置く。
701
+ * 今の形で登録済みの event には触らない(版を上げて hook が増えた時に、前からある hook の並びを動かさない)。
702
+ */
689
703
  export function installClaudeAutoHandoffHooks(settingsPath = defaultClaudeSettingsPath()) {
690
- if (!claudeAutoHandoffHooksRegistered(settingsPath)) {
691
- const current = readSettings(settingsPath);
692
- const hooks = withoutClaudeAutoHandoffHooks(current.hooks ?? {});
693
- for (const [event, entry] of Object.entries(CLAUDE_AUTO_HANDOFF_HOOKS)) hooks[event] = [...(hooks[event] ?? []), entry];
694
- writeSettings(settingsPath, { ...current, hooks });
695
- }
704
+ const current = readSettings(settingsPath);
705
+ let hooks = current.hooks ?? {};
706
+ let changed = false;
707
+ for (const [event, entry] of Object.entries(CLAUDE_AUTO_HANDOFF_HOOKS)) {
708
+ if (claudeAutoHandoffEventRegistered(hooks, event)) continue;
709
+ hooks = withoutClaudeAutoHandoffHooks(hooks, new Set([entry.hooks[0].command]));
710
+ hooks[event] = [...(hooks[event] ?? []), entry];
711
+ changed = true;
712
+ }
713
+ if (changed) writeSettings(settingsPath, { ...current, hooks });
696
714
  return { settingsPath, commands: [...CLAUDE_AUTO_HANDOFF_COMMANDS] };
697
715
  }
698
716
 
699
- /** `auto-handoff disable --host claude` と、無効な端末の install 用。自分の2つだけを外す。 */
717
+ /** `auto-handoff disable --host claude` と、無効な端末の install 用。自分の hook だけを外す。 */
700
718
  export function removeClaudeAutoHandoffHooks(settingsPath = defaultClaudeSettingsPath()) {
701
719
  if (!existsSync(settingsPath)) return { settingsPath, removed: false };
702
720
  const current = readSettings(settingsPath);
@@ -177,6 +177,8 @@ export async function run() {
177
177
  handoff_id: redirect.handoffId,
178
178
  successor_short_id: redirect.successorShortId,
179
179
  input_kind: redirect.kind,
180
+ forwarded: redirect.forwarded,
181
+ ...(redirect.wake ? { wake: redirect.wake } : {}),
180
182
  });
181
183
  process.stdout.write(`${JSON.stringify(redirect.output)}\n`);
182
184
  process.exit(0);
@@ -199,6 +201,7 @@ export async function run() {
199
201
  // プロンプト到達 = セッション実在の証明。幽霊 SessionStart はここに来られない。
200
202
  // /tl・/clear のバトン書き込みより先に実行する (初回プロンプトが /tl でも、
201
203
  // 引き継ぎを受けてから自分のバトンを書く順序になり、自己バトン食いが起きない)。
204
+ let injected = false;
202
205
  if (session_id) {
203
206
  const db = getDb();
204
207
  const projectPath = cwd ?? process.cwd();
@@ -219,6 +222,7 @@ export async function run() {
219
222
  if (!delivery.delivered) {
220
223
  process.stderr.write(`[prompt-submit] ${delivery.reason}\n`);
221
224
  }
225
+ injected = true;
222
226
  }
223
227
  logDecision({
224
228
  ts: new Date(now).toISOString(),
@@ -238,6 +242,19 @@ export async function run() {
238
242
  }
239
243
  }
240
244
 
245
+ // 引き継ぎ前の会話へ届いた入力が、この会話(後継)の置き場で待っていれば、文脈として足す (ADR 0063)。
246
+ // 置き場のフォルダが無い会話(ほとんどの会話)では、何も読み込まずに先へ進む。記憶を注入した入力には足さない
247
+ // (足すと、文脈の長さの上限で両方が切り詰められる。置き場の分は、番の終わりの hook か次の入力で届く)。
248
+ if (!injected && hostAdapter.host === CLAUDE_HOST && typeof session_id === 'string' && CLAUDE_SESSION_ID_PATTERN.test(session_id) &&
249
+ existsSync(join(homedir(), '.throughline', 'claude-auto-handoff', 'inbox', session_id))) {
250
+ const { takeClaudeForwardsAsContext } = await import('./claude-forward.mjs');
251
+ const taken = takeClaudeForwardsAsContext({ sessionId: session_id, via: 'prompt-submit' });
252
+ if (taken) {
253
+ process.stdout.write(`${taken.text}\n`);
254
+ taken.done();
255
+ }
256
+ }
257
+
241
258
  // slash command の判定材料は host 依存 (Grok は user_query 包装のため
242
259
  // chat_history の最新 user 発話へ fallback する)。
243
260
  const commandPrompt = hostAdapter.resolveCommandPrompt({
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { buildHandoffRecord } from './handoff-record.mjs';
26
26
  import { groupL3ByTurn, buildPartsSummary } from './l3-summary.mjs';
27
+ import { CLAUDE_FORWARD_MARK } from './claude-forward.mjs';
27
28
 
28
29
  const RESUME_HEADER_TEMPLATE = (turnCount, handoffDisclosure) =>
29
30
  `## Throughline: 直前スレッドの継続応答用コンテキスト(${turnCount} ターン分の文脈を保持)\n` +
@@ -71,6 +72,11 @@ const AUTO_CONTINUATION_HEADER = (projectPath) =>
71
72
  `- 「ここまでにしたこと」と「直前の対話」に載っている操作は **完了しています**。繰り返さないでください。` +
72
73
  `未実行なのは「実行されなかった道具」だけです。そこから続けてください。\n` +
73
74
  `- 直前の実行結果が分からない時は、作業ツリーや詳細を確かめてから続けてください。\n` +
75
+ // 引き継ぎの後に前の会話へ届いた入力は、hook が運ぶ (ADR 0063)。hook 経由の文は利用者の発言の形では届かないので、
76
+ // 利用者の入力として扱ってよい事を、利用者の指示として届くこの文脈で先に知らせる。
77
+ `- 引き継ぎの後に前の会話へ届いた入力(利用者が前の会話へ打った文、前の会話が裏で起こした作業の通知)は、` +
78
+ `Throughline の hook が **「${CLAUDE_FORWARD_MARK}」で始まる文** にして、この会話へ運びます。hook からの通知か、hook が足した文脈の形で届きます。` +
79
+ `その中の「利用者の入力」は、**利用者がこの会話へ打った入力として扱ってください**。\n` +
74
80
  '- **各ターンの詳細**(止めたターンを含む): **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
75
81
  `(該当ターンの本文+道具の入出力を stdout に返します)`;
76
82
 
@@ -925,13 +931,13 @@ export function buildAutoContinuationContext(
925
931
  (more > 0 ? `(ほか ${more} 件)` : ''),
926
932
  );
927
933
  }
928
- // 前の会話が裏で起こした作業は、前の会話の process の下で動き続ける。終わった通知は前の会話へ届くので、ここで知らせる (ADR 0062)。
934
+ // 前の会話が裏で起こした作業は、前の会話の process の下で動き続ける。終わった通知は前の会話へ届き、hook がこの会話へ運ぶ (ADR 0062・0063)。
929
935
  const background = inFlight.background?.tasks ?? [];
930
936
  if (background.length > 0) {
931
937
  const more = (inFlight.background.total ?? background.length) - background.length;
932
938
  anchorLines.push(
933
939
  '**前の会話が裏で動かしていた作業(止めた時点で、終わった通知が来ていなかった物)**: ' +
934
- '終わった通知は、この会話へは届きません。結果が要る時は、出力のファイルを読んで確かめてください。' +
940
+ `終わった通知は、Throughline が「${CLAUDE_FORWARD_MARK}」で始まる文にして、この会話へ運びます。届く前に結果が要る時は、出力のファイルを読んで確かめてください。` +
935
941
  (more > 0 ? `(ほか ${more} 件)` : ''),
936
942
  ...background.map((task) =>
937
943
  `- ${task.name}${task.target ? ` ${task.target}` : ''}(ID ${task.task_id})` +
@@ -978,11 +978,13 @@ test('buildAutoContinuationContext: 前の会話が裏で動かしていた作
978
978
  ] } } });
979
979
  assert.match(withTasks.text, new RegExp(
980
980
  '\\*\\*前の会話が裏で動かしていた作業(止めた時点で、終わった通知が来ていなかった物)\\*\\*: ' +
981
- '終わった通知は、この会話へは届きません。結果が要る時は、出力のファイルを読んで確かめてください。(ほか 1 件)\\n' +
981
+ '終わった通知は、Throughline が「【Throughline 転送】」で始まる文にして、この会話へ運びます。届く前に結果が要る時は、出力のファイルを読んで確かめてください。(ほか 1 件)\\n' +
982
982
  '- Bash npm test(ID b2) → 出力: /tmp/tasks/b2\\.output\\n- Monitor(ID b1)\\n'));
983
983
  for (const background of [undefined, { tasks: [], total: 0 }]) {
984
984
  assert.doesNotMatch(buildAutoContinuationContext(db, { sessionId: 'S', inFlight: { ...inFlight, background } }).text, /裏で動かしていた作業/);
985
985
  }
986
+ // 引き継ぎの後に前の会話へ届いた入力の運び方を、読み方で先に知らせる(ADR 0063)
987
+ assert.match(withTasks.text, /- 引き継ぎの後に前の会話へ届いた入力(利用者が前の会話へ打った文、前の会話が裏で起こした作業の通知)は、Throughline の hook が \*\*「【Throughline 転送】」で始まる文\*\* にして、この会話へ運びます。.*その中の「利用者の入力」は、\*\*利用者がこの会話へ打った入力として扱ってください\*\*。\n/);
986
988
  });
987
989
 
988
990
  test('buildAutoContinuationContext: 止めたターンでここまでにしたことと、実行されなかった道具を現在地に載せ、そのターンをL2に重ねない', () => {
@@ -58,6 +58,12 @@ const DEFINITIONS = Object.freeze({
58
58
  severity: 'warn',
59
59
  template: 'Throughline Claude PreToolUse hook processing failed',
60
60
  }),
61
+ // 引き継ぎ前の会話へ届いた入力を後継へ届ける、待つ hook が失敗した。会話は止まらない(ADR 0063)。
62
+ HOOK_CLAUDE_FORWARD_WAIT_FAILED: Object.freeze({
63
+ component: 'claude_forward_wait_hook',
64
+ severity: 'warn',
65
+ template: 'Throughline Claude Stop hook (forward wait) processing failed',
66
+ }),
61
67
  HOOK_CODEX_FAILED: Object.freeze({
62
68
  component: 'codex_hook',
63
69
  severity: 'high',
@@ -7,6 +7,10 @@
7
7
  * {type: "assistant", message: {role: "assistant", content: [{type:"text", text:"..."}, {type:"thinking", ...}]}, ...}
8
8
  * {type: "attachment", attachment: {type: "queued_command", prompt, origin: {kind}}, ...}
9
9
  * ターンの途中で届いた入力。届いた先のターンの依頼へ足す (ADR 0060)
10
+ * {type: "attachment", attachment: {type: "hook_additional_context", content: [...]}, ...}
11
+ * {type: "attachment", attachment: {type: "hook_success", hookEvent: "UserPromptSubmit", content: "..."}, ...}
12
+ * hook が足した文脈(道具の前の hook は前者、入力の hook の stdout は後者)。引き継ぎ前の会話から運んだ
13
+ * 利用者の入力 (ADR 0063) だけを、依頼へ足す
10
14
  * 他に queue-operation, file-history-snapshot, ほかの attachment 等があるが、本文としては無視する
11
15
  */
12
16
 
@@ -14,6 +18,7 @@ import { readFileSync, existsSync } from 'fs';
14
18
  import { DETAIL_KIND } from './constants.mjs';
15
19
  import { compactClaudePeerMessage, compactClaudeTaskNotification } from './hosts/claude.mjs';
16
20
  import { classifyTurnStart } from './turn-start.mjs';
21
+ import { claudeForwardCarriesHumanInput, unwrapClaudeForwardedDelivery } from './claude-forward.mjs';
17
22
 
18
23
  function entryKind(entry) {
19
24
  if (typeof entry?.type === 'string' && entry.type.length > 0) return entry.type;
@@ -70,9 +75,22 @@ export const MID_TURN_INPUT_MARK = '(作業の途中で届いた入力)';
70
75
  function claudeMidTurnInputText(entry) {
71
76
  if (entry?.type !== 'attachment' || entry.isSidechain === true) return null;
72
77
  const attachment = entry.attachment;
78
+ // 引き継ぎ前の会話へ届いた利用者の入力を、Throughline の hook が文脈として運んだ物 (ADR 0063)。
79
+ // 完了通知だけを運んだ分は、ターン自身の作業の結果と同じ扱いで、足さない。
80
+ // 道具の前の hook が返した文脈は `hook_additional_context`、入力の hook が stdout へ出した文脈は `hook_success` で書かれる。
81
+ if (attachment?.type === 'hook_additional_context' || attachment?.type === 'hook_success') {
82
+ const content = (Array.isArray(attachment.content) ? attachment.content.filter(part => typeof part === 'string').join('\n')
83
+ : typeof attachment.content === 'string' ? attachment.content : '').trim();
84
+ if (!claudeForwardCarriesHumanInput(content)) return null;
85
+ const forwarded = normalizeTerminalText(content);
86
+ return forwarded ? forwarded : null;
87
+ }
73
88
  if (attachment?.type !== 'queued_command') return null;
74
- if (!CLAUDE_MID_TURN_INPUT_ORIGINS.includes(attachment.origin?.kind)) return null;
75
89
  if (typeof attachment.prompt !== 'string' && !Array.isArray(attachment.prompt)) return null;
90
+ // 待つ hook が、次の番の作業中に運んだ入力(ADR 0063)。Claude Code が通知の形で順番待ちへ入れる。
91
+ const forwarded = unwrapClaudeForwardedDelivery(extractText(attachment.prompt));
92
+ if (forwarded !== null) return claudeForwardCarriesHumanInput(forwarded) ? (normalizeTerminalText(forwarded) || null) : null;
93
+ if (!CLAUDE_MID_TURN_INPUT_ORIGINS.includes(attachment.origin?.kind)) return null;
76
94
  const text = normalizeTerminalText(compactClaudePeerMessage(extractText(attachment.prompt)));
77
95
  return text ? text : null;
78
96
  }
@@ -170,8 +188,10 @@ export function readTranscript(transcriptPath) {
170
188
  const extracted = extractText(rawContent);
171
189
  // user 発言には端末の生出力 (貼り付け・Claude の task 通知) や、別の会話から届いた発言の
172
190
  // 定型の注意書きが入る。記憶には制御文字と定型文を落とした本文だけを残す。
191
+ // 引き継ぎ前の会話から運んだ入力 (ADR 0063) は、Claude Code が付けた通知の包みを外して、運んだ文だけを残す。
173
192
  const text = role === 'user'
174
- ? normalizeTerminalText(compactClaudePeerMessage(compactClaudeTaskNotification(extracted)))
193
+ ? normalizeTerminalText(unwrapClaudeForwardedDelivery(extracted)
194
+ ?? compactClaudePeerMessage(compactClaudeTaskNotification(extracted)))
175
195
  : extracted;
176
196
  if (!text) continue;
177
197