@cocorograph/hub-agent 0.7.3 → 0.7.5

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.7.3",
3
+ "version": "0.7.5",
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
@@ -68,11 +68,13 @@ import {
68
68
  listSessions as listTmuxSessions,
69
69
  listWorktreeNameHistory,
70
70
  listWorktreeStubs,
71
+ isPaneRunningClaude,
71
72
  rebindClaudeSession,
72
73
  shouldSkipRebindRespawn,
73
74
  recoverTuiInput,
74
75
  removeWorktree as removeWorktreeDir,
75
76
  resumeWithMessage,
77
+ pasteToSessionByName,
76
78
  setSessionMouse,
77
79
  setTmuxGlobalEnv,
78
80
  setTuiEffort,
@@ -1432,6 +1434,13 @@ export function handleUntrackedPtyData(msg, ctx) {
1432
1434
  }
1433
1435
  }
1434
1436
 
1437
+ /**
1438
+ * 離席中キュー投入 (claude.tui.queue.flush) の冪等化用に、処理済み flush_id を記憶する。
1439
+ * WS 再送で同じ flush が二重に届いても二重 paste しないため。上限を超えたら全消去 (best-effort)。
1440
+ */
1441
+ const _seenFlushIds = new Set()
1442
+ const _SEEN_FLUSH_CAP = 2000
1443
+
1435
1444
  async function dispatch(msg, ctx) {
1436
1445
  const t = msg?.type || ""
1437
1446
  try {
@@ -1973,6 +1982,58 @@ async function dispatch(msg, ctx) {
1973
1982
  })()
1974
1983
  return
1975
1984
  }
