@cocorograph/hub-agent 0.6.56 → 0.6.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.56",
3
+ "version": "0.6.58",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -64,6 +64,7 @@ import {
64
64
  listWorktreeNameHistory,
65
65
  listWorktreeStubs,
66
66
  rebindClaudeSession,
67
+ recoverTuiInput,
67
68
  removeWorktree as removeWorktreeDir,
68
69
  resumeWithMessage,
69
70
  setTmuxGlobalEnv,
@@ -1361,6 +1362,35 @@ async function dispatch(msg, ctx) {
1361
1362
  })()
1362
1363
  return
1363
1364
  }
1365
+ case "claude.tui.recoverInput": {
1366
+ // 中断キャンセル後の入力欄復旧 (agent >= 0.6.57)。対話 claude は「送信直後の Esc」
1367
+ // で本文を入力ボックスへ戻すため、放置すると次の bracketed paste が後ろへ連結され
1368
+ // メッセージが合体する (2026-06-08 報告バグ)。frontend が Esc 送出直後にこれを送り、
1369
+ // agent がペインを実測して「expect_text が入力ボックスへ戻った」ことを確認してから
1370
+ // クリアし、結果を claude.tui.recoverInput.result で返す (backend が user-level
1371
+ // broadcast し、frontend は request_id で自分の要求と照合する)。
1372
+ const sessionName =
1373
+ typeof msg.session_name === "string" ? msg.session_name : ""
1374
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
1375
+ const requestId =
1376
+ typeof msg.request_id === "string" ? msg.request_id : ""
1377
+ const expectText =
1378
+ typeof msg.expect_text === "string" ? msg.expect_text : ""
1379
+ if (!sessionName || !requestId) return
1380
+ ;(async () => {
1381
+ const result = await recoverTuiInput(sessionName, expectText, {
1382
+ logger,
1383
+ })
1384
+ ctx.client.send({
1385
+ type: "claude.tui.recoverInput.result",
1386
+ request_id: requestId,
1387
+ session_name: sessionName,
1388
+ cwd: cwd || undefined,
1389
+ cleared: !!result.cleared,
1390
+ })
1391
+ })()
1392
+ return
1393
+ }
1364
1394
  case "claude.tui.rehydratePermissions": {
1365
1395
  // セッション切替でビューが再マウントすると、その時点の承認/質問カードは React
1366
1396
  // state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
package/src/state.mjs CHANGED
@@ -119,6 +119,81 @@ export function detectPermissionModeFromText(text) {
119
119
  return null
120
120
  }
121
121
 
122
+ /**
123
+ * 対話 claude TUI のペインテキストから「入力欄内の本文」を取り出す。
124
+ *
125
+ * 中断キャンセル後の入力欄復旧 (claude.tui.recoverInput) の読込側。claude は
126
+ * 「送信直後の Esc」で直前メッセージの本文を入力欄へ戻すため、これを実測して
127
+ * クリアしないと、次の bracketed paste が後ろへ連結されてメッセージが合体する。
128
+ *
129
+ * 2 形式に対応する (claude v2.1.16x 実機確認 2026-06-08):
130
+ * - **現行 (ルール区切り)**: `───…` / `❯ 本文` (折り返しは 2 スペース字下げで続行) /
131
+ * `───…` / フッター。入力プロンプト `❯` は列 0 から始まる (選択ダイアログのカーソル
132
+ * ` ❯ 1. …` は字下げされるため誤検出しない)。
133
+ * - **旧 (罫線ボックス)**: `╭─…─╮ / │ > 本文 │ / ╰─…─╯`。末尾から最も近い枠の先頭行が
134
+ * プロンプト (`>` / `❯`) で始まる時だけ採用する。
135
+ *
136
+ * 空入力時のプレースホルダ等も文字列として返り得るので、呼び出し側は「期待する本文と
137
+ * 一致するか」で判定すること (本関数は在中テキストの抽出のみを担う)。
138
+ *
139
+ * @param {string} text capturePane の結果 (ANSI 除去済み)
140
+ * @returns {string|null} 入力欄内の本文 (入力欄が見つからない / 空なら null)
141
+ */
142
+ export function detectInputBoxText(text) {
143
+ if (!text) return null
144
+ const lines = text.split("\n")
145
+
146
+ // ── 現行形式: 列 0 の `❯` プロンプト行 + 下側ルールまでの続行行 ──
147
+ let promptIdx = -1
148
+ for (let i = lines.length - 1; i >= 0; i--) {
149
+ if (/^❯/.test(lines[i])) {
150
+ promptIdx = i
151
+ break
152
+ }
153
+ }
154
+ if (promptIdx >= 0) {
155
+ const parts = [lines[promptIdx].replace(/^❯\s?/, "").replace(/\s+$/, "")]
156
+ for (let i = promptIdx + 1; i < lines.length; i++) {
157
+ const ln = lines[i]
158
+ if (/^\s*─{4,}\s*$/.test(ln)) break // 下側ルールで入力欄が終わる
159
+ parts.push(ln.replace(/\s+$/, ""))
160
+ }
161
+ const joined = parts.join("\n").trim()
162
+ return joined || null
163
+ }
164
+
165
+ // ── 旧形式: 罫線ボックス ──
166
+ let bottom = -1
167
+ for (let i = lines.length - 1; i >= 0; i--) {
168
+ if (/^\s*╰/.test(lines[i])) {
169
+ bottom = i
170
+ break
171
+ }
172
+ }
173
+ if (bottom < 0) return null
174
+ let top = -1
175
+ for (let i = bottom - 1; i >= 0; i--) {
176
+ if (/^\s*╭/.test(lines[i])) {
177
+ top = i
178
+ break
179
+ }
180
+ }
181
+ if (top < 0 || bottom - top < 2) return null
182
+ const inner = lines.slice(top + 1, bottom)
183
+ const first = inner[0].match(/^\s*│\s*[>❯]\s?(.*)$/)
184
+ if (!first) return null
185
+ // 右罫線と行末空白を落とす (折り返し行は左罫線 + 字下げで続く)。
186
+ const strip = (s) => s.replace(/\s*│\s*$/, "").replace(/\s+$/, "")
187
+ const parts = [strip(first[1])]
188
+ for (let i = 1; i < inner.length; i++) {
189
+ const m = inner[i].match(/^\s*│\s?(.*)$/)
190
+ if (!m) break
191
+ parts.push(strip(m[1]))
192
+ }
193
+ const joined = parts.join("\n").trim()
194
+ return joined || null
195
+ }
196
+
122
197
  export async function listSessionNames(opts = {}) {
123
198
  const tmuxBin = opts.tmuxBin || "tmux"
124
199
  try {
package/src/tmux.mjs CHANGED
@@ -20,7 +20,12 @@ import path from "node:path"
20
20
  import { promisify } from "node:util"
21
21
 
22
22
  import { ensureClaudeMd } from "./claude-md.mjs"
23
- import { detectSessionState, getSessionCwd } from "./state.mjs"
23
+ import {
24
+ capturePane,
25
+ detectInputBoxText,
26
+ detectSessionState,
27
+ getSessionCwd,
28
+ } from "./state.mjs"
24
29
  import { getSessionUsages } from "./usage.mjs"
25
30
 
26
31
  const execFileP = promisify(execFile)
@@ -860,6 +865,85 @@ export async function cyclePermissionMode(name, opts = {}) {
860
865
  }
861
866
  }
862
867
 
868
+ /**
869
+ * 中断キャンセル後の入力欄復旧 (claude.tui.recoverInput / agent >= 0.6.57)。
870
+ *
871
+ * 対話 claude TUI は「送信直後の Esc」で直前メッセージの本文を入力ボックスへ戻す。
872
+ * cockpit の TUI チャットはこれを関知できず、次の bracketed paste が戻された本文の
873
+ * 後ろへ連結されて別メッセージが合体送信されるバグがあった (2026-06-08 報告。
874
+ * AAA→キャンセル→BBB 送信が AAABBB になる)。
875
+ *
876
+ * 本関数は frontend が PTY 経由で Esc (中断) を送った直後に呼ばれ、ペインを短間隔で
877
+ * 実測して「期待する本文 (expectText) が入力欄へ戻った」ことを確認してからクリアする:
878
+ * 1. capturePane → detectInputBoxText で入力欄本文を抽出し、expectText と照合
879
+ * (ペイン幅での折り返しを考慮して空白を正規化し、先頭 40 文字で部分照合)
880
+ * 2. 在中確認後に Escape を送る。現行 claude (v2.1.16x 実機確認 2026-06-08) は
881
+ * 「Esc again to clear」の 2 段階クリアなので、在中が続く限り短間隔で Escape を
882
+ * 繰り返す (連続 2 打がヒント窓内に収まりクリアされる。旧版の 1 打クリアなら
883
+ * 1 回目の後の照合で抜ける)。**毎回在中を確認してから打つ**ため、空入力への
884
+ * Esc 連打 (= rewind ダイアログが開く) は起きない。
885
+ * 3. C-c は使わない。C-c はクリアと同時に exit を 1 段プライムするため、frontend 側の
886
+ * 旧 agent フォールバック (\x03) と近接すると 2 連 C-c で claude ごと終了し得る。
887
+ *
888
+ * deadline までに本文が現れなければ「復元は起きなかった」(= 応答開始後の通常中断や
889
+ * 復元しない claude バージョン) として cleared:false を返す。ベストエフォートで throw しない。
890
+ *
891
+ * @param {string} name tmux セッション名
892
+ * @param {string} expectText 入力ボックスへ戻っていると期待する本文 (キャンセルした送信本文)
893
+ * @param {{logger?:object,tmuxBin?:string,deadlineMs?:number}} [opts]
894
+ * @returns {Promise<{ok:boolean, cleared:boolean, error?:string}>}
895
+ */
896
+ export async function recoverTuiInput(name, expectText, opts = {}) {
897
+ const bin = tmuxBin(opts)
898
+ // 折り返し (tmux はグリッド幅で行を割る) を跨いでも照合できるよう空白を全除去して
899
+ // 正規化し、先頭 40 文字を needle にする (長文はボックス内で truncate され得るため)。
900
+ const norm = (s) => String(s || "").replace(/\s+/g, "")
901
+ const needle = norm(expectText).slice(0, 40)
902
+ if (!needle) return { ok: true, cleared: false }
903
+ const deadline = Date.now() + (opts.deadlineMs ?? 1500)
904
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
905
+ const readBox = async () =>
906
+ detectInputBoxText(await capturePane(name, { noCache: true }))
907
+ const present = async () => {
908
+ const box = await readBox()
909
+ return !!box && norm(box).includes(needle)
910
+ }
911
+ try {
912
+ while (Date.now() < deadline) {
913
+ if (await present()) {
914
+ // 在中確認 → Escape → 再確認、を短間隔で繰り返す (最大 4 打)。現行 claude の
915
+ // 「Esc again to clear」窓 (>= 0.5s 実測) に 2 打目が収まりクリアされる。
916
+ // 在中でなくなった時点で打鍵をやめるので、空入力への Esc は送られない。
917
+ for (let attempt = 0; attempt < 4; attempt++) {
918
+ await execFileP(bin, ["send-keys", "-t", name, "Escape"])
919
+ await sleep(200)
920
+ if (!(await present())) {
921
+ opts.logger?.info(
922
+ { session: name, len: expectText?.length, attempt },
923
+ "tui recoverInput: cleared restored text",
924
+ )
925
+ return { ok: true, cleared: true }
926
+ }
927
+ }
928
+ // 4 打しても残っている = クリア不能 (想定外のバージョン差異)。状態を正直に返す。
929
+ opts.logger?.warn(
930
+ { session: name },
931
+ "tui recoverInput: text present but could not clear",
932
+ )
933
+ return { ok: true, cleared: false }
934
+ }
935
+ await sleep(120)
936
+ }
937
+ return { ok: true, cleared: false }
938
+ } catch (err) {
939
+ opts.logger?.warn(
940
+ { session: name, err: err?.message },
941
+ "recoverTuiInput failed",
942
+ )
943
+ return { ok: false, cleared: false, error: err?.message || String(err) }
944
+ }
945
+ }
946
+
863
947
  /**
864
948
  * tmux セッションの対話 claude を、指定 session_id に `--resume` で載せ替える
865
949
  * (T04784 TUI resume binding)。
@@ -66,6 +66,8 @@ export class TuiViewerRegistry {
66
66
  this.ttlSec = ttlSec
67
67
  this.logger = logger
68
68
  this._sweepTimer = null
69
+ /** atomic write の tmp 名を一意化するための単調増加カウンタ (race 回避)。 */
70
+ this._tmpSeq = 0
69
71
  }
70
72
 
71
73
  /** ディレクトリを作成し、起動時の残骸を掃除して周期 sweep を張る。 */
@@ -131,15 +133,29 @@ export class TuiViewerRegistry {
131
133
 
132
134
  /**
133
135
  * atomic write (tmp+rename)。書けなくても agent は落とさない。
136
+ *
137
+ * tmp 名は ``<fp>.<pid>.<seq>.tmp`` で **書込ごとに一意化**する。固定 ``<fp>.tmp`` を
138
+ * 使うと、同一マーカー (同 session_id / 同 cwd) へ複数タブ・高頻度ハートビートが並行
139
+ * note() したとき、先勝ちの rename が tmp を移動した後に後発の rename が ENOENT で
140
+ * 失敗する (rename '<fp>.tmp' → '<fp>': no such file)。マーカー自体は勝者が書くので
141
+ * 致命的ではないが、ログを汚し鮮度更新を取りこぼす余地があった。一意 tmp なら各書込が
142
+ * 独立した tmp を rename するため衝突しない。
143
+ *
134
144
  * @param {string} fp
135
145
  * @param {string} body
136
146
  */
137
147
  async _atomicWrite(fp, body) {
138
- const tmp = `${fp}.tmp`
148
+ const tmp = `${fp}.${process.pid}.${++this._tmpSeq}.tmp`
139
149
  try {
140
150
  await writeFile(tmp, body)
141
151
  await rename(tmp, fp)
142
152
  } catch (err) {
153
+ // rename 前に書けた tmp が残りうるので掃除する (sweep は *.json のみ対象)。
154
+ try {
155
+ await unlink(tmp)
156
+ } catch {
157
+ /* 無ければ no-op */
158
+ }
143
159
  this.logger?.warn({ err: err?.message, fp }, "viewer marker write failed")
144
160
  }
145
161
  }