tailii-host 0.10.1 → 0.11.1

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.
Files changed (71) hide show
  1. package/dist/approval/hook.d.ts +2 -0
  2. package/dist/approval/hook.js +38 -3
  3. package/dist/approval/hook.js.map +1 -1
  4. package/dist/backend/herdr.d.ts +71 -23
  5. package/dist/backend/herdr.js +277 -75
  6. package/dist/backend/herdr.js.map +1 -1
  7. package/dist/backend/sessionBackend.d.ts +8 -1
  8. package/dist/backend/sessionBackend.js +3 -0
  9. package/dist/backend/sessionBackend.js.map +1 -1
  10. package/dist/backend/tmux.d.ts +387 -2
  11. package/dist/backend/tmux.js +936 -55
  12. package/dist/backend/tmux.js.map +1 -1
  13. package/dist/chat/subagentTranscript.js +11 -3
  14. package/dist/chat/subagentTranscript.js.map +1 -1
  15. package/dist/chat/transcriptTailer.d.ts +3 -1
  16. package/dist/chat/transcriptTailer.js +65 -4
  17. package/dist/chat/transcriptTailer.js.map +1 -1
  18. package/dist/commands/doctor.d.ts +5 -0
  19. package/dist/commands/doctor.js +59 -0
  20. package/dist/commands/doctor.js.map +1 -1
  21. package/dist/commands/launch.d.ts +14 -0
  22. package/dist/commands/launch.js +84 -24
  23. package/dist/commands/launch.js.map +1 -1
  24. package/dist/engine/engine.js +1 -0
  25. package/dist/engine/engine.js.map +1 -1
  26. package/dist/engine/handlers/conversation.js +29 -2
  27. package/dist/engine/handlers/conversation.js.map +1 -1
  28. package/dist/hub/hubDaemon.js +18 -5
  29. package/dist/hub/hubDaemon.js.map +1 -1
  30. package/dist/hub/hubProtocol.d.ts +16 -0
  31. package/dist/hub/hubProtocol.js +26 -2
  32. package/dist/hub/hubProtocol.js.map +1 -1
  33. package/dist/hub/reaper.d.ts +31 -0
  34. package/dist/hub/reaper.js +122 -4
  35. package/dist/hub/reaper.js.map +1 -1
  36. package/dist/hub/sessionHub.d.ts +76 -0
  37. package/dist/hub/sessionHub.js +177 -12
  38. package/dist/hub/sessionHub.js.map +1 -1
  39. package/dist/protocol/decode.js +11 -0
  40. package/dist/protocol/decode.js.map +1 -1
  41. package/dist/protocol/messages.d.ts +30 -0
  42. package/dist/sessions/claudeLiveStatus.d.ts +62 -0
  43. package/dist/sessions/claudeLiveStatus.js +202 -0
  44. package/dist/sessions/claudeLiveStatus.js.map +1 -0
  45. package/dist/sessions/claudeSessionStore.js +13 -0
  46. package/dist/sessions/claudeSessionStore.js.map +1 -1
  47. package/dist/sessions/cronSchedule.d.ts +20 -0
  48. package/dist/sessions/cronSchedule.js +172 -0
  49. package/dist/sessions/cronSchedule.js.map +1 -0
  50. package/dist/sessions/heartbeat.d.ts +44 -3
  51. package/dist/sessions/heartbeat.js +44 -4
  52. package/dist/sessions/heartbeat.js.map +1 -1
  53. package/dist/sessions/sessionSearch.js +9 -0
  54. package/dist/sessions/sessionSearch.js.map +1 -1
  55. package/dist/shared/clientBuild.d.ts +1 -1
  56. package/dist/shared/clientBuild.js +1 -1
  57. package/dist/shared/crossSession.d.ts +10 -3
  58. package/dist/shared/crossSession.js +105 -1
  59. package/dist/shared/crossSession.js.map +1 -1
  60. package/dist/shared/localCommandModelCopy.d.ts +2 -0
  61. package/dist/shared/localCommandModelCopy.js +43 -0
  62. package/dist/shared/localCommandModelCopy.js.map +1 -0
  63. package/dist/shared/narration.d.ts +7 -0
  64. package/dist/shared/narration.js +119 -0
  65. package/dist/shared/narration.js.map +1 -0
  66. package/dist/shared/pastedContent.d.ts +7 -0
  67. package/dist/shared/pastedContent.js +113 -0
  68. package/dist/shared/pastedContent.js.map +1 -0
  69. package/dist/shared/systemNotice.js +4 -1
  70. package/dist/shared/systemNotice.js.map +1 -1
  71. package/package.json +3 -3
@@ -2,11 +2,11 @@
2
2
  // tailii (TS host) — herdr backend のセッション操作(list / reattach / kill / send / read)。
3
3
  // TmuxSessionManager と同じ面(SessionBackend)を herdr CLI(socket API のフロント)で実装する。
4
4
  //
5
- // 実測済みの herdr 0.7.5 挙動(設計正本は docs/herdr-backend.md):
5
+ // 実測済みの herdr 0.7.5 挙動(0.9.3 でも CLI 面・JSON 形状は同一。設計正本は docs/herdr-backend.md):
6
6
  // - 全 pane は専用 named session `tailii` に収容(全コマンドに `--session tailii` 前置)。
7
7
  // 起動は `tab create --cwd --env --no-focus`(root_pane.pane_id が新 pane)→ `pane rename`