1985
+ case "claude.tui.queue.flush": {
1986
+ // 離席中(非アクティブセッション)キュー自動投入 (案A の agent 側受け口)。
1987
+ // ブラウザの常駐ドレイン (useCockpitTuiQueueDrainAll) が、表示していないセッションの
1988
+ // 送信待ちキュー先頭 1 件をここへ送る。tmux send-keys (PTY 非依存) で投入するので、
1989
+ // そのセッションのターミナルを誰も開いていなくても届く。結果を flush.ack で返し、
1990
+ // ブラウザは ack を受けてはじめて localStorage から 1 件 dequeue する (冪等)。
1991
+ const sessionName =
1992
+ typeof msg.session_name === "string" ? msg.session_name : ""
1993
+ const text = typeof msg.text === "string" ? msg.text : ""
1994
+ const flushId = typeof msg.flush_id === "string" ? msg.flush_id : ""
1995
+ const ackFail = (reason) => {
1996
+ if (flushId && sessionName) {
1997
+ ctx.client.send({
1998
+ type: "claude.tui.queue.flush.ack",
1999
+ session_name: sessionName,
2000
+ flush_id: flushId,
2001
+ ok: false,
2002
+ error: reason,
2003
+ })
2004
+ }
2005
+ }
2006
+ if (!sessionName || !flushId || !text) {
2007
+ ackFail("missing session_name / flush_id / text")
2008
+ return
2009
+ }
2010
+ // 冪等化: 同一 flush_id を既に処理済みなら再 paste せず ok を返す (ブラウザは dequeue 済み
2011
+ // を再確認するだけ)。WS 再送・ack 取りこぼしによる二重投入を防ぐ。
2012
+ if (_seenFlushIds.has(flushId)) {
2013
+ ctx.client.send({
2014
+ type: "claude.tui.queue.flush.ack",
2015
+ session_name: sessionName,
2016
+ flush_id: flushId,
2017
+ ok: true,
2018
+ })
2019
+ return
2020
+ }
2021
+ ;(async () => {
2022
+ const result = await pasteToSessionByName(sessionName, text, { logger })
2023
+ if (result.ok) {
2024
+ if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
2025
+ _seenFlushIds.add(flushId)
2026
+ }
2027
+ ctx.client.send({
2028
+ type: "claude.tui.queue.flush.ack",
2029
+ session_name: sessionName,
2030
+ flush_id: flushId,
2031
+ ok: !!result.ok,
2032
+ error: result.ok ? undefined : result.error,
2033
+ })
2034
+ })()
2035
+ return
2036
+ }
1976
2037
  case "claude.tui.rehydratePermissions": {
1977
2038
  // セッション切替でビューが再マウントすると、その時点の承認/質問カードは React
1978
2039
  // state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
@@ -2080,13 +2141,23 @@ async function dispatch(msg, ctx) {
2080
2141
  // resume は targetId をキー、fresh は session_id 不在のため bind の request_id を
2081
2142
  // キーにする (同一マウントの再送は同 request_id で skip、新規要求は再マウントで
2082
2143
  // 新 request_id になり 1 回だけ起動する)。
2144
+ // 実プロセス由来の「claude 実行中か」。respawn-pane -k による生成中 claude の誤 kill
2145
+ // (症状D 再発 D2: armed 140s cap / capture gap で generating=false に倒れ、--resume bind
2146
+ // でも実行中 claude を kill していた)を、capture/armed に依存しない pane_current_command で防ぐ。
2147
+ const paneRunningClaude = await isPaneRunningClaude(sessionName, { logger })
2083
2148
  let rebind = { ok: false, skipped: false }
2084
2149
  const bindKey = fresh ? `fresh:${request_id || ""}` : targetId
2085
2150
  if (bindKey && sessionName && (fresh || targetId)) {
2086
2151
  if (ctx.tuiReboundSessions.get(sessionName) === bindKey) {
2087
2152
  rebind = { ok: true, skipped: true }
2088
2153
  } else if (
2089
- shouldSkipRebindRespawn({ generating, fresh, targetId, newestId })
2154
+ shouldSkipRebindRespawn({
2155
+ generating,
2156
+ fresh,
2157
+ targetId,
2158
+ newestId,
2159
+ paneRunningClaude,
2160
+ })
2090
2161
  ) {
2091
2162
  // 生成中 claude を再接続/remount の bind で kill しない (謎停止対策)。respawn を抑止して
2092
2163
  // 既存の生成中 claude を温存する。bindKey は記録して以降の remount を冪等化する
package/src/tmux.mjs CHANGED
@@ -796,6 +796,17 @@ export function isSafeSessionId(sessionId) {
796
796
  return typeof sessionId === "string" && /^[A-Za-z0-9_-]{8,128}$/.test(sessionId)
797
797
  }
798
798
 
799
+ /** tmux セッション名として安全な形だけ許可する。`-t <name>` の引数に流すため、先頭ハイフン
800
+ * (フラグ誤認) や予期せぬ文字を弾く。agent が作るセッション名は英数・`_`・`-`・`.` のみ。 */
801
+ export function isSafeSessionName(name) {
802
+ return typeof name === "string" && /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(name)
803
+ }
804
+
805
+ /** bracketed paste マーカーと貼り付け確定の待ち時間 (pasteToSessionByName 用)。 */
806
+ const PASTE_START = "\x1b[200~"
807
+ const PASTE_END = "\x1b[201~"
808
+ const PASTE_CONFIRM_DELAY_MS = 120
809
+
799
810
  /** agent 設定 (model / permissionMode) を claude のフラグ文字列に変換する ("" or "--model X ...")。 */
800
811
  export function composeClaudeFlags(opts = {}) {
801
812
  const model = (opts.model || "").trim()
@@ -953,6 +964,55 @@ export async function resumeWithMessage(name, text, opts = {}) {
953
964
  }
954
965
  }
955
966
 
967
+ /**
968
+ * 離席中キュー投入: 送信待ちメッセージ 1 件を、ブラウザがそのセッションのターミナルを
969
+ * マウントしていなくても tmux send-keys で対話 claude TUI へ投入する (案A の agent 側実体)。
970
+ *
971
+ * resumeWithMessage は改行を空白へ畳む単一行用 (誤確定防止) でチャット本文の複数行を壊すため、
972
+ * こちらは **bracketed paste** (ESC[200~ … ESC[201~) で本文を「貼り付け」として送り、改行を
973
+ * 保ったまま入力欄へ入れてから、確定の Enter (CR) を別途送る。これは frontend の通常送信
974
+ * (`\x1b[200~${text}\x1b[201~` → CR) と同じ手順を agent 側で再現したもの。PTY 非依存
975
+ * (tmux send-keys) なので、当該セッションのターミナルを誰も開いていなくても届く。
976
+ *
977
+ * @param {string} name tmux セッション名
978
+ * @param {string} text 投入する本文 (複数行可)
979
+ * @param {{logger?:object,tmuxBin?:string}} [opts]
980
+ * @returns {Promise<{ok:boolean, error?:string}>}
981
+ */
982
+ export async function pasteToSessionByName(name, text, opts = {}) {
983
+ const bin = tmuxBin(opts)
984
+ const body = String(text ?? "")
985
+ if (!body) return { ok: false, error: "empty paste text" }
986
+ if (!isSafeSessionName(name)) return { ok: false, error: "unsafe session name" }
987
+ try {
988
+ // copy-mode 等に入っているとキーが奪われるので先に抜ける (cyclePermission と同じ防御)。
989
+ try {
990
+ await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
991
+ } catch {
992
+ /* copy-mode でなければ "not in a mode" エラー = 無視 */
993
+ }
994
+ // bracketed paste の開始/終了マーカーと本文を literal (-l) で送る。改行を含む本文も
995
+ // 「貼り付け」として扱われ、Claude TUI は改行で確定しない (CR を別送するまで未確定)。
996
+ await execFileP(bin, ["send-keys", "-t", name, "-l", PASTE_START])
997
+ await execFileP(bin, ["send-keys", "-t", name, "-l", body])
998
+ await execFileP(bin, ["send-keys", "-t", name, "-l", PASTE_END])
999
+ // 貼り付けが入力欄へ反映される猶予を置いてから確定 (CR を本文より先着させない)。
1000
+ await _delay(PASTE_CONFIRM_DELAY_MS)
1001
+ await execFileP(bin, ["send-keys", "-t", name, "Enter"])
1002
+ opts.logger?.info(
1003
+ { session: name, len: body.length },
1004
+ "tui queue flush: pasted queued message (headless)",
1005
+ )
1006
+ return { ok: true }
1007
+ } catch (err) {
1008
+ opts.logger?.warn(
1009
+ { session: name, err: err?.message },
1010
+ "pasteToSessionByName failed",
1011
+ )
1012
+ return { ok: false, error: err?.message || String(err) }
1013
+ }
1014
+ }
1015
+
956
1016
  /**
957
1017
  * 対話 claude TUI に shift+tab (BACKTAB) を 1 回送って権限モードを循環させる。
958
1018
  *
@@ -1237,8 +1297,49 @@ export async function recoverTuiInput(name, expectText, opts = {}) {
1237
1297
  * @param {{generating?: boolean, fresh?: boolean, targetId?: string|null, newestId?: string|null}} a
1238
1298
  * @returns {boolean}
1239
1299
  */
1240
- export function shouldSkipRebindRespawn({ generating, fresh, targetId, newestId } = {}) {
1300
+ /**
1301
+ * ペインの前景プロセスが claude を実行中か(= respawn-pane -k で kill すると in-flight 応答を失う)
1302
+ * を pane_current_command から判定する。capture-pane スクレイプや armed(140s cap) の脆い推論に
1303
+ * 依存しない実プロセス由来の判定で、hub-agent 再起動・armed cap・capture gap を跨いで有効。
1304
+ * シェル(fish/bash/zsh/sh/dash/tmux/login)なら false。判定不能時は安全側 false(= respawn 許可)。
1305
+ */
1306
+ export async function isPaneRunningClaude(name, opts = {}) {
1307
+ if (!name) return false
1308
+ try {
1309
+ const { stdout } = await execFileP(tmuxBin(opts), [
1310
+ "display-message",
1311
+ "-p",
1312
+ "-t",
1313
+ `${name}:`,
1314
+ "-F",
1315
+ "#{pane_current_command}",
1316
+ ])
1317
+ const cmd = (stdout || "").trim().toLowerCase()
1318
+ if (!cmd) return false
1319
+ const SHELLS = new Set(["fish", "bash", "zsh", "sh", "dash", "tmux", "login"])
1320
+ // TUI チャットのペインで走るのは claude(claude / claude.exe / node 等)かシェルのいずれか。
1321
+ // シェルでない前景プロセス = claude 実行中とみなす(命名差 claude.exe/node に強い)。
1322
+ return !SHELLS.has(cmd)
1323
+ } catch {
1324
+ return false
1325
+ }
1326
+ }
1327
+
1328
+ export function shouldSkipRebindRespawn({
1329
+ generating,
1330
+ fresh,
1331
+ targetId,
1332
+ newestId,
1333
+ paneRunningClaude,
1334
+ } = {}) {
1241
1335
  if (fresh) return false
1336
+ // 実行中 claude を kill しない最優先ガード(症状D / 再発 D2 の根治): ペインが現に claude を
1337
+ // 実行中で、最新セッション(= 今動いている会話)への再 bind(remount/reconnect)なら、respawn
1338
+ // (respawn-pane -k) は破壊的なだけで不要。generating の脆い検出(isArmed 140s cap / capture gap)
1339
+ // で false に倒れても、実プロセス由来の paneRunningClaude が守る。targetId 未指定(= newest 解決前)
1340
+ // も「最新への再 bind」とみなす。明示的な別会話への切替(targetId !== newestId)と +新規(fresh)は
1341
+ // 従来どおり respawn する。
1342
+ if (paneRunningClaude && (!targetId || targetId === newestId)) return true
1242
1343
  if (!generating) return false
1243
1344
  return !!targetId && targetId === newestId
1244
1345
  }