tailii-host 0.10.1 → 0.11.0

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 (67) 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 +31 -23
  5. package/dist/backend/herdr.js +181 -50
  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/launch.js +4 -2
  19. package/dist/commands/launch.js.map +1 -1
  20. package/dist/engine/engine.js +1 -0
  21. package/dist/engine/engine.js.map +1 -1
  22. package/dist/engine/handlers/conversation.js +29 -2
  23. package/dist/engine/handlers/conversation.js.map +1 -1
  24. package/dist/hub/hubDaemon.js +18 -5
  25. package/dist/hub/hubDaemon.js.map +1 -1
  26. package/dist/hub/hubProtocol.d.ts +16 -0
  27. package/dist/hub/hubProtocol.js +26 -2
  28. package/dist/hub/hubProtocol.js.map +1 -1
  29. package/dist/hub/reaper.d.ts +31 -0
  30. package/dist/hub/reaper.js +122 -4
  31. package/dist/hub/reaper.js.map +1 -1
  32. package/dist/hub/sessionHub.d.ts +76 -0
  33. package/dist/hub/sessionHub.js +177 -12
  34. package/dist/hub/sessionHub.js.map +1 -1
  35. package/dist/protocol/decode.js +11 -0
  36. package/dist/protocol/decode.js.map +1 -1
  37. package/dist/protocol/messages.d.ts +30 -0
  38. package/dist/sessions/claudeLiveStatus.d.ts +62 -0
  39. package/dist/sessions/claudeLiveStatus.js +202 -0
  40. package/dist/sessions/claudeLiveStatus.js.map +1 -0
  41. package/dist/sessions/claudeSessionStore.js +13 -0
  42. package/dist/sessions/claudeSessionStore.js.map +1 -1
  43. package/dist/sessions/cronSchedule.d.ts +20 -0
  44. package/dist/sessions/cronSchedule.js +172 -0
  45. package/dist/sessions/cronSchedule.js.map +1 -0
  46. package/dist/sessions/heartbeat.d.ts +44 -3
  47. package/dist/sessions/heartbeat.js +44 -4
  48. package/dist/sessions/heartbeat.js.map +1 -1
  49. package/dist/sessions/sessionSearch.js +9 -0
  50. package/dist/sessions/sessionSearch.js.map +1 -1
  51. package/dist/shared/clientBuild.d.ts +1 -1
  52. package/dist/shared/clientBuild.js +1 -1
  53. package/dist/shared/crossSession.d.ts +10 -3
  54. package/dist/shared/crossSession.js +105 -1
  55. package/dist/shared/crossSession.js.map +1 -1
  56. package/dist/shared/localCommandModelCopy.d.ts +2 -0
  57. package/dist/shared/localCommandModelCopy.js +43 -0
  58. package/dist/shared/localCommandModelCopy.js.map +1 -0
  59. package/dist/shared/narration.d.ts +7 -0
  60. package/dist/shared/narration.js +119 -0
  61. package/dist/shared/narration.js.map +1 -0
  62. package/dist/shared/pastedContent.d.ts +7 -0
  63. package/dist/shared/pastedContent.js +113 -0
  64. package/dist/shared/pastedContent.js.map +1 -0
  65. package/dist/shared/systemNotice.js +4 -1
  66. package/dist/shared/systemNotice.js.map +1 -1
  67. package/package.json +3 -3
@@ -5,7 +5,8 @@
5
5
  import { execFile } from "node:child_process";
6
6
  import { PROTOCOL_V1 } from "../protocol.js";
7
7
  import { SessionMetadataStore, validateSessionName } from "../sessions/sessionMetadataStore.js";
8
- import { normalizeForTextMatch } from "../shared/invisibleText.js";
8
+ import { normalizeForTextMatch, stripInvisibleForComparison } from "../shared/invisibleText.js";
9
+ import { unwrapPastedContent } from "../shared/pastedContent.js";
9
10
  /** tmux 実行ファイルの既定絶対パス(PATH 外のため絶対指定)。 */
10
11
  export const DEFAULT_TMUX_PATH = "/opt/homebrew/bin/tmux";
11
12
  /** pane_current_command がこの集合なら、Claude 本体は終了してシェルだけが残っている。 */
@@ -27,7 +28,12 @@ const INPUT_PROMPT_SIGILS = ["❯", "›", "!"];
27
28
  export function inputBoxIsShellMode(box) {
28
29
  return box?.prompt === "!";
29
30
  }
30
- /** 行が入力欄の水平罫線(`────…`)か。名前付き会話は上罫線中央にタイトルが埋まる。 */
31
+ /**
32
+ * 行(trim 済み)が水平罫線(`────…`)か。タイトルの埋まった罫線は、行末にも罫線が 2 個以上
33
+ * 続くものだけを認める(以前の版の形 `──── <タイトル> ──`)。**変更前から同じ規則。広げない**:
34
+ * 行末の罫線 1 個を認めると、入力欄の下に出る statusLine の行(` ───── haiku │ main ─`)を
35
+ * 枠と読む。
36
+ */
31
37
  function isInputBoxRuleLine(line) {
32
38
  const scalars = [...line];
33
39
  if (scalars.length < 3)
@@ -52,10 +58,86 @@ function isInputBoxRuleLine(line) {
52
58
  }
53
59
  return trailing >= 2;
54
60
  }
61
+ /** 行が、行頭(0 桁目)から行末まで罫線だけか(入力欄の下の枠)。 */
62
+ function isPureFrameLine(rawLine) {
63
+ return /^[─━]{3,}\s*$/u.test(rawLine);
64
+ }
65
+ /**
66
+ * 入力欄の枠(上下 2 本の水平罫線)の位置。`lines` は trim していない行(SGR は除去済み)。
67
+ *
68
+ * 探す順:
69
+ * 1. 下の枠からプロンプトの行まで辿り、そのすぐ上の行を上の枠にする(`findFrameAbovePrompt`)。
70
+ * 入力欄の形そのものを辿るので、入力欄の中の罫線だけの行・履歴の罫線・上の枠に埋まった
71
+ * タイトルに左右されない
72
+ * 2. 1 で辿れない画面(tmux の画面が崩れて下の枠が消えた・字下げされた写しなど)は、
73
+ * **変更前と同じ規則**: trim した行を `isInputBoxRuleLine` で探し、末尾側の 2 本を枠にする
74
+ *
75
+ * 1 と 2 の間に「行頭から罫線が 3 個以上続く行 2 本」を探す段を置いていたが、取り除いた。
76
+ * 履歴に見えている行頭からの罫線を枠と取り違える(スラッシュコマンドの控えは、折り返した
77
+ * 続きの行が字下げされない。実測 2.1.285: `/rename <長い名前> ─────── tail` の控えの 2 行目が
78
+ * `─────── tail`)。タイトルの長い会話と、画面が崩れて下の枠が消えた tmux の pane で、空の
79
+ * 入力欄を「履歴が残っている」と読み、その行が画面の外へ流れるまで送信がすべて拒否された
80
+ * (どちらも変更前は届いていた)。1 で辿れない画面の読み方は、変更前から変えない。
81
+ */
82
+ function findInputBoxFrame(lines) {
83
+ const framed = findFrameAbovePrompt(lines);
84
+ if (framed !== null)
85
+ return framed;
86
+ let bottom = -1;
87
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
88
+ if (isInputBoxRuleLine((lines[index] ?? "").trim())) {
89
+ bottom = index;
90
+ break;
91
+ }
92
+ }
93
+ for (let index = bottom - 1; index >= 0; index -= 1) {
94
+ if (isInputBoxRuleLine((lines[index] ?? "").trim()))
95
+ return { top: index, bottom };
96
+ }
97
+ return null;
98
+ }
99
+ /** 行(trim していない)が、入力欄の 1 行目の形(行頭がプロンプト記号)か。 */
100
+ function startsWithInputPrompt(rawLine) {
101
+ return INPUT_PROMPT_SIGILS.some((sigil) => rawLine.startsWith(sigil));
102
+ }
103
+ /**
104
+ * 下の枠から辿って見つける入力欄の枠。下の枠(行頭から行末まで罫線だけの、最後の行)から上へ、
105
+ * 入力欄の本文の続きの行(字下げ・空行)を辿り、プロンプト記号で始まる行(入力欄の 1 行目)の
106
+ * **すぐ上の行**を上の枠とする。その行が罫線で終わっていなければ、入力欄とは見なさない。
107
+ *
108
+ * 入力欄の形を最後まで確かめられたときだけ枠を返す。確かめられない画面は null を返し、呼び出し
109
+ * 側が変更前と同じ規則で読む。
110
+ *
111
+ * 上の枠の行頭の形を問わないので、タイトルが長い名前付きの会話でも読める。タイトルの表示幅が
112
+ * 「pane の幅 − 5」以上になると、上の罫線は行頭の罫線が 3 個に届かない(`── <タイトル> ─` /
113
+ * `─ <タイトル> ─` / ` <タイトル>… ─`。実測 2.1.284)。
114
+ */
115
+ function findFrameAbovePrompt(lines) {
116
+ let bottom = -1;
117
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
118
+ if (isPureFrameLine(lines[index] ?? "")) {
119
+ bottom = index;
120
+ break;
121
+ }
122
+ }
123
+ // 下の枠のすぐ下は、ボトムバーか statusLine(字下げ)か空行。プロンプト記号で始まる行が
124
+ // 続くなら、見つけた罫線は下の枠ではない(tmux の画面が崩れて下の枠が消え、上の枠を拾った)。
125
+ if (startsWithInputPrompt(lines[bottom + 1] ?? ""))
126
+ return null;
127
+ let index = bottom - 1;
128
+ while (index >= 0 && /^(?: {2}|\s*$)/u.test(lines[index] ?? ""))
129
+ index -= 1;
130
+ if (index < 1)
131
+ return null;
132
+ const first = lines[index] ?? "";
133
+ if (!startsWithInputPrompt(first))
134
+ return null;
135
+ return /[─━]\s*$/u.test(lines[index - 1] ?? "") ? { top: index - 1, bottom } : null;
136
+ }
55
137
  /**
56
138
  * 画面から claude TUI の入力欄を取り出す(TESTABLE)。
57
139
  *
58
- * 入力欄は末尾側の 2 本の水平罫線に挟まれた領域。`❯` 行だけを探す旧実装は
140
+ * 入力欄は上下 2 本の水平罫線に挟まれた領域(枠の探し方は `findInputBoxFrame`)。`❯` 行だけを探す旧実装は
59
141
  * シェルモード(プロンプトが `!` になる)で入力欄を見失い、注入検証が必ず失敗
60
142
  * → 本文を 3 回重ね打ちして入力欄を壊し、送信失敗として throw していた
61
143
  * (実障害 2026-08-03: `!` 始まりの送信が HerdrFailedError)。
@@ -70,23 +152,11 @@ function isInputBoxRuleLine(line) {
70
152
  * herdr は加えて注入前に `selectionDialogVisible` → Esc でダイアログを閉じる。
71
153
  */