8
- // → `pane run <id> "exec /bin/zsh -lc '<cmd>'"`。0.7.5 で `agent start` から pane 生成
9
- // /cwd/env 指定が廃止されたため(旧: agent start 一発で pane 起動)。
8
+ // → `pane run <id> "exec env -u HERDR_ENV /bin/zsh -lc '<cmd>'"`(長いときは一時スクリプト経由)。
9
+ // 0.7.5 で `agent start` から pane 生成/cwd/env 指定が廃止されたため(旧: agent start 一発で pane 起動)。
10
10
  // `pane run` はタイプ注入で argv のクォートを保持しない → コマンドは単一文字列で渡す。
11
11
  // exec 前置によりコマンド終了と同時に pane が閉じる(旧 agent start と同義・stale shell なし)。
12
12
  // - `pane read --source recent-unwrapped --lines N` は tmux `capture-pane -J -S -N` 相当。
@@ -21,9 +21,8 @@ import * as fs from "node:fs";
21
21
  import * as os from "node:os";
22
22
  import * as path from "node:path";
23
23
  import { PROTOCOL_V1 } from "../protocol.js";
24
- import { normalizeForTextMatch, stripInvisibleForComparison } from "../shared/invisibleText.js";
25
24
  import { HERDR_PANE_ID_PATTERN, SessionMetadataStore, validateSessionName, } from "../sessions/sessionMetadataStore.js";
26
- import { CHAT_BLOCKED_BY_LOGIN_PROMPT, claudeInputBoxRendered, extractClaudeInputBox, inputBoxHasRealPendingText, inputBoxRealText, inputBoxIsShellMode, inputBoxTextMatchesRecordedPrompt, LoginCodeError, paneCommandLooksLikeAgent, screenInLoginFlow, ChatInjectionRejectedError, classifySubmitFrame, SUBMIT_ATTEMPT_LIMIT, submitLoginCode, } from "./tmux.js";
25
+ import { cancelChoiceDialog, CHAT_BLOCKED_BY_LOGIN_PROMPT, CHAT_BLOCKED_AFTER_CHOICE_CANCELLED, CHAT_BLOCKED_BY_STUCK_CHOICE, CHAT_BLOCKED_BY_UNREADABLE_CHOICE, CHOICE_CANCEL_MAX_POLLS, CHOICE_CANCEL_POLL_MS, claudeComposerBarVisible, claudeInputBoxRendered, extractClaudeInputBox, inputBoxHasRealPendingText, inputBoxRealText, inputBoxIsShellMode, inputBoxTextMatchesRecordedPrompt, LoginCodeError, paneCommandLooksLikeAgent, screenInLoginFlow, inputBoxTextIncludesProbe, typedTextProbe, typedTextWouldBreak, textIsPasteSafe, pasteTextInline, clearTypedInput, INJECT_VERIFY_BUDGET_MS, CLEAR_UNKNOWN_INPUT_BOUND, BRACKETED_PASTE_START, BRACKETED_PASTE_END, PASTED_TEXT_POLL_MS, PASTED_TEXT_TIMEOUT_MS, TYPED_TEXT_CLEAR_SETTLE_MS, ChatInjectionRejectedError, classifySubmitFrame, SEND_NOW_MAX_POLLS, SEND_NOW_POLL_MS, sendQueuedNow, settleFrameAfterChoiceCancel, SUBMIT_ATTEMPT_LIMIT, submitLoginCode, } from "./tmux.js";
27
26
  /** herdr 実行ファイルの既定絶対パス(SSH exec は非ログインシェルで PATH 外のため絶対指定)。 */
28
27
  export function defaultHerdrPath() {
29
28
  return path.join(os.homedir(), ".local", "bin", "herdr");
@@ -56,6 +55,15 @@ const HERDR_TIMEOUT_MS = 15_000;
56
55
  * (2026-09-06 実機で発症)。窓内のシェル前面は「起動中」として生存扱いにする。
57
56
  */
58
57
  export const HERDR_LAUNCH_GRACE_SECONDS = 20;
58
+ /**
59
+ * TMUX / TMUX_PANE を除いた環境。herdr server は最初に `herdr` を叩いたプロセスの環境を引き継ぎ、
60
+ * その配下の Claude は TMUX があると外側の tmux pane 名を状態ファイルの `tmux` 欄に書く
61
+ * (reaper の照合を取り違える)。tmux 内の hook / CLI から起動されても持ち込まないよう外す。
62
+ */
63
+ export function envWithoutTmux(env = process.env) {
64
+ const { TMUX: _tmux, TMUX_PANE: _tmuxPane, ...rest } = env;
65
+ return rest;
66
+ }
59
67
  /**
60
68
  * 実 herdr を起動する既定ランナー。herdr 非0 exit は throw せず結果で表現する。
61
69
  * `sessionName`(既定 tailii)を `--session` として全コマンドに前置する。
@@ -66,7 +74,7 @@ export function processHerdrCommandRunner(herdrPath = defaultHerdrPath(), sessio
66
74
  // 発行するのは pane list / pane get / tab list など即応するコマンドだけ。無期限に
67
75
  // 待つと engine の read loop と hub の tick ループが同時に止まるため上限を切る
68
76
  // (tmux ランナー・`gitService` と同じ規約)。
69
- { maxBuffer: 16 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
77
+ { maxBuffer: 16 * 1024 * 1024, timeout: timeoutMs, env: envWithoutTmux() }, (error, stdout, stderr) => {
70
78
  if (error && typeof error.code === "string") {
71
79
  // 実行ファイル起動自体の失敗(ENOENT 等)のみ throw(tmux ランナーと同じ境界)。
72
80
  reject(error);
@@ -77,13 +85,48 @@ export function processHerdrCommandRunner(herdrPath = defaultHerdrPath(), sessio
77
85
  });
78
86
  });
79
87
  }
88
+ /**
89
+ * herdr CLI のエラー JSON(`{"error":{"code":"…"}}`、stderr に出る)から code を取り出す。
90
+ * 判定不能は null。
91
+ */
92
+ export function herdrErrorCode(output) {
93
+ const text = output.trim();
94
+ if (text.length === 0)
95
+ return null;
96
+ try {
97
+ const parsed = JSON.parse(text);
98
+ return typeof parsed.error?.code === "string" ? parsed.error.code : null;
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /**
105
+ * 旧サーバーを止めて新しい版へ移る手順。`delete` まで行うのは、旧版の pane が herdr の
106
+ * claude 連携へ会話 id を申告済みで、新しいサーバー(≥0.8)が保存状態から復元するときに
107
+ * Tailii のフック抜きの `claude --resume` で自動再開してしまうため(実測 2026-09-30,
108
+ * 0.7.5 の保存状態 → 0.9.3 サーバー)。保存状態を消せば会話は開き直したときに Tailii が
109
+ * 自前の起動で resume する。0.7.5 の `pane release-agent` は申告を消さない(実測)。
110
+ */
111
+ export const HERDR_RESTART_COMMAND = "herdr session stop tailii && herdr session delete tailii";
112
+ /**
113
+ * herdr を更新した後、tailii セッションのサーバーが旧版のまま動いている(`protocol_mismatch`)
114
+ * ときの案内。0.9.x の CLI は 0.7.5 のサーバーへ全コマンドを拒否するため、サーバーを
115
+ * 止めるまで Tailii の herdr 操作は全て失敗する(実測 2026-09-30, 0.9.3 CLI × 0.7.5 server)。
116
+ * 止めると pane のプロセス(実行中の会話)は終了するので、自動では止めず利用者に委ねる。
117
+ */
118
+ export const HERDR_RESTART_NEEDED_MESSAGE = "herdr が更新されましたが、Tailii 用の herdr サーバーは古い版のまま動いています。" +
119
+ `Mac で \`${HERDR_RESTART_COMMAND}\` を実行してから会話を開き直してください` +
120
+ "(実行中の会話は一度終了し、開き直すと続きから再開します)。";
80
121
  /** HerdrSessionManager が投げる型付きエラー。 */
81
122
  export class HerdrFailedError extends Error {
82
123
  args;
83
124
  exitCode;
84
125
  stderr;
85
126
  constructor(args, exitCode, stderr) {
86
- super(`herdr ${args.join(" ")} failed (exit ${exitCode}): ${stderr}`);
127
+ super(herdrErrorCode(stderr) === "protocol_mismatch"
128
+ ? HERDR_RESTART_NEEDED_MESSAGE
129
+ : `herdr ${args.join(" ")} failed (exit ${exitCode}): ${stderr}`);
87
130
  this.args = args;
88
131
  this.exitCode = exitCode;
89
132
  this.stderr = stderr;
@@ -121,6 +164,12 @@ const ENTER_SEQUENCE = "\r";
121
164
  * 空 composer への C-u は完全に no-op で、処理中の agent にも干渉しない)。
122
165
  */
123
166
  const KILL_LINE_SEQUENCE = "\u0015";
167
+ /**
168
+ * `chat:sendNow` の chord(Ctrl-X Ctrl-S)の生制御文字(chat-send-now)。herdr の send-keys は
169
+ * キーごとに別プロセスで送るため、2 打の間が空くと chord が成立しない恐れがある。
170
+ * **1 回の send-text で続けて送る**(実測 herdr 0.7.5 + Claude Code 2.1.283 で成立)。
171
+ */
172
+ const SEND_NOW_SEQUENCE = "\u0018\u0013";
124
173
  /**
125
174
  * 画面に選択ダイアログのフッター行があるか。チャット本文が「Enter to select」を
126
175
  * 引用しただけの偽陽性(誤 Esc・再送スキップの実害)を避けるため、substring では
@@ -138,48 +187,8 @@ function stripAnsiForMatch(line) {
138
187
  // eslint-disable-next-line no-control-regex
139
188
  return line.replace(/\u001b\[[0-9;:?]*[ -/]*[@-~]/g, "");
140
189
  }
141
- /**
142
- * 入力反映検証に使う probe(本文の**末尾側**)。検証不能な本文(空など)は null。
143
- *
144
- * 末尾側なのは composer の表示特性のため: 入力が表示高を超えると composer は下へ
145
- * スクロールし**先頭行が窓外へ消える**(実測 2.1.220: 10行ペーストで先頭4行が
146
- * capture から消失)。カーソルは常に末尾にあるので、末尾側の probe だけが
147
- * 「見えている範囲」との照合を保証できる(先頭24字の旧 probe は多行/長文で
148
- * 構造的に偽陰性 → 再投入 → 本文二重化の温床だった)。
149
- *
150
- * 先頭 `!`(シェルモード)は claude TUI がモード記号として吸い上げ、入力欄本文には
151
- * 残らない(`!ls -la` は `! ls -la` と描画される)。単一行本文では照合キーからも
152
- * 落とさないと末尾24字に `!` が含まれるとき反映検証が失敗する。
153
- */
154
- export function typedTextProbe(text) {
155
- // 不可視文字は端末のセルに載らない(capture に出ない)ため、probe に混ざると反映検証が
156
- // 構造的に偽陰性になる。probe は不可視文字を除いた可視本文から取る
157
- // (照合側 `inputBoxTextIncludesProbe` も両辺から落とすので規則は一致する)。
158
- const lines = stripInvisibleForComparison(text)
159
- .split("\n")
160
- .map((line) => line.trim())
161
- .filter((line) => line.length > 0);
162
- const lastLine = lines.at(-1);
163
- if (lastLine === undefined)
164
- return null;
165
- const body = lines.length === 1 && lastLine.startsWith("!") ? lastLine.slice(1).trim() : lastLine;
166
- if (body.length === 0)
167
- return null;
168
- return body.slice(-24);
169
- }
170
- /**
171
- * 入力欄テキストに probe が反映されているか(折り返し非依存)。
172
- * extractClaudeInputBox は表示行を trim + "\n" 連結で返すため、probe が入力欄の
173
- * 行折り返しをまたぐと生の includes は絶対に一致しない(全角24字=48桁 > 内幅で必発)。
174
- * この偽陰性が「反映済み本文の再投入 = 初回送信の本文二重化」の根因だった(実機5件)。
175
- * 空白類(折り返しの改行・trim 痕・全角空白含む)と不可視文字を両辺から除去して照合する。
176
- */
177
- export function inputBoxTextIncludesProbe(boxText, probe) {
178
- const needle = normalizeForTextMatch(probe);
179
- if (needle.length === 0)
180
- return false;
181
- return normalizeForTextMatch(boxText).includes(needle);
182
- }
190
+ // 反映検証の probe は tmux backend と共有する(long-text-paste)。従来の import 元を保つ。
191
+ export { inputBoxTextIncludesProbe, typedTextProbe } from "./tmux.js";
183
192
  /** herdr CLI の JSON stdout から `result` を取り出す。JSON でない/エラー封筒は null。 */
184
193
  export function parseHerdrResult(stdout) {
185
194
  try {
@@ -312,6 +321,55 @@ export function parseHerdrForegroundCommand(stdout) {
312
321
  const name = first["name"];
313
322
  return typeof name === "string" ? name : "";
314
323
  }
324
+ /**
325
+ * `pane process-info` の前面プロセス群の pid(前面プロセスグループ id を含む)。
326
+ * Claude Code の `~/.claude/sessions/<pid>.json` と照合する(tmux 欄の無い herdr 用, 実測で一致)。
327
+ */
328
+ export function parseHerdrForegroundPids(stdout) {
329
+ const result = parseHerdrResult(stdout);
330
+ const info = result?.["process_info"];
331
+ if (typeof info !== "object" || info === null)
332
+ return [];
333
+ const raw = info;
334
+ const pids = new Set();
335
+ const group = raw["foreground_process_group_id"];
336
+ if (typeof group === "number" && Number.isInteger(group) && group > 0)
337
+ pids.add(group);
338
+ const list = raw["foreground_processes"];
339
+ if (Array.isArray(list)) {
340
+ for (const item of list) {
341
+ if (typeof item !== "object" || item === null)
342
+ continue;
343
+ const pid = item["pid"];
344
+ if (typeof pid === "number" && Number.isInteger(pid) && pid > 0)
345
+ pids.add(pid);
346
+ }
347
+ }
348
+ return [...pids];
349
+ }
350
+ /**
351
+ * セッション名に対応する pane を選ぶ。
352
+ * 1. 記録済み pane ID の pane が同じ label(または label なし)ならそれ。
353
+ * 2. 無ければ label(= セッション名)一致の pane。
354
+ * 3. それも無ければ、記録 ID の pane の label が**他の Tailii セッション名でない**限りそれ
355
+ * (利用者が herdr 上で pane 名を変えた会話も見失わない。`keys.rename_pane` で変えられる)。
356
+ * 記録 ID の pane が別の Tailii セッション名を持つときだけ採用しない: pane ID はサーバーの状態を
357
+ * 消したとき(`herdr session delete tailii` 後の初回起動など)に w1:p1 から振り直されるため、
358
+ * 古い記録 ID が別の会話の pane を指し得る(取り違えると別会話へ送信・stale 掃除で別会話を閉じる)。
359
+ * @param isTailiiName label がメタに記録された Tailii セッション名か(取り違えの判定用)。
360
+ * 3. に入ったときだけ呼ぶ(メタ全件の読み込みは重いので、通常の 1. / 2. では読まない)。
361
+ */
362
+ export function resolveHerdrSessionPane(panes, name, recordedPaneId, isTailiiName) {
363
+ const byId = recordedPaneId !== undefined ? panes.find((pane) => pane.paneId === recordedPaneId) : undefined;
364
+ if (byId !== undefined && (byId.label === null || byId.label === name))
365
+ return byId;
366
+ const byLabel = panes.find((pane) => pane.label === name);
367
+ if (byLabel !== undefined)
368
+ return byLabel;
369
+ if (byId !== undefined && byId.label !== null && !isTailiiName(byId.label))
370
+ return byId;
371
+ return null;
372
+ }
315
373
  /** herdr backend のセッション list / reattach / kill / send / read とメタデータ統合。 */
316
374
  export class HerdrSessionManager {
317
375
  runner;
@@ -322,6 +380,14 @@ export class HerdrSessionManager {
322
380
  submitDelayMs;
323
381
  /** sendTextSubmit の CR→残留確認 間隔 ms(テスト注入用)。 */
324
382
  submitVerifyDelayMs;
383
+ /** 選択ダイアログを Esc で閉じた後の消滅確認の間隔 ms(テスト注入用)。 */
384
+ choiceCancelPollMs;
385
+ /** 「今すぐ送信」の chord 後、ヒント行の消滅確認の間隔 ms(テスト注入用)。 */
386
+ sendNowPollMs;
387
+ /** 貼り付けた本文が入力欄に入るのを確かめる間隔と上限 ms(テスト注入用)。 */
388
+ pastedTextPollMs;
389
+ pastedTextTimeoutMs;
390
+ clearSettleMs;
325
391
  /** 入力欄へ本文が反映されなかったときの再投入間隔 ms(RC 切断 limbo 対策)。 */
326
392
  inputRetryDelayMs;
327
393
  /** clearInputBox の C-u 1回ごとの反映待ち ms(実測レイテンシ 9〜27ms。テスト注入用)。 */
@@ -343,6 +409,11 @@ export class HerdrSessionManager {
343
409
  this.protocolVersion = options.protocolVersion ?? PROTOCOL_V1;
344
410
  this.submitDelayMs = options.submitDelayMs ?? 600;
345
411
  this.submitVerifyDelayMs = options.submitVerifyDelayMs ?? 700;
412
+ this.choiceCancelPollMs = options.choiceCancelPollMs ?? CHOICE_CANCEL_POLL_MS;
413
+ this.sendNowPollMs = options.sendNowPollMs ?? SEND_NOW_POLL_MS;
414
+ this.pastedTextPollMs = options.pastedTextPollMs ?? PASTED_TEXT_POLL_MS;
415
+ this.pastedTextTimeoutMs = options.pastedTextTimeoutMs ?? PASTED_TEXT_TIMEOUT_MS;
416
+ this.clearSettleMs = options.clearSettleMs ?? TYPED_TEXT_CLEAR_SETTLE_MS;
346
417
  this.inputRetryDelayMs = options.inputRetryDelayMs ?? 1_500;
347
418
  this.clearKeyDelayMs = options.clearKeyDelayMs ?? 150;
348
419
  this.readyTimeoutMs = options.readyTimeoutMs ?? 10_000;
@@ -382,21 +453,19 @@ export class HerdrSessionManager {
382
453
  return this.listFromPanes(panes);
383
454
  }
384
455
  async listFromPanes(panes) {
385
- const paneIds = new Set(panes.map((pane) => pane.paneId));
386
- const labels = new Set(panes.map((pane) => pane.label).filter((label) => label !== null));
387
456
  // タブラベル(Mac 側リネーム含む会話タイトル表示)を name→label で引けるようにする
388
457
  // (session-title 逆方向同期: セッション名と異なるラベルを displayTitle として載せる)。
389
458
  const tabLabels = await this.tabLabels();
459
+ const metas = this.store.all();
460
+ const tailiiNames = new Set(metas.map((meta) => meta.name));
390
461
  const infos = [];
391
- for (const meta of this.store.all()) {
462
+ for (const meta of metas) {
392
463
  if (meta.backend !== "herdr")
393
464
  continue;
394
465
  const agent = meta.agent ?? "claude";
395
466
  const providerSessionId = meta.providerSessionId ?? (agent === "claude" ? meta.claudeSessionId : undefined);
396
- const alive = (meta.herdrPaneId !== undefined && paneIds.has(meta.herdrPaneId)) || labels.has(meta.name);
397
- const pane = (meta.herdrPaneId !== undefined
398
- ? panes.find((candidate) => candidate.paneId === meta.herdrPaneId)
399
- : undefined) ?? panes.find((candidate) => candidate.label === meta.name);
467
+ const pane = resolveHerdrSessionPane(panes, meta.name, meta.herdrPaneId, (label) => tailiiNames.has(label));
468
+ const alive = pane !== null;
400
469
  const tabLabel = pane?.tabId != null ? tabLabels.get(pane.tabId) : undefined;
401
470
  // 自動適用ラベル(hub tick が書いた値 = meta.autoTabTitle)と既定連番は人為リネーム
402
471
  // ではないため displayTitle に載せない(iOS が override として誤取り込みしないため)。
@@ -500,6 +569,24 @@ export class HerdrSessionManager {
500
569
  return true;
501
570
  }
502
571
  }
572
+ /** pane の前面プロセスの pid 群。取得できなければ null(reaper の Claude 状態照合用)。 */
573
+ async agentProcessIds(name) {
574
+ validateSessionName(name);
575
+ const target = await this.paneTarget(name);
576
+ if (target === null)
577
+ return null;
578
+ try {
579
+ const result = await this.runner(["pane", "process-info", "--pane", target]);
580
+ if (result.exitCode !== 0)
581
+ return null;
582
+ // 空は「前面プロセスが無い」ではなく形式変更・一時失敗の疑い → 取得不能として扱う。
583
+ const pids = parseHerdrForegroundPids(result.stdout);
584
+ return pids.length > 0 ? pids : null;
585
+ }
586
+ catch {
587
+ return null;
588
+ }
589
+ }
503
590
  /**
504
591
  * メタの createdAt(launch 時刻。launcher は pane 再利用時に更新しない)が猶予窓内か。
505
592
  * 未記録は窓外(従来どおりの判定)。時計の巻き戻りで経過が負になった場合も窓外に倒し、
@@ -522,7 +609,7 @@ export class HerdrSessionManager {
522
609
  validateSessionName(name);
523
610
  const pane = await this.findPane(name);
524
611
  if (pane === null) {
525
- throw new HerdrFailedError(["tab", "rename", name], 1, "pane not found");
612
+ throw await this.missingPaneError(["tab", "rename", name]);
526
613
  }
527
614
  if (pane.tabId === null) {
528
615
  throw new HerdrFailedError(["tab", "rename", name], 1, "tab id unavailable");
@@ -540,7 +627,7 @@ export class HerdrSessionManager {
540
627
  validateSessionName(name);
541
628
  const target = await this.paneTarget(name);
542
629
  if (target === null) {
543
- throw new HerdrFailedError(["pane", "close", name], 1, "pane not found");
630
+ throw await this.missingPaneError(["pane", "close", name]);
544
631
  }
545
632
  const args = ["pane", "close", target];
546
633
  const result = await this.runner(args);
@@ -558,6 +645,7 @@ export class HerdrSessionManager {
558
645
  * リトライで確定させる。submit 済みの空入力への Enter は no-op なので二重送信は起きない。
559
646
  */
560
647
  async sendTextSubmit(name, text, options = {}) {
648
+ const startedAtMs = Date.now();
561
649
  // ブート直後の注入は本文ごと TUI 初期化に破棄され得る(実測: 入力欄にも jsonl にも
562
650
  // 残らない)。herdr の claude 検出(agent_status が unknown を抜けるまで)を注入の
563
651
  // 準備完了ゲートにする。working(処理中の queue 入力)も注入可。判定不能は fail-open。
@@ -583,9 +671,29 @@ export class HerdrSessionManager {
583
671
  }
584
672
  // 選択ダイアログ(/remote-control 等)が開いたままだと本文がダイアログに食われる。
585
673
  // 注入前に Esc で閉じてから入力欄へ流す(Mac 側で手動 Esc するのと同じ操作)。
586
- if (dialogWindow !== null && screenHasSelectionFooter(dialogWindow)) {
587
- await this.sendKeys(name, ["Escape"]);
588
- await new Promise((resolve) => setTimeout(resolve, 300));
674
+ // herdr は `cancelChoiceDialog` 指定の有無に関わらず閉じる(従来からの挙動。設問は hub が
675
+ // 保留で守るので、ここへ来るのは利用者が答えずに送ると決めた送信か ❯ メニューだけ)。
676
+ // Esc は 1 回だけ打ち、消えたのを確かめてから進む(chat-cancel-choice)。旧実装は 300ms
677
+ // 待つだけで確認せず、閉じ切る前に本文を打ってダイアログへ落とし得た。
678
+ // ボトムバーが見えているフレームでは撃たない: Esc は処理中の Claude を中断するキーでもあり、
679
+ // 30 行窓は会話本文(閉じたダイアログの転写や引用)を含むため。
680
+ let choiceStuck = false;
681
+ let choiceCancelled = false;
682
+ if (dialogWindow !== null) {
683
+ const outcome = await cancelChoiceDialog({
684
+ screen: dialogWindow,
685
+ capture: () => this.captureDialogWindow(name),
686
+ sendEscape: () => this.sendKeys(name, ["Escape"]),
687
+ detect: (window) => screenHasSelectionFooter(window) && !claudeComposerBarVisible(window),
688
+ pollMs: this.choiceCancelPollMs,
689
+ maxPolls: CHOICE_CANCEL_MAX_POLLS,
690
+ });
691
+ // 閉じたかどうかの最終判断は下のフレーム判定に任せる(30 行窓は会話本文を含むので、
692
+ // 本文に残った文言で「閉じていない」と見誤っても、ダイアログでなければ従来どおり送る)。
693
+ choiceStuck = outcome === "stuck";
694
+ choiceCancelled = outcome === "cancelled";
695
+ if (choiceCancelled)
696
+ options.onChoiceDialogCancelled?.();
589
697
  }
590
698
  // 中断(停止)直後の入力欄に残存テキストがあるまま注入すると、今回の本文がその後ろへ
591
699
  // 連結され 1 メッセージとして送信される(実機FB 2026-07-29: 停止→送信で「前回本文+今回
@@ -603,8 +711,25 @@ export class HerdrSessionManager {
603
711
  // ではないので上の Esc では閉じられない)。ここまで来てバーが見えないなら、本文を
604
712
  // 1 キーも打たずに諦める。
605
713
  // 見逃し側(未知フレーム)は従来どおり注入へ進む=送信が丸ごと不能にはならない。
606
- const injectFrame = classifySubmitFrame(await this.captureVisibleScreenAnsiOrNull(name));
714
+ // 閉じた直後は描画途中でボトムバーが欠けることがある。設問を閉じる指定の送信だけ、
715
+ // 入力欄が描画されるまで短く撮り直す(指定の無い送信は read 回数を増やさない)。
716
+ const { frame: injectFrame } = await settleFrameAfterChoiceCancel({
717
+ capture: () => this.captureVisibleScreenAnsiOrNull(name),
718
+ pollMs: this.choiceCancelPollMs,
719
+ maxPolls: choiceCancelled && options.cancelChoiceDialog === true ? CHOICE_CANCEL_MAX_POLLS : 0,
720
+ });
721
+ if (choiceCancelled && options.cancelChoiceDialog === true && injectFrame === "unknown") {
722
+ throw new ChatInjectionRejectedError(CHAT_BLOCKED_AFTER_CHOICE_CANCELLED);
723
+ }
724
+ if (options.cancelChoiceDialog === true && injectFrame === "unknown") {
725
+ // 設問がある前提の送信は fail-closed: 入力欄(ボトムバー)が見えたときだけ打つ。
726
+ // 指定の無い送信は従来どおり(未知フレームで送信が丸ごと不能にならないよう fail-open)。
727
+ throw new ChatInjectionRejectedError(CHAT_BLOCKED_BY_UNREADABLE_CHOICE);
728
+ }
607
729
  if (injectFrame === "dialog") {
730
+ // Esc は打ったが閉じない。ここで本文を打つとダイアログへ落ちるので、何も打たずに諦める。
731
+ if (choiceStuck)
732
+ throw new ChatInjectionRejectedError(CHAT_BLOCKED_BY_STUCK_CHOICE);
608
733
  throw new ChatInjectionRejectedError("ダイアログの表示中はメッセージを送信できません(アプリで選択肢に答えてから送り直してください)");
609
734
  }
610
735
  const pending = injectFrame === "pending" ? await this.inputBoxPendingText(name) : "";
@@ -636,6 +761,43 @@ export class HerdrSessionManager {
636
761
  // chat_send を uncertain(アプリの明示再送)へ倒す。
637
762
  const probe = typedTextProbe(text);
638
763
  let typed = probe === null;
764
+ if (!typed && typedTextWouldBreak(text) && textIsPasteSafe(text)) {
765
+ // 1 回で打つと壊れる本文は、小分けの括弧付き貼り付けで渡す(long-text-paste)。1 回で打つと
766
+ // CLI が読み取りの境界ごとに貼り付けとして取り込み、入力欄が `[Pasted text #N]` になって
767
+ // 下の反映検証が必ず失敗する(実測 2.1.283: 983 字の送信が 3 回打ち直した末に失敗)。
768
+ const target = await this.paneTarget(name);
769
+ if (target === null) {
770
+ throw await this.missingPaneError(["pane", "send-text", name]);
771
+ }
772
+ const sendRaw = async (payload) => {
773
+ const args = ["pane", "send-text", target, payload];
774
+ const result = await this.runner(args);
775
+ if (result.exitCode !== 0)
776
+ throw new HerdrFailedError(args, result.exitCode, result.stderr);
777
+ };
778
+ const outcome = await pasteTextInline(text, {
779
+ // 1 つずつ書き込む。まとめて書き込むと、CLI が読み進めるのを待たされる(実測: 8KB を
780
+ // 1 回で書くと入り切るまで 4〜7 秒、20KB は 10 秒でも入り切らない。1 つずつなら 1 秒)。
781
+ paste: async (pieces) => {
782
+ for (const piece of pieces)
783
+ await sendRaw(`${BRACKETED_PASTE_START}${piece}${BRACKETED_PASTE_END}`);
784
+ },
785
+ capture: () => this.captureVisibleScreenAnsiOrNull(name),
786
+ sendKills: (count) => sendRaw(KILL_LINE_SEQUENCE.repeat(count)),
787
+ pollMs: this.pastedTextPollMs,
788
+ timeoutMs: this.pastedTextTimeoutMs,
789
+ clearDelayMs: this.clearKeyDelayMs,
790
+ clearSettleMs: this.clearSettleMs,
791
+ verifyDeadlineMs: startedAtMs + INJECT_VERIFY_BUDGET_MS,
792
+ });
793
+ if (outcome !== "arrived") {
794
+ // 打つ場合の反映検証と同じ扱い: 残骸を best-effort で掃除してから失敗として返す。
795
+ // 貼り直しはしない(CLI が止まっていただけなら、貼り直した分だけ後から重なって入る)。
796
+ await this.clearInputBox(name);
797
+ throw new HerdrFailedError(["pane", "send-text", name], 1, "pasted text did not reach the input box (RC limbo / ダイアログ表示中 / 入力欄不可視 / CLI の停止のいずれか)");
798
+ }
799
+ typed = true;
800
+ }
639
801
  for (let attempt = 0; attempt < 3 && !typed; attempt += 1) {
640
802
  if (attempt > 0) {
641
803
  await new Promise((resolve) => setTimeout(resolve, this.inputRetryDelayMs));
@@ -671,6 +833,15 @@ export class HerdrSessionManager {
671
833
  if ((await this.submitTypedText(name)) !== "submitted")
672
834
  options.onUnconfirmedSubmit?.();
673
835
  }
836
+ /** CLI のキューに溜まっている発話を今すぐ届ける(chat-send-now, SessionBackend 共通面)。 */
837
+ async sendQueuedNow(name) {
838
+ return sendQueuedNow({
839
+ capture: () => this.captureVisibleScreenAnsiOrNull(name),
840
+ sendChord: () => this.sendKeys(name, [SEND_NOW_SEQUENCE], true),
841
+ pollMs: this.sendNowPollMs,
842
+ maxPolls: SEND_NOW_MAX_POLLS,
843
+ });
844
+ }
674
845
  /**
675
846
  * 入力済みの本文を Enter で送信し、成立を確認するまで上限つきで撃ち直す(tmux 側と同型)。
676
847
  *
@@ -764,6 +935,7 @@ export class HerdrSessionManager {
764
935
  */
765
936
  async clearInputBox(name) {
766
937
  try {
938
+ let first = null;
767
939
  for (let attempt = 0; attempt < 15; attempt += 1) {
768
940
  // ダイアログ判定は selectionDialogVisible(末尾30行窓)に揃える。全画面を
769
941
  // 走査すると、チャット本文に残る閉じたダイアログの転写(「Enter to select」行)を
@@ -777,12 +949,27 @@ export class HerdrSessionManager {
777
949
  return false;
778
950
  if (realText.replace(/\s+/g, "").length === 0)
779
951
  return true;
952
+ first ??= realText;
780
953
  await this.sendKeys(name, [KILL_LINE_SEQUENCE], true);
781
954
  await new Promise((resolve) => setTimeout(resolve, this.clearKeyDelayMs));
782
955
  }
783
956
  // 上限到達時も最後の C-u の結果は未観測なので、最終状態を見てから判定する。
784
957
  const realText = inputBoxRealText(await this.captureVisibleScreenAnsi(name));
785
- return realText !== null && realText.replace(/\s+/g, "").length === 0;
958
+ if (realText === null)
959
+ return false;
960
+ if (realText.replace(/\s+/g, "").length === 0)
961
+ return true;
962
+ // 消えてはいるが行が多い(9 行以上の本文は 15 回では消し切れない)。進んでいる限り続ける。
963
+ // 1 字も消えていない残存(C-u の効かない中身)には、これ以上打たない。
964
+ // ダイアログが出ていたら `clearTypedInput` が 1 キーも打たない(フレームを確かめてから打つ)。
965
+ if (realText === first)
966
+ return false;
967
+ return await clearTypedInput(CLEAR_UNKNOWN_INPUT_BOUND, {
968
+ capture: () => this.captureVisibleScreenAnsiOrNull(name),
969
+ sendKills: (count) => this.sendKeys(name, [KILL_LINE_SEQUENCE.repeat(count)], true),
970
+ clearDelayMs: this.clearKeyDelayMs,
971
+ clearSettleMs: this.clearSettleMs,
972
+ });
786
973
  }
787
974
  catch {
788
975
  return false;
@@ -903,7 +1090,7 @@ export class HerdrSessionManager {
903
1090
  validateSessionName(name);
904
1091
  const target = await this.paneTarget(name);
905
1092
  if (target === null) {
906
- throw new HerdrFailedError(["pane", "read", name], 1, "pane not found");
1093
+ throw await this.missingPaneError(["pane", "read", name]);
907
1094
  }
908
1095
  return this.readPane(target, ["--source", "visible", "--format", "ansi"]);
909
1096
  }
@@ -931,7 +1118,7 @@ export class HerdrSessionManager {
931
1118
  return;
932
1119
  const target = await this.paneTarget(name);
933
1120
  if (target === null) {
934
- throw new HerdrFailedError(["pane", "send-keys", name], 1, "pane not found");
1121
+ throw await this.missingPaneError(["pane", "send-keys", name]);
935
1122
  }
936
1123
  for (const key of keys) {
937
1124
  let args;
@@ -966,7 +1153,7 @@ export class HerdrSessionManager {
966
1153
  validateSessionName(name);
967
1154
  const target = await this.paneTarget(name);
968
1155
  if (target === null) {
969
- throw new HerdrFailedError(["pane", "read", name], 1, "pane not found");
1156
+ throw await this.missingPaneError(["pane", "read", name]);
970
1157
  }
971
1158
  const lines = options.lines ?? this.captureLines;
972
1159
  if (options.joinWrappedLines ?? false) {
@@ -991,7 +1178,7 @@ export class HerdrSessionManager {
991
1178
  validateSessionName(name);
992
1179
  const target = await this.paneTarget(name);
993
1180
  if (target === null) {
994
- throw new HerdrFailedError(["pane", "read", name], 1, "pane not found");
1181
+ throw await this.missingPaneError(["pane", "read", name]);
995
1182
  }
996
1183
  return this.readPane(target, ["--source", "visible"]);
997
1184
  }
@@ -1026,6 +1213,24 @@ export class HerdrSessionManager {
1026
1213
  // ENOENT 等(未導入環境)は無視。
1027
1214
  }
1028
1215
  }
1216
+ /**
1217
+ * 入出力先の pane が見つからなかったときのエラー。`pane list` 自体が旧サーバーとの非互換
1218
+ * (`protocol_mismatch`)で失敗していたなら、「pane が無い」ではなく更新の案内を返す
1219
+ * (livePanes は失敗を空集合として握るため、そのままだと送信失敗が `pane not found` に化ける)。
1220
+ * 見つからなかった後だけ 1 回追加で問い合わせるので、正常時の経路は増えない。
1221
+ */
1222
+ async missingPaneError(args) {
1223
+ try {
1224
+ const result = await this.runner(["pane", "list"]);
1225
+ if (result.exitCode !== 0 && herdrErrorCode(result.stderr) === "protocol_mismatch") {
1226
+ return new HerdrFailedError(["pane", "list"], result.exitCode, result.stderr);
1227
+ }
1228
+ }
1229
+ catch {
1230
+ // 実行ファイル起動失敗などは従来どおり「pane が無い」として扱う。
1231
+ }
1232
+ return new HerdrFailedError(args, 1, "pane not found");
1233
+ }
1029
1234
  /** 生存 pane の一覧。取得できなかった場合は null(「0 件」と区別する)。 */
1030
1235
  async livePanesOrNull() {
1031
1236
  try {
@@ -1044,14 +1249,11 @@ export class HerdrSessionManager {
1044
1249
  }
1045
1250
  /** セッション名の現存 pane を解決する(記録済み pane ID 優先、無ければ label 一致)。 */
1046
1251
  async findPane(name) {
1047
- const panes = await this.livePanes();
1048
- const recorded = this.store.get(name)?.herdrPaneId;
1049
- if (recorded !== undefined) {
1050
- const byId = panes.find((pane) => pane.paneId === recorded);
1051
- if (byId !== undefined)
1052
- return byId;
1053
- }
1054
- return panes.find((pane) => pane.label === name) ?? null;
1252
+ return resolveHerdrSessionPane(await this.livePanes(), name, this.store.get(name)?.herdrPaneId,
1253
+ // 3. の判定でだけ該当 1 件を読む(findPane はライブビュー更新で 250ms ごとに呼ばれるので
1254
+ // メタ全件は読まない)。名前の一致まで見るのは、大文字小文字を区別しない FS で
1255
+ // `S-B` が `s-b.json` を読んでも別名扱いにし、listFromPanes(中身の名前で判定)と揃えるため。
1256
+ (label) => this.store.get(label)?.name === label);
1055
1257
  }
1056
1258
  /** 入出力 target の pane ID(現存しなければ null)。 */
1057
1259
  async paneTarget(name) {