72
154
  export function extractClaudeInputBox(screen) {
73
- const lines = screen.split("\n").map((line) => line.trim());
74
- let bottom = -1;
75
- for (let index = lines.length - 1; index >= 0; index -= 1) {
76
- if (isInputBoxRuleLine(lines[index] ?? "")) {
77
- bottom = index;
78
- break;
79
- }
80
- }
81
- let top = -1;
82
- for (let index = bottom - 1; index >= 0; index -= 1) {
83
- if (isInputBoxRuleLine(lines[index] ?? "")) {
84
- top = index;
85
- break;
86
- }
87
- }
88
- if (top >= 0 && bottom > top)
89
- return splitInputPrompt(lines.slice(top + 1, bottom));
155
+ const raw = screen.split("\n").map((line) => line.replace(/\r$/, ""));
156
+ const lines = raw.map((line) => line.trim());
157
+ const frame = findInputBoxFrame(raw);
158
+ if (frame !== null)
159
+ return splitInputPrompt(lines.slice(frame.top + 1, frame.bottom));
90
160
  // 罫線が無い画面は最後の `❯` 行 1 行だけを入力欄とみなす(下の行まで含めると
91
161
  // フッターを未送信テキストと誤認して送信確定ループが終わらない)。
92
162
  for (let index = lines.length - 1; index >= 0; index -= 1) {
@@ -169,6 +239,448 @@ export function inputBoxRealText(ansiScreen) {
169
239
  return null;
170
240
  return analysis.faintOnly ? "" : analysis.text;
171
241
  }
242
+ /**
243
+ * CLI が 1 回に読み取る入力の上限(バイト)。macOS の pty は 1022(実測 2.1.284: tmux / herdr とも)。
244
+ * Linux の pty は 1 回で約 4095 バイトまで返しうる(未実測。広い側に倒す)。
245
+ */
246
+ export const PASTE_READ_BYTES = process.platform === "darwin" ? 1022 : 4095;
247
+ /**
248
+ * 1 回の読み取りがこの字数に達する本文は、貼り付けとして渡す。CLI が貼り付けと判定する境界
249
+ * (実測: 800 字はそのまま、801 字で貼り付け)より手前に取る。
250
+ */
251
+ const PASTE_RISK_CHARS = 700;
252
+ /**
253
+ * 1 回で打つと壊れる本文か(long-text-paste, TESTABLE)。真なら小分けの貼り付けで渡す。
254
+ *
255
+ * CLI(実測 2.1.284)は **1 回の読み取りで 800 字を超える入力**を、打鍵ではなく貼り付けとして
256
+ * 扱う。数えるのは文字数で、1 回の読み取りは 1022 バイトで頭打ちになる(macOS)。だから日本語
257
+ * (1 字 3 バイト = 約 340 字)は何千字でも貼り付けにならず、英数字中心の本文だけがなる。意図しない
258
+ * 貼り付けは、本文が読み取りの境界で分かれて届く:
259
+ * - 入力欄が `[Pasted text #2][Pasted text #3]残り` になり、包み(`<pasted_content>`)が本文の
260
+ * 途中に入る
261
+ * - 直前の貼り付けの案内が出ている間は、先頭の読み取り分(1022 字)が入力欄に入らない
262
+ * (1500 字を送って記録されたのは末尾 478 字)
263
+ * - herdr の反映検証は `[Pasted text #N]` を「打鍵が届いていない」と判定して送信を失敗させる
264
+ *
265
+ * もう 1 つ、**タブを含む本文**。打ったタブは入力欄に入らない(実測: `"X1\tA\tB"` → `X1AB`。
266
+ * 字下げが消える)。複数回の読み取りに分かれると、次の読み取り分が前の分の途中へ挿入される。
267
+ * 貼り付けたタブは空白 4 個になって残る。
268
+ *
269
+ * 同じく、**打つとキー操作として効く文字**: CR は Enter になり、本文がそこで送信されて 2 通に
270
+ * 分かれる。DEL は Backspace になり、直前の 1 字が消える(実測 2.1.284。変更前からの挙動)。
271
+ * 貼り付けなら、CR / U+2028 は改行になり、制御文字は落ちる(`normalizeTextForPaste`)。
272
+ *
273
+ * どれでもない本文(短い本文 / 日本語中心でタブの無い長文)は、従来どおり打つ。
274
+ */
275
+ /** 打つとキー操作として効く文字(タブ・CR・DEL・その他の制御文字。LF と ESC は除く)と U+2028 / U+2029。 */
276
+ const TYPED_AS_KEYS = /[\u0000-\u0009\u000b-\u001a\u001c-\u001f\u007f-\u009f\u2028\u2029]/;
277
+ export function typedTextWouldBreak(text, readBytes = PASTE_READ_BYTES) {
278
+ if (TYPED_AS_KEYS.test(text))
279
+ return true;
280
+ let start = 0;
281
+ let windowBytes = 0;
282
+ let windowChars = 0;
283
+ const bytes = [];
284
+ const chars = [];
285
+ for (const point of text) {
286
+ const size = Buffer.byteLength(point);
287
+ bytes.push(size);
288
+ chars.push(point.length);
289
+ windowBytes += size;
290
+ windowChars += point.length;
291
+ while (windowBytes > readBytes) {
292
+ windowBytes -= bytes[start] ?? 0;
293
+ windowChars -= chars[start] ?? 0;
294
+ start += 1;
295
+ }
296
+ if (windowChars >= PASTE_RISK_CHARS)
297
+ return true;
298
+ }
299
+ return false;
300
+ }
301
+ /** tmux の引数で渡せる本文の上限(バイト)。約 16KB で `command too long` になる手前に取る。 */
302
+ const TMUX_TYPED_MAX_BYTES = 12_000;
303
+ /** tmux で 1 回で打つと壊れる本文か(TESTABLE)。引数の上限を超える本文も貼り付けで渡す。 */
304
+ export function tmuxTypedTextWouldBreak(text) {
305
+ return typedTextWouldBreak(text) || Buffer.byteLength(text) > TMUX_TYPED_MAX_BYTES;
306
+ }
307
+ /** 括弧付き貼り付けの開始・終了(端末の bracketed paste)。 */
308
+ export const BRACKETED_PASTE_START = "\u001b[200~";
309
+ export const BRACKETED_PASTE_END = "\u001b[201~";
310
+ /**
311
+ * 貼り付けとして渡せる本文か。ESC を含む本文は、途中の `ESC[201~` で貼り付けが終わり、残りが
312
+ * 打鍵として解釈されうるので渡さない(従来どおり打つ)。
313
+ */
314
+ export function textIsPasteSafe(text) {
315
+ return !text.includes("\u001b");
316
+ }
317
+ /**
318
+ * 入力反映検証に使う probe(本文の**末尾側**)。検証不能な本文(空など)は null。
319
+ *
320
+ * 末尾側なのは composer の表示特性のため: 入力が表示高を超えると composer は下へ
321
+ * スクロールし**先頭行が窓外へ消える**(実測 2.1.220: 10行ペーストで先頭4行が
322
+ * capture から消失)。カーソルは常に末尾にあるので、末尾側の probe だけが
323
+ * 「見えている範囲」との照合を保証できる(先頭24字の旧 probe は多行/長文で
324
+ * 構造的に偽陰性 → 再投入 → 本文二重化の温床だった)。
325
+ *
326
+ * 先頭 `!`(シェルモード)は claude TUI がモード記号として吸い上げ、入力欄本文には
327
+ * 残らない(`!ls -la` は `! ls -la` と描画される)。単一行本文では照合キーからも
328
+ * 落とさないと末尾24字に `!` が含まれるとき反映検証が失敗する。
329
+ */
330
+ export function typedTextProbe(text) {
331
+ // 不可視文字は端末のセルに載らない(capture に出ない)ため、probe に混ざると反映検証が
332
+ // 構造的に偽陰性になる。probe は不可視文字を除いた可視本文から取る
333
+ // (照合側 `inputBoxTextIncludesProbe` も両辺から落とすので規則は一致する)。
334
+ const lines = stripInvisibleForComparison(text)
335
+ .split("\n")
336
+ .map((line) => line.trim())
337
+ .filter((line) => line.length > 0);
338
+ const lastLine = lines.at(-1);
339
+ if (lastLine === undefined)
340
+ return null;
341
+ const body = lines.length === 1 && lastLine.startsWith("!") ? lastLine.slice(1).trim() : lastLine;
342
+ if (body.length === 0)
343
+ return null;
344
+ return body.slice(-24);
345
+ }
346
+ /**
347
+ * 入力欄テキストに probe が反映されているか(折り返し非依存)。
348
+ * extractClaudeInputBox は表示行を trim + "\n" 連結で返すため、probe が入力欄の
349
+ * 行折り返しをまたぐと生の includes は絶対に一致しない(全角24字=48桁 > 内幅で必発)。
350
+ * この偽陰性が「反映済み本文の再投入 = 初回送信の本文二重化」の根因だった(実機5件)。
351
+ * 空白類(折り返しの改行・trim 痕・全角空白含む)と不可視文字を両辺から除去して照合する。
352
+ *
353
+ * 結合文字は両辺を NFC にそろえる。端末には合成済みの形で映るので(`e` + U+0301 → `é`、
354
+ * `か` + U+3099 → `が`)、分解形(macOS 由来の文字列に多い)のままの probe は一致しない
355
+ * (独立検証が実測: 結合文字を含む本文の反映検証が必ず失敗した)。
356
+ */
357
+ export function inputBoxTextIncludesProbe(boxText, probe) {
358
+ const needle = normalizeForTextMatch(probe.normalize("NFC"));
359
+ if (needle.length === 0)
360
+ return false;
361
+ return normalizeForTextMatch(boxText.normalize("NFC")).includes(needle);
362
+ }
363
+ /**
364
+ * 1 つの貼り付けに入れる本文の上限(TESTABLE)。CLI(実測 2.1.284)は、1 つの貼り付けが
365
+ * **800 字以下で改行 2 個以下**なら、畳まずに本文のまま入力欄へ入れる(801 字・改行 3 個から
366
+ * `[Pasted text #N +M lines]` に畳む。字数は UTF-16 の単位で数える)。境界より手前に取る。
367
+ */
368
+ export const INLINE_PASTE_MAX_CHARS = 500;
369
+ export const INLINE_PASTE_MAX_NEWLINES = 1;
370
+ /**
371
+ * 1 つの貼り付けのバイト数の上限。括弧(12 バイト)を足しても、CLI の 1 回の読み取り(1022 バイト)と
372
+ * pty の入力の待ち行列に収まる大きさにする。まとめて書き込むと、CLI が読み進めるのを待たされる
373
+ * (実測 herdr: 8KB を 1 回で書くと、入り切るまで 4〜7 秒。1 つずつなら 0.3 秒)。
374
+ */
375
+ export const INLINE_PASTE_MAX_BYTES = 900;
376
+ /**
377
+ * 貼り付ける前に、CLI が記録するときの形へそろえる(TESTABLE)。CLI(実測 2.1.284)は貼り付けの
378
+ * CR / CRLF / U+2028 を LF にし、制御文字(DEL・U+009B など)を落とす。先にそろえておくと、
379
+ * 入力欄に映る本文と照合できる(そろえないと、これらを含む本文は毎回「入らなかった」と判定される)。
380
+ * タブと LF は残す。ESC を含む本文は貼り付けない(`textIsPasteSafe`)。
381
+ */
382
+ export function normalizeTextForPaste(text) {
383
+ return text
384
+ .replace(/\r\n?|[\u2028\u2029]/g, "\n")
385
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
386
+ }
387
+ /**
388
+ * 拡張子の「.」の直後(`shot-1.` | `png`)。後ろが拡張子らしい語(英数字 2〜5 字で、語の終わり)の
389
+ * 「.」だけを拾う。
390
+ */
391
+ const EXTENSION_DOT = /\.(?=[0-9A-Za-z]{2,5}(?:["'\s]|$))/g;
392
+ /**
393
+ * 本文を、畳まれずに入力欄へ入る大きさの貼り付けに分ける(long-text-paste, TESTABLE)。
394
+ * つなげると元の本文に戻る。
395
+ *
396
+ * - 1 つは `INLINE_PASTE_MAX_CHARS` 字・`INLINE_PASTE_MAX_BYTES` バイト・改行
397
+ * `INLINE_PASTE_MAX_NEWLINES` 個まで
398
+ * - 書記素の途中では分けない(結合文字・ZWJ でつないだ絵文字・サロゲートの対)
399
+ * - **拡張子の付いた語を、1 つの貼り付けの中に丸ごと入れない**。CLI は、貼り付けの中の実在する
400
+ * 画像のパスを `[Image #N]` の添付に置き換える(実測 2.1.284: 貼り付けの全体がパスのとき。
401
+ * パスが 2 つ以上並ぶときは、後ろに本文が続いていても置き換わり、語の順序まで変わる)。
402
+ * アプリは添付のパスを本文の前に空白区切りで並べる(`p1 p2 本文`)。拡張子の「.」の直後で
403
+ * 分けると、前半は拡張子が無く、後半はパスの形をしていない
404
+ */
405
+ export function splitForInlinePaste(text) {
406
+ const pieces = [];
407
+ let current = "";
408
+ let bytes = 0;
409
+ let newlines = 0;
410
+ const flush = () => {
411
+ if (current === "")
412
+ return;
413
+ let start = 0;
414
+ for (const match of current.matchAll(EXTENSION_DOT)) {
415
+ pieces.push(current.slice(start, match.index + 1));
416
+ start = match.index + 1;
417
+ }
418
+ if (start < current.length)
419
+ pieces.push(current.slice(start));
420
+ current = "";
421
+ bytes = 0;
422
+ newlines = 0;
423
+ };
424
+ for (const { segment } of GRAPHEMES.segment(text)) {
425
+ const breaks = segment === "\n" ? 1 : 0;
426
+ const size = Buffer.byteLength(segment);
427
+ if (current.length + segment.length > INLINE_PASTE_MAX_CHARS ||
428
+ bytes + size > INLINE_PASTE_MAX_BYTES ||
429
+ newlines + breaks > INLINE_PASTE_MAX_NEWLINES) {
430
+ flush();
431
+ }
432
+ current += segment;
433
+ bytes += size;
434
+ newlines += breaks;
435
+ // 改行で終わる形にそろえる(行の途中から始まる貼り付けを作らない)。
436
+ if (newlines >= INLINE_PASTE_MAX_NEWLINES)
437
+ flush();
438
+ }
439
+ flush();
440
+ return pieces;
441
+ }
442
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });
443
+ /** ボトムバーの位置に `paste again to expand` が出ているか(常駐 TUI 行は読み飛ばす, TESTABLE)。 */
444
+ export function screenShowsPasteHint(screen) {
445
+ const lines = screen
446
+ .split("\n")
447
+ .map((line) => stripSgr(line).trim())
448
+ .filter((line) => line !== "");
449
+ let index = lines.length - 1;
450
+ while (index >= 0 && isTrailingTuiLine(lines[index] ?? ""))
451
+ index -= 1;
452
+ return lines[index] === PASTE_EXPAND_HINT;
453
+ }
454
+ /** 照合用にそろえる: 結合文字は合成済み、空白類・不可視文字・制御文字は落とす。 */
455
+ function normalizePastedForMatch(value) {
456
+ return normalizeForTextMatch(value.normalize("NFC")).replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
457
+ }
458
+ /**
459
+ * 入力欄が長い本文の中ほどを置き換えて映す表示(実測 2.1.284)。入力欄の本文が約 10000 字を
460
+ * 超えると、CLI は「先頭約 500 字 + `[...Truncated text #N +M lines...]` + 末尾」の形で映す
461
+ * (表示だけ。送信される本文は全文)。語の途中でも置き換わる。
462
+ */
463
+ const TRUNCATED_DISPLAY = /\[\.\.\.Truncated text #\d+(?: \+\d+ lines)?\.\.\.\]/g;
464
+ /**
465
+ * 入力欄に映っている本文が、`text` の一部として順に現れるか(TESTABLE)。
466
+ * 置き換えの表示(`TRUNCATED_DISPLAY`)で区切られた各部分が、`text` の中にこの順で現れることを
467
+ * 確かめる。`anchoredAtEnd` なら、最後の部分は `text` の末尾と同文でなければならない。
468
+ * 一致した字数を返す(一致しなければ -1)。照合は空白類・不可視文字を落とした形で行う。
469
+ */
470
+ export function matchInputBoxAgainstText(boxText, text, anchoredAtEnd) {
471
+ const body = normalizePastedForMatch(text);
472
+ // 本文そのものが置き換えの表示と同じ文言を含むことがある。先に、そのままの形で照合する。
473
+ const whole = normalizePastedForMatch(boxText);
474
+ if (whole.length > 0 && (anchoredAtEnd ? body.endsWith(whole) : body.includes(whole)))
475
+ return whole.length;
476
+ const parts = boxText.split(TRUNCATED_DISPLAY).map(normalizePastedForMatch);
477
+ let cursor = 0;
478
+ let matched = 0;
479
+ for (const [index, part] of parts.entries()) {
480
+ const last = index === parts.length - 1;
481
+ if (part.length === 0) {
482
+ // 置き換えの表示で終わる入力欄は、末尾を確かめられない。
483
+ if (last && anchoredAtEnd && parts.length > 1)
484
+ return -1;
485
+ continue;
486
+ }
487
+ if (last && anchoredAtEnd) {
488
+ if (!body.endsWith(part) || body.length - part.length < cursor)
489
+ return -1;
490
+ }
491
+ else {
492
+ const at = body.indexOf(part, cursor);
493
+ if (at === -1)
494
+ return -1;
495
+ cursor = at + part.length;
496
+ }
497
+ matched += part.length;
498
+ }
499
+ return matched === 0 ? -1 : matched;
500
+ }
501
+ /**
502
+ * 貼り付けた本文が入力欄に入ったか(TESTABLE)。**入力欄に映っている全体が、本文の末尾と同文**
503
+ * であることで確かめる(入力欄は長い本文の末尾側だけを映す)。pty は先入れ先出しなので、最後の
504
+ * 貼り付けが入っていれば、その前の貼り付けも CLI が読み終えている。
505
+ * 約 10000 字を超える本文は、中ほどが置き換えの表示になる(`matchInputBoxAgainstText`)。
506
+ *
507
+ * 末尾の数十字だけを探す照合にはしない。各行の終わりが同じ文言の本文(表・ログ)だと、途中まで
508
+ * 入った時点で一致してしまう。CLI の処理が止まっている最中にそうなると、続けて打つ Enter が
509
+ * 貼り付けの残りと一緒に読まれて改行として本文に入り、送信されないまま入力欄に残る(実測
510
+ * 2.1.284: 30 行の本文を 11 行まで入った時点で止めた)。
511
+ */
512
+ export function pastedTextArrived(boxText, text) {
513
+ const matched = matchInputBoxAgainstText(boxText, text, true);
514
+ // 先頭の `!`(シェルモード)は CLI がモード記号として吸い上げ、入力欄の本文には残らない。
515
+ return matched >= Math.min(24, normalizePastedForMatch(text).replace(/^!/, "").length) && matched > 0;
516
+ }
517
+ /**
518
+ * 入力欄に映っているのが、貼り付けた本文の一部か(TESTABLE)。貼り付けが途中まで入った形
519
+ * (CLI がまだ読んでいる / 処理が止まっている)を、別の中身と見分ける。
520
+ */
521
+ export function pastedTextPartlyArrived(boxText, text) {
522
+ return matchInputBoxAgainstText(boxText, text, false) > 0;
523
+ }
524
+ /** 貼り付けが入力欄に入るのを待つ上限と、確かめる間隔の既定(ms)。実測は 0.3 秒以内に入る。 */
525
+ export const PASTED_TEXT_TIMEOUT_MS = 3_000;
526
+ /**
527
+ * tmux で待つ上限の既定(ms)。tmux は確かめられなくても送信確定へ進むので、長く待つ意味が薄い
528
+ * (画面が崩れて読めない間は、貼り付けで渡す送信のたびにこの時間がかかる)。入っている途中なら延ばす。
529
+ */
530
+ export const PASTED_TEXT_TIMEOUT_TMUX_MS = 1_500;
531
+ /**
532
+ * 入っている途中なら待つ時間を延ばす。その上限(`timeoutMs` の何倍まで。既定で 9 秒)。
533
+ * 注入の全体(前後の確認 約 1 秒・貼り付けの送出 約 1 秒・送信確定 最大 3.4 秒を含む)を、
534
+ * アプリが応答を待つ 18 秒の内側に収める。herdr は、まれに入り切るまで数秒かかる(実測:
535
+ * 10000 字が 7.5 秒で入り切らなかった回が 40 回に 1 回)。
536
+ */
537
+ const PASTED_TEXT_MAX_EXTENSIONS = 3;
538
+ export const PASTED_TEXT_POLL_MS = 100;
539
+ /** C-u を 1 回にまとめて打つ数(貼り付けと判定される大きさには遠く届かない)。 */
540
+ const TYPED_TEXT_KILL_BATCH = 40;
541
+ /**
542
+ * C-u をまとめて打った後、入力欄が変わるのを待つ上限(ms)。長い本文は、1 回消すたびに入力欄の
543
+ * 描き直しがかかり、40 回ぶんの反映に 1 秒以上かかる(実測 herdr: 10000 字の本文。150ms おきに
544
+ * 3 回見て変わらなければ諦める作りでは、消している途中で諦め、先頭の 468 字が残った)。
545
+ */
546
+ export const TYPED_TEXT_CLEAR_SETTLE_MS = 1_500;
547
+ /** まとめて打った後、入力欄を撮り直す間隔の上限(ms)。 */
548
+ const CLEAR_POLL_MS = 50;
549
+ /** まとめて打っても入力欄が変わらない回が続いたら諦める回数。 */
550
+ const TYPED_TEXT_CLEAR_IDLE_LIMIT = 2;
551
+ /**
552
+ * 入れかけの長い本文を入力欄から消す(TESTABLE)。空にできたら true。
553
+ *
554
+ * C-u(kill-line)は 1 回で 1 行ぶんしか消えない: 多行の本文は末尾行 → その改行の順に消え、
555
+ * N 行なら 2N-1 回かかる(実測 2.1.220)。回数を固定(`clearInputBox` は 15 回)にすると
556
+ * 9 行以上の本文は途中までしか消えない。40 回ずつまとめて打ち、**入力欄が空になるまで、
557
+ * 進んでいる限り続ける**(上限は本文の大きさから決める)。まとめて打った後は、入力欄が変わるのを
558
+ * `clearSettleMs` まで待つ。2 回続けて変わらなければ諦める(同じ行が並ぶ本文は、消えていても
559
+ * 映っている範囲が同じに見えるので、1 回では諦めない)。空の入力欄への C-u は何もしない。
560
+ * 入力欄のフレームと確かめられないとき(ダイアログ・判別不能)は 1 キーも打たない。
561
+ */
562
+ export async function clearTypedInput(text, io) {
563
+ const maxKills = 2 * text.split("\n").length + Math.ceil(Array.from(text).length / 20) + TYPED_TEXT_KILL_BATCH;
564
+ const settleMs = io.clearSettleMs ?? TYPED_TEXT_CLEAR_SETTLE_MS;
565
+ let screen = await io.capture();
566
+ let idle = 0;
567
+ for (let sent = 0;; sent += TYPED_TEXT_KILL_BATCH) {
568
+ const frame = classifySubmitFrame(screen);
569
+ if (frame === "submitted")
570
+ return true;
571
+ if (screen === null || frame !== "pending" || sent >= maxKills)
572
+ return false;
573
+ const before = inputBoxRealText(screen);
574
+ await io.sendKills(TYPED_TEXT_KILL_BATCH);
575
+ // 打った分が効き終わるのを待つ: 入力欄が変わり始め、その後 3 回続けて同じになるまで。
576
+ // 効いている途中で次を打つと、上限の回数を先に使い切る(消し切る前に諦める)。
577
+ const deadline = Date.now() + settleMs;
578
+ let changed = false;
579
+ let previous = before;
580
+ let quiet = 0;
581
+ for (let polls = 0; quiet < 3 && (changed || polls < 3 || Date.now() < deadline); polls += 1) {
582
+ await new Promise((resolve) => setTimeout(resolve, Math.min(io.clearDelayMs, CLEAR_POLL_MS)));
583
+ screen = await io.capture();
584
+ if (classifySubmitFrame(screen) !== "pending") {
585
+ changed = true;
586
+ break;
587
+ }
588
+ const now = inputBoxRealText(screen ?? "");
589
+ if (now !== before)
590
+ changed = true;
591
+ quiet = changed && now === previous ? quiet + 1 : 0;
592
+ previous = now;
593
+ }
594
+ idle = changed ? 0 : idle + 1;
595
+ if (idle >= TYPED_TEXT_CLEAR_IDLE_LIMIT)
596
+ return false;
597
+ }
598
+ }
599
+ /**
600
+ * 行数の分からない残存を消すときの上限(`clearTypedInput` の本文の代わりに渡す)。
601
+ * 400 行ぶん(C-u 約 800 回)まで。
602
+ */
603
+ export const CLEAR_UNKNOWN_INPUT_BOUND = "\n".repeat(400);
604
+ /** 貼り付けの送出が途中で失敗し、入った分を入力欄から消した(= 非配達が確定)ときの拒否文言。 */
605
+ export const CHAT_BLOCKED_BY_UNTYPED_TEXT = "長いメッセージを入力欄へ正しく入力できなかったため、送信しませんでした(もう一度送ってください)";
606
+ /**
607
+ * 注入を始めてから、貼り付けが入ったのを確かめ終えるまでに使ってよい時間(ms)。アプリは応答を
608
+ * 18 秒待つ。この後の送信確定(最大で tmux 3.4 秒 / herdr 5.2 秒)を引いた残りに収める。
609
+ */
610
+ export const INJECT_VERIFY_BUDGET_MS = 12_000;
611
+ /**
612
+ * 本文を、小分けの括弧付き貼り付けで入力欄へ入れる(tmux / herdr 共通, long-text-paste)。
613
+ *
614
+ * ```
615
+ * 小分けにして順に貼り付ける(splitForInlinePaste)
616
+ * 100ms ごとに最大 3 秒、入力欄に入ったのを確かめる(pastedTextArrived)→ `arrived`
617
+ * (入っている途中で、進んでいる間は延ばす。最大 9 秒。注入を始めてから 12 秒まで)
618
+ * 確かめられなかった → `unverified`(1 キーも打たない。どう扱うかは backend が決める)
619
+ * 貼り付けの送出が途中で失敗した:
620
+ * 入った分が入力欄に映っている → 消す(clearTypedInput)。空にできたら
621
+ * ChatInjectionRejectedError(非配達が確定)
622
+ * それ以外 → 送出のエラーをそのまま返す
623
+ * ```
624
+ *
625
+ * 貼り付けは打鍵と違い、読み取りの境界・タブ・結合文字で壊れない。1 つが 800 字以下・改行 2 個
626
+ * 以下なら CLI は畳まずに本文のまま入力欄へ入れるので、**打った場合と同じ本文が記録される**
627
+ * (`<pasted_content>` の包みが付かない)。
628
+ *
629
+ * **確かめられなかったときの扱いは、本文を打つ場合(変更前からの経路)と同じにする**:
630
+ * - tmux: そのまま送信確定へ進む(Enter を押し、成立を確認できなければ `onUnconfirmedSubmit`)。
631
+ * 画面を読めないだけのことがある(実測: 肌色付きの ✌🏽 などを含む発話の後、tmux の画面が
632
+ * 崩れて入力欄が 1 行ずれる)。Enter は貼り付けの後ろに並ぶので、途中までの本文が送信される
633
+ * ことは無い
634
+ * - herdr: 失敗として返す(打つ場合の反映検証と同じ。Remote Control 切断直後は入力が捨てられ、
635
+ * 確かめずに進むと「送信成立」と誤判定する)
636
+ *
637
+ * 採らなかった方式:
638
+ * - **小分けにして打つ**: タブのカーソルずれ・処理の停止・結合文字で、順序の崩れた本文や先頭が
639
+ * 重複した本文が黙って届いた(独立検証が実 claude で再現)。
640
+ * - **全文を 1 つの貼り付けで渡す**: 入力欄は `[Pasted text #N +M lines]` になり、本文は包まれて
641
+ * 記録される。確かめる根拠が改行の数しか無く、同じ行数の別の貼り付け(Mac 側の下書き)と
642
+ * 見分けられない。包みは Claude に「利用者が書いたとは限らない内容」と伝えるためのもので、
643
+ * 利用者の指示の全体をその扱いにしてしまう。会話の一覧・タイトル・検索にも包みが出る。
644
+ * - **確かめられなかった本文を控えて、後から入った分を消す**(控え・確認用の印): CLI が止まった
645
+ * ときの「ちょうど 1 通」は守れるが、画面の読み取りに頼る判定が増え、それ自体が誤配送と
646
+ * 送信不能の源になった(独立検証 5 周: 印が発話として届く・下書きを削る・画面が崩れると
647
+ * 以後の送信がすべて拒否される)。
648
+ */
649
+ export async function pasteTextInline(text, io) {
650
+ const body = normalizeTextForPaste(text);
651
+ try {
652
+ await io.paste(splitForInlinePaste(body));
653
+ }
654
+ catch (error) {
655
+ // 途中まで入った本文を残すと、次の送信の冒頭で断片が独立した発話として送られる。
656
+ const screen = await io.capture();
657
+ const box = screen === null ? null : inputBoxRealText(screen);
658
+ if (classifySubmitFrame(screen) === "pending" && box !== null && pastedTextPartlyArrived(box, body) &&
659
+ (await clearTypedInput(body, io))) {
660
+ throw new ChatInjectionRejectedError(CHAT_BLOCKED_BY_UNTYPED_TEXT);
661
+ }
662
+ throw error;
663
+ }
664
+ const started = Date.now();
665
+ const hardDeadline = Math.min(started + io.timeoutMs * PASTED_TEXT_MAX_EXTENSIONS, io.verifyDeadlineMs ?? Number.POSITIVE_INFINITY);
666
+ let deadline = Math.min(started + io.timeoutMs, hardDeadline);
667
+ let previous = null;
668
+ for (;;) {
669
+ await new Promise((resolve) => setTimeout(resolve, io.pollMs));
670
+ const screen = await io.capture();
671
+ const box = screen === null ? null : inputBoxRealText(screen);
672
+ if (box !== null && pastedTextArrived(box, body))
673
+ return "arrived";
674
+ // 入っている途中(入力欄が本文の一部で、前回から進んだ)なら、待つ時間を延ばす。CLI は
675
+ // 続けて届いた貼り付けを、1 つずつ時間をかけて取り込むことがある。
676
+ if (box !== null && box !== previous && previous !== null && pastedTextPartlyArrived(box, body)) {
677
+ deadline = Math.min(Date.now() + io.timeoutMs, hardDeadline);
678
+ }
679
+ previous = box;
680
+ if (Date.now() >= deadline)
681
+ return "unverified";
682
+ }
683
+ }
172
684
  /**
173
685
  * 入力欄の残存テキストが、transcript に記録済みの発話本文と同文か(restored-prompt-discard)。
174
686
  *
@@ -183,9 +695,15 @@ export function inputBoxRealText(ansiScreen) {
183
695
  * 生で比べると、貼り付け由来のゼロ幅文字 1 つで照合が外れる。
184
696
  */
185
697
  export function inputBoxTextMatchesRecordedPrompt(boxText, recordedPrompt) {
186
- const normalize = (value) => normalizeForTextMatch(value).replace(/^!/, "");
698
+ // Mac 側で貼り付けて送った発話は、中断で `[Pasted text #N]` として書き戻される。表示からは
699
+ // 本文が分からない(同じ行数の別の貼り付けと見分けられない)ので、同文とは判定しない。
700
+ const normalize = (value) => normalizeForTextMatch(value.normalize("NFC")).replace(/^!/, "");
701
+ // 約 10000 字を超える発話は、中ほどが置き換えの表示になって書き戻される。
702
+ if (boxText.search(TRUNCATED_DISPLAY) !== -1) {
703
+ return matchInputBoxAgainstText(boxText, unwrapPastedContent(recordedPrompt), true) >= 24;
704
+ }
187
705
  const pending = normalize(boxText);
188
- const recorded = normalize(recordedPrompt);
706
+ const recorded = normalize(unwrapPastedContent(recordedPrompt));
189
707
  if (pending.length === 0 || recorded.length === 0)
190
708
  return false;
191
709
  if (pending === recorded)
@@ -211,25 +729,13 @@ export function extractInputBoxSuggestion(ansiScreen) {
211
729
  */
212
730
  function analyzeInputBox(ansiScreen) {
213
731
  const rawLines = ansiScreen.split("\n").map((line) => line.replace(/\r$/, ""));
214
- const stripped = rawLines.map((line) => stripSgr(line).trim());
732
+ const plain = rawLines.map((line) => stripSgr(line));
733
+ const stripped = plain.map((line) => line.trim());
215
734
  // 罫線ペアで入力欄領域を特定する(extractClaudeInputBox と同じ規則を SGR 除去後の行へ)。
216
- let bottom = -1;
217
- for (let index = stripped.length - 1; index >= 0; index -= 1) {
218
- if (isInputBoxRuleLine(stripped[index] ?? "")) {
219
- bottom = index;
220
- break;
221
- }
222
- }
223
- let top = -1;
224
- for (let index = bottom - 1; index >= 0; index -= 1) {
225
- if (isInputBoxRuleLine(stripped[index] ?? "")) {
226
- top = index;
227
- break;
228
- }
229
- }
735
+ const frame = findInputBoxFrame(plain);
230
736
  let bodyRaw;
231
- if (top >= 0 && bottom > top) {
232
- bodyRaw = rawLines.slice(top + 1, bottom);
737
+ if (frame !== null) {
738
+ bodyRaw = rawLines.slice(frame.top + 1, frame.bottom);
233
739
  }
234
740
  else {
235
741
  // 罫線が無い画面は最後の `❯` 行 1 行だけを入力欄とみなす(extractClaudeInputBox と同型)。
@@ -245,7 +751,8 @@ function analyzeInputBox(ansiScreen) {
245
751
  bodyRaw = [rawLines[sigilIndex] ?? ""];
246
752
  }
247
753
  // プレースホルダー(文言一致)は "" 扱い(extractClaudeInputBox が担保・後方互換)。
248
- const box = extractClaudeInputBox(stripped.join("\n"));
754
+ // 字下げを残した行を渡す(trim した行を渡すと、入力欄の中の罫線だけの行を枠と取り違える)。
755
+ const box = extractClaudeInputBox(plain.join("\n"));
249
756
  if (box === null)
250
757
  return null;
251
758
  return { text: box.text, faintOnly: bodyIsFaintOnly(bodyRaw) };
@@ -472,6 +979,9 @@ export function loginCodeScreenState(screen) {
472
979
  const rules = tail.filter((line) => isInputBoxRuleLine(line.trim())).length;
473
980
  if (rules >= 2)
474
981
  return "accepted";
982
+ // 名前付きの会話は、上の罫線にタイトルが埋まって罫線に数えられない。入力欄の形で読む。
983
+ if (findFrameAbovePrompt(tail) !== null)
984
+ return "accepted";
475
985
  return "pending";
476
986
  }
477
987
  /**
@@ -638,8 +1148,8 @@ function splitInputPrompt(bodyLines) {
638
1148
  const TMUX_TIMEOUT_MS = 15_000;
639
1149
  /** 実 tmux を絶対パスで起動する既定ランナー。tmux 非0 exit は throw せず結果で表現する。 */
640
1150
  export function processTmuxCommandRunner(tmuxPath = DEFAULT_TMUX_PATH, timeoutMs = TMUX_TIMEOUT_MS) {
641
- return (args) => new Promise((resolve, reject) => {
642
- execFile(tmuxPath, args, {
1151
+ return (args, input) => new Promise((resolve, reject) => {
1152
+ const child = execFile(tmuxPath, args, {
643
1153
  maxBuffer: 16 * 1024 * 1024, timeout: timeoutMs,
644
1154
  }, (error, stdout, stderr) => {
645
1155
  if (error && typeof error.code === "string") {
@@ -650,6 +1160,12 @@ export function processTmuxCommandRunner(tmuxPath = DEFAULT_TMUX_PATH, timeoutMs
650
1160
  const exitCode = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
651
1161
  resolve({ exitCode, stdout: String(stdout), stderr: String(stderr) });
652
1162
  });
1163
+ // 標準入力で渡す本文(`load-buffer -`)。tmux が読まずに終わっても落ちないよう、書き込みの
1164
+ // 失敗は握る(結果は exit code で返る)。
1165
+ if (input !== undefined) {
1166
+ child.stdin?.on("error", () => { });
1167
+ child.stdin?.end(input);
1168
+ }
653
1169
  });
654
1170
  }
655
1171
  /** TmuxSessionManager が投げる型付きエラー。 */
@@ -726,6 +1242,222 @@ export function screenShowsDialogFooter(screen) {
726
1242
  }
727
1243
  /** ダイアログのフッターを探す viewport 末尾の行数(フッターは最下段に出る)。 */
728
1244
  const DIALOG_FOOTER_WINDOW_LINES = 4;
1245
+ /**
1246
+ * **Esc で閉じてよい**選択ダイアログ(AskUserQuestion の設問 / ❯ メニュー)が画面下部に出ているか
1247
+ * (chat-cancel-choice, TESTABLE)。実測 2.1.283 のフッター:
1248
+ * - 設問(1 問): `Enter to select · ↑/↓ to navigate · Esc to cancel`
1249
+ * - 設問(複数問): `Enter to select · Tab/Arrow keys to navigate · Esc to cancel`
1250
+ * - 自由記述欄にフォーカス中: 上に `· ctrl+g to edit in VS Code` が挟まる(80 桁では折り返す)
1251
+ *
1252
+ * `screenShowsDialogFooter` より狭い: ツール承認(`Esc to cancel · Tab to amend`)と `/login` の
1253
+ * 継続待ちは含めない。承認の Esc は「ツールを拒否してターンを止める」で、選択肢を閉じるのとは
1254
+ * 重さが違う(承認はアプリの承認モーダルが正規の経路)。
1255
+ * 折り返しで `Esc to cancel` が次の行へ落ちるので、同じ行に両方あることは要求しない。
1256
+ * Esc は処理中の Claude を中断するキーでもあるため、**ボトムバーが見えているフレームでは
1257
+ * 決して true にしない**(本文の引用を末尾窓で拾っても、composer を見ているなら撃たない)。
1258
+ */
1259
+ export function screenShowsCancellableChoice(screen) {
1260
+ if (claudeComposerBarVisible(screen))
1261
+ return false;
1262
+ return screen
1263
+ .split("\n")
1264
+ .map((line) => stripSgr(line).trimEnd())
1265
+ .filter((line) => line.trim() !== "")
1266
+ .slice(-DIALOG_FOOTER_WINDOW_LINES)
1267
+ .some((line) => line.trim().startsWith("Enter to select"));
1268
+ }
1269
+ /**
1270
+ * ツール承認ダイアログ(フッター `Esc to cancel · Tab to amend`)が画面下部に出ているか(TESTABLE)。
1271
+ * 通常の承認は PreToolUse hook がアプリの承認モーダルで捌き TUI には出ないが、hook の allow を
1272
+ * 上書きする Claude Code 自身の安全確認(作業フォルダ外への Artifact 保存・`.claude/` への書き込み等)
1273
+ * は TUI に残り、利用者が答えるまで会話が止まる。pane_preview の「静止した入力待ち」として初回
1274
+ * フレームから送り、iPhone の転写カードで答えられるようにする(開き直しでもカードが出る)。
1275
+ * フッターはカーソルが 1 番目のとき `Esc to cancel · Tab to amend`、2 番目以降へ動かすと
1276
+ * `Esc to cancel` 単独になる(実測 2.1.283)ので、行頭の `Esc to cancel` で拾う(`/login` の各画面も
1277
+ * 同じフッターだが、そちらも静止した入力待ちとして初回から送る対象なので区別しない)。
1278
+ * 判定は `screenShowsCancellableChoice` と同じ硬化(末尾数行・行頭一致・ボトムバーが見えたら偽)。
1279
+ */
1280
+ export function screenShowsApprovalDialog(screen) {
1281
+ if (claudeComposerBarVisible(screen))
1282
+ return false;
1283
+ return screen
1284
+ .split("\n")
1285
+ .map((line) => stripSgr(line).trim())
1286
+ .filter((line) => line !== "")
1287
+ .slice(-DIALOG_FOOTER_WINDOW_LINES)
1288
+ .some((line) => line.startsWith("Esc to cancel"));
1289
+ }
1290
+ /**
1291
+ * 選択ダイアログを Esc で閉じ、消えたことを確認する(tmux / herdr 共通, chat-cancel-choice)。
1292
+ *
1293
+ * **Esc は 1 回しか打たない**。実測 2.1.283 では設問が複数でも、自由記述欄にフォーカスが
1294
+ * あっても 1 回で閉じて 0.3 秒以内に空の入力欄へ戻る。閉じた後の入力欄へもう 1 回届くと
1295
+ * 「Esc 2 回」= 巻き戻し(rewind)の画面が開き、続けて打つ本文がそこへ落ちる。
1296
+ * 消えたかどうかは読めたフレームだけで判断する(capture 不能を「閉じた」と見なさない)。
1297
+ */
1298
+ export async function cancelChoiceDialog(io) {
1299
+ const detect = io.detect ?? screenShowsCancellableChoice;
1300
+ if (io.screen === null || !detect(io.screen))
1301
+ return "absent";
1302
+ await io.sendEscape();
1303
+ for (let poll = 0; poll < io.maxPolls; poll += 1) {
1304
+ await new Promise((resolve) => setTimeout(resolve, io.pollMs));
1305
+ const after = await io.capture();
1306
+ if (after !== null && !detect(after))
1307
+ return "cancelled";
1308
+ }
1309
+ return "stuck";
1310
+ }
1311
+ /** Esc 後にダイアログの消滅を確かめる間隔と回数の既定(合計 約 2.4s)。 */
1312
+ export const CHOICE_CANCEL_POLL_MS = 300;
1313
+ export const CHOICE_CANCEL_MAX_POLLS = 8;
1314
+ /**
1315
+ * 設問を閉じて送る指定なのに、入力欄へ戻ったことを画面で確かめられなかったときの拒否文言。
1316
+ * hub は「未回答の設問がある」と知っている。判別できない画面へ本文を打つと選択肢へ落ちる。
1317
+ */
1318
+ export const CHAT_BLOCKED_BY_UNREADABLE_CHOICE = "設問の画面を確認できなかったため、メッセージを送信しませんでした(少し待って送り直すか、アプリで設問に答えてください)";
1319
+ /** 設問は閉じたが、その後の入力欄を画面で確かめられなかったときの拒否文言。 */
1320
+ export const CHAT_BLOCKED_AFTER_CHOICE_CANCELLED = "設問は閉じましたが、入力欄を確認できなかったためメッセージを送信しませんでした(送り直してください)";
1321
+ /**
1322
+ * 選択ダイアログを閉じた直後のフレームを、入力欄が描画されるまで撮り直す(tmux / herdr 共通)。
1323
+ * 閉じた直後は描画途中でボトムバーが欠け、`unknown` に見えることがある。ここで諦めると
1324
+ * 「設問は閉じたのに本文は未送信」になるので、`unknown` の間だけ短く待つ。
1325
+ */
1326
+ export async function settleFrameAfterChoiceCancel(io) {
1327
+ let screen = await io.capture();
1328
+ let frame = classifySubmitFrame(screen);
1329
+ for (let poll = 0; poll < io.maxPolls && frame === "unknown"; poll += 1) {
1330
+ await new Promise((resolve) => setTimeout(resolve, io.pollMs));
1331
+ screen = await io.capture();
1332
+ frame = classifySubmitFrame(screen);
1333
+ }
1334
+ return { screen, frame };
1335
+ }
1336
+ /** 選択ダイアログを閉じられなかったときの拒否文言(hub が会話本文へそのまま出す)。 */
1337
+ export const CHAT_BLOCKED_BY_STUCK_CHOICE = "選択肢を閉じられなかったため、メッセージを送信しませんでした(アプリで選択肢に答えるか、Mac 側の画面で閉じてください)";
1338
+ /** ヒント行の末尾(実測 2.1.283。chord の表記だけが端末で変わる)。 */
1339
+ const SEND_NOW_HINT_SUFFIX = " to send now";
1340
+ /**
1341
+ * 既定の `chat:sendNow` の表記。tmux は `ctrl+x ctrl+s`、拡張キーを解する端末(herdr)は
1342
+ * `ctrl+enter` を出す(どちらの端末でも既定の割り当ては両方生きている)。
1343
+ */
1344
+ const SEND_NOW_DEFAULT_CHORDS = new Set(["ctrl+x ctrl+s", "ctrl+enter"]);
1345
+ /**
1346
+ * chord の表記か(`ctrl+j` / `alt+enter` / `ctrl+x ctrl+k` など。修飾キー + キーが 1〜2 打)。
1347
+ * 過去の発話の折り返しが偶然 ` to send now` で終わっただけの行を、割り当て変更と取り違えない。
1348
+ */
1349
+ const CHORD_NOTATION = /^(?:ctrl|alt|shift|meta|cmd|super)\+\S+(?: (?:(?:ctrl|alt|shift|meta|cmd|super)\+)?\S+)?$/;
1350
+ /** ヒント行を探す viewport 末尾の非空行数(スピナー・Tip・タスク一覧・agents パネルを挟む)。 */
1351
+ const SEND_NOW_HINT_WINDOW_LINES = 40;
1352
+ /** ヒント行から上へ、キュー済み発話の `❯` 行を探す行数(折り返した長い発話・複数件のキュー)。 */
1353
+ const SEND_NOW_QUEUE_WALK_LINES = 40;
1354
+ /**
1355
+ * 画面に「今すぐ送信」のヒントが出ているか。実測 2.1.283 の形:
1356
+ *
1357
+ * ```
1358
+ * ❯ キュー済みの発話(長ければ 2 桁字下げで折り返す)
1359
+ * ctrl+x ctrl+s to send now
1360
+ * ✽ Proofing… (9s · ↓ 183 tokens)
1361
+ * ────────
1362
+ * ❯ Press up to edit queued messages
1363
+ * ```
1364
+ *
1365
+ * 会話本文がこの文言を引用しただけで拾わないよう、**ヒント行のすぐ上がキュー済みの発話**
1366
+ * (`❯` で始まる行と、その折り返し)であることを要求する。応答本文は `⏺` で始まるので、
1367
+ * 本文中の引用は上へ辿ると先に `⏺` に当たって外れる。
1368
+ * 誤検出しても実害は無い(キューが空のときの chord は何もしない。実測)。
1369
+ */
1370
+ export function screenSendNowHint(screen) {
1371
+ const lines = screen.split("\n").map((line) => stripSgr(line).trimEnd());
1372
+ let scanned = 0;
1373
+ for (let index = lines.length - 1; index >= 0 && scanned < SEND_NOW_HINT_WINDOW_LINES; index -= 1) {
1374
+ const line = lines[index] ?? "";
1375
+ const trimmed = line.trim();
1376
+ if (trimmed === "")
1377
+ continue;
1378
+ scanned += 1;
1379
+ if (!line.startsWith(" ") || !trimmed.endsWith(SEND_NOW_HINT_SUFFIX))
1380
+ continue;
1381
+ if (!lineFollowsQueuedPrompt(lines, index))
1382
+ continue;
1383
+ const chord = trimmed.slice(0, -SEND_NOW_HINT_SUFFIX.length).trim().toLowerCase();
1384
+ if (SEND_NOW_DEFAULT_CHORDS.has(chord))
1385
+ return "ready";
1386
+ if (CHORD_NOTATION.test(chord))
1387
+ return "rebound";
1388
+ }
1389
+ return "absent";
1390
+ }
1391
+ /** `index` の行から上へ折り返し(字下げ行)と空行だけを辿って、`❯` で始まる行に着くか。 */
1392
+ function lineFollowsQueuedPrompt(lines, index) {
1393
+ for (let walked = 0, cursor = index - 1; cursor >= 0 && walked < SEND_NOW_QUEUE_WALK_LINES; cursor -= 1) {
1394
+ const line = lines[cursor] ?? "";
1395
+ if (line.trim() === "")
1396
+ continue;
1397
+ walked += 1;
1398
+ if (line.codePointAt(0) === 0x276f)
1399
+ return true;
1400
+ if (!line.startsWith(" "))
1401
+ return false;
1402
+ }
1403
+ return false;
1404
+ }
1405
+ export const SEND_NOW_BLOCKED_BY_DIALOG = "ダイアログの表示中は「今すぐ送信」できません(アプリで選択肢に答えてからもう一度試してください)";
1406
+ export const SEND_NOW_BLOCKED_BY_DRAFT = "Mac 側の入力欄に入力中の文字があるため「今すぐ送信」しませんでした(その文字ごと送信されるのを防ぐため)";
1407
+ export const SEND_NOW_BLOCKED_BY_UNREADABLE = "画面を確認できなかったため「今すぐ送信」しませんでした(少し待ってもう一度試してください)";
1408
+ export const SEND_NOW_BLOCKED_BY_REBOUND = "Claude Code のキー割り当てが変更されているため「今すぐ送信」できません(chat:sendNow を既定に戻してください)";
1409
+ export const SEND_NOW_UNCONFIRMED = "「今すぐ送信」を送りましたが、届いたことを確認できませんでした";
1410
+ export const SEND_NOW_EXPIRED_IN_QUEUE = "先に送ったメッセージの処理に時間がかかったため「今すぐ送信」しませんでした(もう一度試してください)";
1411
+ /**
1412
+ * chord 後にヒント行の消滅を確かめる間隔と回数の既定(合計 約 6s)。短い発話は 0.7s 以内に消えるが、
1413
+ * 貼り付け扱いの長文(約 800 字超)は 4.5s かかった(実測 2.1.283)。届いているのに `failed` を
1414
+ * 返すと、利用者は届いた発話をもう一度流そうとする。
1415
+ */
1416
+ export const SEND_NOW_POLL_MS = 300;
1417
+ export const SEND_NOW_MAX_POLLS = 20;
1418
+ /** 判別できないフレーム(描画の途中)を撮り直す回数。chord を送る前だけ(合計 約 1.5s)。 */
1419
+ export const SEND_NOW_SETTLE_POLLS = 5;
1420
+ /**
1421
+ * CLI のキューに溜まっている発話を今すぐ届ける(tmux / herdr 共通, chat-send-now)。
1422
+ * CLI の `chat:sendNow` を 1 回送り、ヒント行が消えたことを確かめる。
1423
+ *
1424
+ * 実測 2.1.283:
1425
+ * - ツールの実行中: ツールが背景へ回り、発話は同じターンへ届く(`queued_command`)。
1426
+ * - 応答の生成中 / `/` で始まる発話(添付のパス): そのターンを打ち切り、発話は新しいターンで届く。
1427
+ * - キューが空 / アイドル: 何も起きない。
1428
+ * - **入力欄に文字がある: その文字ごと送信される**。Mac 側の下書きを勝手に送らないよう、
1429
+ * 入力欄が空と確かめられたフレームでだけ送る(fail-closed)。
1430
+ */
1431
+ export async function sendQueuedNow(io) {
1432
+ let screen = await io.capture();
1433
+ let frame = classifySubmitFrame(screen);
1434
+ // 描画の途中でバーが欠けたフレームは、少し待つと読める。読めるまで chord は送らない。
1435
+ for (let poll = 0; poll < (io.settlePolls ?? SEND_NOW_SETTLE_POLLS) && frame === "unknown"; poll += 1) {
1436
+ await new Promise((resolve) => setTimeout(resolve, io.pollMs));
1437
+ screen = await io.capture();
1438
+ frame = classifySubmitFrame(screen);
1439
+ }
1440
+ if (frame === "dialog")
1441
+ return { status: "blocked", reason: SEND_NOW_BLOCKED_BY_DIALOG };
1442
+ if (screen === null || frame === "unknown") {
1443
+ return { status: "blocked", reason: SEND_NOW_BLOCKED_BY_UNREADABLE };
1444
+ }
1445
+ if (frame === "pending")
1446
+ return { status: "blocked", reason: SEND_NOW_BLOCKED_BY_DRAFT };
1447
+ const hint = screenSendNowHint(screen);
1448
+ if (hint === "absent")
1449
+ return { status: "nothing_queued" };
1450
+ if (hint === "rebound")
1451
+ return { status: "blocked", reason: SEND_NOW_BLOCKED_BY_REBOUND };
1452
+ await io.sendChord();
1453
+ for (let poll = 0; poll < io.maxPolls; poll += 1) {
1454
+ await new Promise((resolve) => setTimeout(resolve, io.pollMs));
1455
+ const after = await io.capture();
1456
+ if (after !== null && screenSendNowHint(after) === "absent")
1457
+ return { status: "sent" };
1458
+ }
1459
+ return { status: "failed", reason: SEND_NOW_UNCONFIRMED };
1460
+ }
729
1461
  /**
730
1462
  * 画面最下部に composer のボトムバーが出ているか=「いま見ているのは入力欄であって
731
1463
  * ダイアログではない」か(TESTABLE。text / ANSI どちらのキャプチャでも使える)。
@@ -750,8 +1482,19 @@ export function claudeComposerBarVisible(screen) {
750
1482
  while (index >= 0 && isTrailingTuiLine(lines[index] ?? ""))
751
1483
  index -= 1;
752
1484
  const candidate = lines[index];
753
- return candidate !== undefined && isComposerBarLine(candidate);
1485
+ if (candidate === undefined)
1486
+ return false;
1487
+ if (isComposerBarLine(candidate))
1488
+ return true;
1489
+ // 貼り付けを取り込んだ後の十数秒は、バーの位置が `paste again to expand` に替わる(実測
1490
+ // 2.1.283。Mac 側で貼り付けた直後も同じ)。入力欄の罫線が見えているときだけバーの代わりと
1491
+ // 認める。認めないと、その間の送信はすべて成立を確認できない(`unknown`)。
1492
+ // ダイアログはフッターが先に判定される(実測: 貼り付けの直後に承認・設問が出ると、
1493
+ // 案内は残らずダイアログのフッターへ置き換わる)。
1494
+ return candidate.trim() === PASTE_EXPAND_HINT && claudeInputBoxRendered(screen);
754
1495
  }
1496
+ /** 貼り付けを取り込んだ直後、ボトムバーの位置に出る案内(実測 2.1.283)。 */
1497
+ const PASTE_EXPAND_HINT = "paste again to expand";
755
1498
  export function classifySubmitFrame(ansiScreen) {
756
1499
  if (ansiScreen === null)
757
1500
  return "unknown";
@@ -778,6 +1521,16 @@ export class TmuxSessionManager {
778
1521
  submitDelayMs;
779
1522
  /** Enter → 送信成立確認 の間隔 ms(テスト注入用)。 */
780
1523
  submitVerifyDelayMs;
1524
+ /** 選択ダイアログを Esc で閉じた後の消滅確認の間隔 ms(テスト注入用)。 */
1525
+ choiceCancelPollMs;
1526
+ /** 「今すぐ送信」の chord 後、ヒント行の消滅確認の間隔 ms(テスト注入用)。 */
1527
+ sendNowPollMs;
1528
+ /** 貼り付けた本文が入力欄に入るのを確かめる間隔と上限 ms(テスト注入用)。 */
1529
+ pastedTextPollMs;
1530
+ pastedTextTimeoutMs;
1531
+ clearSettleMs;
1532
+ /** 貼り付けバッファ名の通番。 */
1533
+ pasteSequence = 0;
781
1534
  constructor(options = {}) {
782
1535
  this.runner = options.runner ?? processTmuxCommandRunner();
783
1536
  this.store = options.store ?? new SessionMetadataStore();
@@ -786,6 +1539,11 @@ export class TmuxSessionManager {
786
1539
  this.clearKeyDelayMs = options.clearKeyDelayMs ?? 150;
787
1540
  this.submitDelayMs = options.submitDelayMs ?? 150;
788
1541
  this.submitVerifyDelayMs = options.submitVerifyDelayMs ?? 700;
1542
+ this.choiceCancelPollMs = options.choiceCancelPollMs ?? CHOICE_CANCEL_POLL_MS;
1543
+ this.sendNowPollMs = options.sendNowPollMs ?? SEND_NOW_POLL_MS;
1544
+ this.pastedTextPollMs = options.pastedTextPollMs ?? PASTED_TEXT_POLL_MS;
1545
+ this.pastedTextTimeoutMs = options.pastedTextTimeoutMs ?? PASTED_TEXT_TIMEOUT_TMUX_MS;
1546
+ this.clearSettleMs = options.clearSettleMs ?? TYPED_TEXT_CLEAR_SETTLE_MS;
789
1547
  this.loginTiming = {
790
1548
  delayMs: options.loginTiming?.delayMs ?? 150,
791
1549
  pollMs: options.loginTiming?.pollMs ?? 250,
@@ -911,6 +1669,7 @@ export class TmuxSessionManager {
911
1669
  * literal 送出 → 150ms(Ink 再描画待ち)→ Enter。
912
1670
  */
913
1671
  async sendTextSubmit(name, text, options = {}) {
1672
+ const startedAtMs = Date.now();
914
1673
  // 1 回の capture で「/login フロー中か」と「残存テキスト」を判定する(chat 毎の capture を増やさない)。
915
1674
  const screen = await this.captureVisibleScreenOrNull(name);
916
1675
  // `/login` フロー中(方式選択 / コード入力待ち / retry)は通常の入力欄が無く、注入した本文が
@@ -929,8 +1688,44 @@ export class TmuxSessionManager {
929
1688
  // 残存の有無は **composer フレームでだけ** 意味を持つ。ダイアログ表示中の
930
1689
  // `inputBoxRealText` はカーソル行(`1. Yes` 等)を返すので、これを残存と信じて
931
1690
  // Enter を撃つと選択肢を誤確定する(実測 2.1.278)。
932
- const ansiScreen = await this.captureVisibleScreenAnsiOrNull(name);
933
- const frame = classifySubmitFrame(ansiScreen);
1691
+ let ansiScreen = await this.captureVisibleScreenAnsiOrNull(name);
1692
+ let frame = classifySubmitFrame(ansiScreen);
1693
+ if (frame === "dialog" && options.cancelChoiceDialog === true) {
1694
+ // 利用者が「選択肢に答えずにメッセージを送る」と決めた送信(chat-cancel-choice)。
1695
+ // 選択ダイアログなら Esc で閉じ、消えたのを確かめてから入力欄へ流す(Mac 側で手動 Esc
1696
+ // するのと同じ操作)。承認ダイアログ / `/login` の継続待ちは `absent` になり、下の拒否へ落ちる。
1697
+ const outcome = await cancelChoiceDialog({
1698
+ screen: ansiScreen,
1699
+ capture: () => this.captureVisibleScreenAnsiOrNull(name),
1700
+ sendEscape: () => this.sendKeys(name, ["Escape"]),
1701
+ pollMs: this.choiceCancelPollMs,
1702
+ maxPolls: CHOICE_CANCEL_MAX_POLLS,
1703
+ });
1704
+ if (outcome !== "absent") {
1705
+ const settled = await settleFrameAfterChoiceCancel({
1706
+ capture: () => this.captureVisibleScreenAnsiOrNull(name),
1707
+ pollMs: this.choiceCancelPollMs,
1708
+ maxPolls: outcome === "cancelled" ? CHOICE_CANCEL_MAX_POLLS : 0,
1709
+ });
1710
+ ansiScreen = settled.screen;
1711
+ frame = settled.frame;
1712
+ }
1713
+ if (outcome === "cancelled")
1714
+ options.onChoiceDialogCancelled?.();
1715
+ // Esc は打ったが閉じない。ここで本文を打つとダイアログへ落ちるので、何も打たずに諦める
1716
+ // (Esc 自体は選択肢を確定しない。非配達は確定している)。
1717
+ if (outcome === "stuck" && frame === "dialog") {
1718
+ throw new ChatInjectionRejectedError(CHAT_BLOCKED_BY_STUCK_CHOICE);
1719
+ }
1720
+ if (outcome === "cancelled" && frame === "unknown") {
1721
+ throw new ChatInjectionRejectedError(CHAT_BLOCKED_AFTER_CHOICE_CANCELLED);
1722
+ }
1723
+ }
1724
+ if (options.cancelChoiceDialog === true && frame === "unknown") {
1725
+ // 設問がある前提の送信は fail-closed: 入力欄(ボトムバー)が見えたときだけ打つ。
1726
+ // 指定の無い送信は従来どおり(未知フレームで送信が丸ごと不能にならないよう fail-open)。
1727
+ throw new ChatInjectionRejectedError(CHAT_BLOCKED_BY_UNREADABLE_CHOICE);
1728
+ }
934
1729
  if (frame === "dialog") {
935
1730
  // 本文を 1 キーも打たずに諦める(ダイアログへ打ち込むより確実に安全)。
936
1731
  // 非配達が確定しているので、hub は uncertain に積まず失敗として片付ける。
@@ -958,10 +1753,65 @@ export class TmuxSessionManager {
958
1753
  // 記号を消して通常入力へ戻す。`!` 始まりの本文は注入時に自分でシェルモードへ入るので、
959
1754
  // 常に通常モードから始めるのが決定的で安全(herdr 側 exitShellMode と同じ防御)。
960
1755
  await this.exitShellMode(name);
961
- await this.sendKeys(name, [text], true);
962
- if ((await this.submitTypedText(name)) !== "submitted")
1756
+ let pasted = "arrived";
1757
+ if (tmuxTypedTextWouldBreak(text) && textIsPasteSafe(text) && typedTextProbe(text) !== null) {
1758
+ // 1 回で打つと壊れる本文は、小分けの括弧付き貼り付けで渡す(long-text-paste)。
1759
+ // 入ったのを確かめられなくても、打つ場合と同じく送信確定へ進む(画面を読めないだけの
1760
+ // ことがある。Enter は貼り付けの後ろに並ぶので、途中までの本文が送信されることは無い)。
1761
+ pasted = await pasteTextInline(text, {
1762
+ paste: (pieces) => this.pasteBracketed(name, pieces),
1763
+ capture: () => this.captureVisibleScreenAnsiOrNull(name),
1764
+ sendKills: (count) => this.sendKeys(name, Array.from({ length: count }, () => "C-u")),
1765
+ pollMs: this.pastedTextPollMs,
1766
+ timeoutMs: this.pastedTextTimeoutMs,
1767
+ clearDelayMs: this.clearKeyDelayMs,
1768
+ clearSettleMs: this.clearSettleMs,
1769
+ verifyDeadlineMs: startedAtMs + INJECT_VERIFY_BUDGET_MS,
1770
+ });
1771
+ }
1772
+ else {
1773
+ await this.sendKeys(name, [text], true);
1774
+ }
1775
+ const outcome = await this.submitTypedText(name);
1776
+ // 入ったのを確かめられなかった貼り付けは、送信確定が成立に見えても未確認として残す
1777
+ // (止まっている CLI の画面は、空の入力欄のまま変わらない)。
1778
+ if (outcome !== "submitted" || pasted === "unverified")
963
1779
  options.onUnconfirmedSubmit?.();
964
1780
  }
1781
+ /**
1782
+ * 小分けにした本文を、1 つずつ括弧付き貼り付けとして pane へ渡す(long-text-paste)。
1783
+ * 本文は標準入力から tmux の貼り付けバッファへ載せる(`load-buffer -`)。引数で渡すと、tmux は
1784
+ * 末尾の `;` をコマンドの区切りとして落とし、約 16KB を超えると `command too long` で失敗する
1785
+ * (実測 tmux 3.7)。`paste-buffer -p` で括弧を付け、`-r` で改行を LF のまま渡す(`-r` が無いと
1786
+ * LF を CR に置き換える)。`-d` で貼った後にバッファを消す。バッファ名は呼び出しごとに変える
1787
+ * (並行する注入と取り違えない)。
1788
+ */
1789
+ async pasteBracketed(name, pieces) {
1790
+ const target = this.paneTarget(name);
1791
+ for (const piece of pieces) {
1792
+ this.pasteSequence += 1;
1793
+ const buffer = `tailii-${process.pid}-${this.pasteSequence}`;
1794
+ const args = ["load-buffer", "-b", buffer, "-", ";", "paste-buffer", "-p", "-r", "-d", "-b", buffer, "-t", target];
1795
+ const result = await this.runner(args, piece);
1796
+ if (result.exitCode !== 0) {
1797
+ // 載せたまま貼れなかったバッファを残さない(本文が tmux に残る)。
1798
+ await this.runner(["delete-buffer", "-b", buffer]).catch(() => undefined);
1799
+ throw new TmuxFailedError(args, result.exitCode, result.stderr);
1800
+ }
1801
+ }
1802
+ }
1803
+ /**
1804
+ * CLI のキューに溜まっている発話を今すぐ届ける(chat-send-now, SessionBackend 共通面)。
1805
+ * `C-x C-s` は 1 回の send-keys で続けて送る(chord を分断しない)。
1806
+ */
1807
+ async sendQueuedNow(name) {
1808
+ return sendQueuedNow({
1809
+ capture: () => this.captureVisibleScreenAnsiOrNull(name),
1810
+ sendChord: () => this.sendKeys(name, ["C-x", "C-s"]),
1811
+ pollMs: this.sendNowPollMs,
1812
+ maxPolls: SEND_NOW_MAX_POLLS,
1813
+ });
1814
+ }
965
1815
  /**
966
1816
  * 入力済みの本文を Enter で送信し、成立を確認するまで上限つきで Enter を撃ち直す
967
1817
  * (herdr 側 `sendTextSubmit` の送信確定ループと同型)。
@@ -1069,6 +1919,7 @@ export class TmuxSessionManager {
1069
1919
  */
1070
1920
  async clearInputBox(name) {
1071
1921
  try {
1922
+ let first = null;
1072
1923
  for (let attempt = 0; attempt < 15; attempt += 1) {
1073
1924
  const ansiScreen = await this.captureVisibleScreenAnsiOrNull(name);
1074
1925
  const realText = ansiScreen === null ? null : inputBoxRealText(ansiScreen);
@@ -1076,12 +1927,26 @@ export class TmuxSessionManager {
1076
1927
  return false;
1077
1928
  if (realText.replace(/\s+/g, "").length === 0)
1078
1929
  return true;
1930
+ first ??= realText;
1079
1931
  await this.sendKeys(name, ["C-u"]);
1080
1932
  await new Promise((resolve) => setTimeout(resolve, this.clearKeyDelayMs));
1081
1933
  }
1082
1934
  const ansiScreen = await this.captureVisibleScreenAnsiOrNull(name);
1083
1935
  const realText = ansiScreen === null ? null : inputBoxRealText(ansiScreen);
1084
- return realText !== null && realText.replace(/\s+/g, "").length === 0;
1936
+ if (realText === null)
1937
+ return false;
1938
+ if (realText.replace(/\s+/g, "").length === 0)
1939
+ return true;
1940
+ // 消えてはいるが行が多い(9 行以上の本文は 15 回では消し切れない)。進んでいる限り続ける。
1941
+ // 1 字も消えていない残存(C-u の効かない中身)には、これ以上打たない。
1942
+ if (realText === first)
1943
+ return false;
1944
+ return await clearTypedInput(CLEAR_UNKNOWN_INPUT_BOUND, {
1945
+ capture: () => this.captureVisibleScreenAnsiOrNull(name),
1946
+ sendKills: (count) => this.sendKeys(name, Array.from({ length: count }, () => "C-u")),
1947
+ clearDelayMs: this.clearKeyDelayMs,
1948
+ clearSettleMs: this.clearSettleMs,
1949
+ });
1085
1950
  }
1086
1951
  catch {
1087
1952
  return false;
@@ -1150,15 +2015,31 @@ export class TmuxSessionManager {
1150
2015
  validateSessionName(name);
1151
2016
  if (keys.length === 0)
1152
2017
  return;
1153
- const args = ["send-keys", "-t", this.paneTarget(name)];
2018
+ const target = this.paneTarget(name);
2019
+ const run = async (args) => {
2020
+ const result = await this.runner(args);
2021
+ if (result.exitCode !== 0)
2022
+ throw new TmuxFailedError(args, result.exitCode, result.stderr);
2023
+ };
2024
+ if (!literal) {
2025
+ await run(["send-keys", "-t", target, ...keys]);
2026
+ return;
2027
+ }
1154
2028
  // literal は `--` で引数終端を明示する(先頭が `-` の本文 — base64url の OAuth コード等 —
1155
2029
  // を tmux がフラグと誤認して `unknown flag` で失敗する)。
1156
- if (literal)
1157
- args.push("-l", "--");
1158
- args.push(...keys);
1159
- const result = await this.runner(args);
1160
- if (result.exitCode !== 0) {
1161
- throw new TmuxFailedError(args, result.exitCode, result.stderr);
2030
+ // 末尾が `;` の引数は、tmux がコマンドの区切りとして `;` を落とす(実測 tmux 3.7:
2031
+ // `abc;` → `abc`)。末尾の `;` は切り離し、文字コード(`-H 3b`)で打つ。
2032
+ if (!keys.some((key) => key.endsWith(";"))) {
2033
+ await run(["send-keys", "-t", target, "-l", "--", ...keys]);
2034
+ return;
2035
+ }
2036
+ for (const key of keys) {
2037
+ const body = key.replace(/;+$/, "");
2038
+ if (body !== "")
2039
+ await run(["send-keys", "-t", target, "-l", "--", body]);
2040
+ const semicolons = key.length - body.length;
2041
+ if (semicolons > 0)
2042
+ await run(["send-keys", "-t", target, "-H", ...Array.from({ length: semicolons }, () => "3b")]);
1162
2043
  }
1163
2044
  }
1164
2045
  /** `capture-pane -p -t <name> -S -<N>` で末尾 N 行のペイン内容を返す(末尾空行は削る)。 */