@cocorograph/hub-agent 0.6.50 → 0.6.52

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.50",
3
+ "version": "0.6.52",
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
@@ -58,6 +58,7 @@ import {
58
58
  listWorktreeStubs,
59
59
  rebindClaudeSession,
60
60
  removeWorktree as removeWorktreeDir,
61
+ resumeWithMessage,
61
62
  } from "./tmux.mjs"
62
63
  import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
63
64
  import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
@@ -73,6 +74,28 @@ import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
73
74
 
74
75
  const logger = pino({ name: "hub-agent" })
75
76
 
77
+ // 遅延回答 resume: 書き込んだ .decision がフックに消費されない (= フック死亡) かを
78
+ // 判定する猶予 (ms)。生存フックは ~0.15s で消費するので 2s 残れば死亡とみなす。
79
+ const RESUME_DECISION_GRACE_MS = 2000
80
+ // resume の二重実行防止 (request_id -> 実施時刻ms)。低頻度なので Map で十分。
81
+ const _tuiResumed = new Map()
82
+ function _markResumed(requestId) {
83
+ const now = Date.now()
84
+ // 古い項目を掃除 (10 分超)。
85
+ for (const [id, at] of _tuiResumed) {
86
+ if (now - at > 600000) _tuiResumed.delete(id)
87
+ }
88
+ if (_tuiResumed.has(requestId)) return false
89
+ _tuiResumed.set(requestId, now)
90
+ return true
91
+ }
92
+ /** 遅延回答を新メッセージとして tmux セッションへ届けて会話を再開する。 */
93
+ async function _resumeTuiAnswer(sessionName, resumeText, requestId) {
94
+ if (!sessionName || !resumeText) return
95
+ if (!_markResumed(requestId)) return // 二重実行防止
96
+ await resumeWithMessage(sessionName, resumeText, { logger })
97
+ }
98
+
76
99
  const BUNDLE_MANIFEST_PATH =
77
100
  process.env.HUB_BUNDLE_MANIFEST ||
78
101
  path.join(os.homedir(), ".claude", "scripts", "manifest.json")
@@ -1052,6 +1075,32 @@ async function dispatch(msg, ctx) {
1052
1075
  msg.updated_input,
1053
1076
  )
1054
1077
  : false
1078
+ // 遅延回答 resume (frontend が session_name + resume_text を載せた TUI 回答のみ)。
1079
+ // フックが既に死んでいて即時確定 (.decision 注入) が届かない場合、回答を新メッセージ
1080
+ // として同一セッションへ送り、永続 claude が文脈ごと続行できるようにする。
1081
+ const resumeText =
1082
+ typeof msg.resume_text === "string" ? msg.resume_text : ""
1083
+ const resumeSession =
1084
+ typeof msg.session_name === "string" ? msg.session_name : ""
1085
+ if (resumeText && resumeSession && ctx.tuiPermissionBridge) {
1086
+ if (!handledByTui) {
1087
+ // 期限切れ (pending に無い) → 即 resume。再配信しないよう drop。
1088
+ ctx.tuiPermissionBridge.drop(msg.request_id)
1089
+ void _resumeTuiAnswer(resumeSession, resumeText, msg.request_id)
1090
+ } else {
1091
+ // pending だった: 生存フックは ~0.15s で .decision を消費する。猶予後も
1092
+ // 残っていれば フック死亡 (Claude Code timeout で kill 等) → resume へ。
1093
+ const reqId = msg.request_id
1094
+ setTimeout(() => {
1095
+ if (ctx.tuiPermissionBridge?.hasUnconsumedDecision(reqId)) {
1096
+ ctx.tuiPermissionBridge.dropDecision(reqId).catch(() => {})
1097
+ ctx.tuiPermissionBridge.drop(reqId)
1098
+ void _resumeTuiAnswer(resumeSession, resumeText, reqId)
1099
+ }
1100
+ }, RESUME_DECISION_GRACE_MS)
1101
+ }
1102
+ return
1103
+ }
1055
1104
  if (handledByTui) return
1056
1105
  if (!ctx.claudeBridge) return
1057
1106
  ctx.claudeBridge.permissionReply({
@@ -1206,6 +1255,38 @@ async function dispatch(msg, ctx) {
1206
1255
  })()
1207
1256
  return
1208
1257
  }
1258
+ case "claude.tui.rehydratePermissions": {
1259
+ // セッション切替でビューが再マウントすると、その時点の承認/質問カードは React
1260
+ // state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
1261
+ // browser が再購読時にこれを送り、該当セッションの未応答リクエストを再 push して
1262
+ // カードを復元する (frontend は request_id で重複排除)。
1263
+ if (!ctx.tuiPermissionBridge) return
1264
+ const sid =
1265
+ typeof msg.session_id === "string" ? msg.session_id : null
1266
+ const reqCwd = typeof msg.cwd === "string" ? msg.cwd : null
1267
+ const pend = ctx.tuiPermissionBridge.listPending({
1268
+ session_id: sid,
1269
+ cwd: reqCwd,
1270
+ })
1271
+ for (const p of pend) {
1272
+ ctx.client.send({
1273
+ type: "claude.permission.request",
1274
+ stream_id: null,
1275
+ session_id: p.session_id,
1276
+ cwd: p.cwd,
1277
+ request_id: p.request_id,
1278
+ tool_name: p.tool_name,
1279
+ input: p.input,
1280
+ })
1281
+ }
1282
+ if (pend.length) {
1283
+ logger.info(
1284
+ { session_id: sid, cwd: reqCwd, count: pend.length },
1285
+ "tui permission re-hydrated to browser",
1286
+ )
1287
+ }
1288
+ return
1289
+ }
1209
1290
  case "claude.tui.bind": {
1210
1291
  // T04784 会話継続バインディング: TUI モード突入時に 1 回送られる。
1211
1292
  // 1. 対象 cwd の最新 session_id を確定 (browser 指定があれば優先)。
package/src/tmux.mjs CHANGED
@@ -716,6 +716,52 @@ export function buildResumeCmd(sessionId, opts = {}) {
716
716
 
717
717
  const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
718
718
 
719
+ /**
720
+ * 遅延回答 resume: 承認/質問カードへの回答がフックの生存中に間に合わなかったとき、
721
+ * 回答を「新しいユーザーメッセージ」として同一 tmux セッションの対話 claude へ届ける。
722
+ *
723
+ * TUI バックエンドの claude は tmux 上で永続し会話文脈を保持するため、回答を新メッセージ
724
+ * として送れば「離席→タイムアウト」後でも文脈を引き継いで続行できる (ユーザー指示の設計)。
725
+ *
726
+ * フックがタイムアウトすると claude は native の質問/承認 UI を pane に出してターンを
727
+ * 止めている。先に Escape を送ってそれを畳み (素の入力欄では無害)、少し待ってから回答
728
+ * テキストを送って Enter で確定する。テキストは send-keys -l で「文字列リテラル」として
729
+ * 送り、改行は空白へ畳んで単一行にする (誤確定防止)。ベストエフォート。
730
+ *
731
+ * @param {string} name tmux セッション名
732
+ * @param {string} text 送る回答メッセージ (人間可読)
733
+ * @param {{logger?:object,tmuxBin?:string}} [opts]
734
+ * @returns {Promise<{ok:boolean, error?:string}>}
735
+ */
736
+ export async function resumeWithMessage(name, text, opts = {}) {
737
+ const bin = tmuxBin(opts)
738
+ const line = String(text || "")
739
+ .replace(/[\r\n]+/g, " ")
740
+ .trim()
741
+ if (!line) return { ok: false, error: "empty resume text" }
742
+ try {
743
+ // 1) native の質問/承認 UI を畳む (素の入力欄なら入力クリアで無害)。
744
+ await execFileP(bin, ["send-keys", "-t", name, "Escape"])
745
+ await _delay(200)
746
+ // 2) 回答テキストをリテラルで送る (-l でキー名解釈を避ける)。
747
+ await execFileP(bin, ["send-keys", "-t", name, "-l", line])
748
+ await _delay(120)
749
+ // 3) Enter で確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
750
+ await execFileP(bin, ["send-keys", "-t", name, "Enter"])
751
+ opts.logger?.info(
752
+ { session: name, len: line.length },
753
+ "tui resume: delivered late answer as new message",
754
+ )
755
+ return { ok: true }
756
+ } catch (err) {
757
+ opts.logger?.warn(
758
+ { session: name, err: err?.message },
759
+ "resumeWithMessage failed",
760
+ )
761
+ return { ok: false, error: err?.message || String(err) }
762
+ }
763
+ }
764
+
719
765
  /**
720
766
  * 対話 claude TUI に shift+tab (BACKTAB) を 1 回送って権限モードを循環させる。
721
767
  *
@@ -15,15 +15,24 @@
15
15
  */
16
16
 
17
17
  import { EventEmitter } from "node:events"
18
- import { watch } from "node:fs"
18
+ import { watch, existsSync } from "node:fs"
19
19
  import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
20
20
  import path from "node:path"
21
21
 
22
22
  export const PERMISSION_REQUESTS_DIR =
23
23
  process.env.COCKPIT_PERMISSION_DIR || "/tmp/cockpit_permission_requests"
24
24
 
25
- /** pending request の TTL。これを超えた seen エントリは GC して再発火を許す。 */
26
- const PENDING_TTL_MS = 5 * 60 * 1000
25
+ /**
26
+ * pending request TTL。これを超えた seen/pending エントリは GC する。
27
+ *
28
+ * 旧 5 分から延長 (6h)。理由 2 点:
29
+ * - 遅延回答 resume: フックが死んだ後でもユーザーが戻って回答→新メッセージで再開できる
30
+ * よう、回答可能な状態を長く保つ (resolve は 2s の .decision 未消費判定で resume へ倒す
31
+ * ので、_pending を長く保っても二重確定は起きない)。
32
+ * - 承認カードの re-hydrate: セッション切替でビューが再マウントしてもカードを復元できるよう、
33
+ * 未応答リクエストのペイロードを保持しておく (listPending)。
34
+ */
35
+ const PENDING_TTL_MS = Number(process.env.COCKPIT_PENDING_TTL_MS) || 6 * 60 * 60 * 1000
27
36
 
28
37
  /**
29
38
  * TUI フック ⇄ browser の権限往復をファイル IPC で仲介する。
@@ -41,7 +50,7 @@ export class TuiPermissionBridge extends EventEmitter {
41
50
  super()
42
51
  this.dir = dir
43
52
  this.logger = logger
44
- /** @type {Map<string, number>} request_id -> 受理時刻(ms) */
53
+ /** @type {Map<string, {payload: object, at: number}>} request_id -> ペイロード + 受理時刻(ms) */
45
54
  this._pending = new Map()
46
55
  /** @type {Set<string>} 二重発火防止 (request_id) */
47
56
  this._seen = new Set()
@@ -96,14 +105,43 @@ export class TuiPermissionBridge extends EventEmitter {
96
105
  }
97
106
  this._gc()
98
107
  this._seen.add(request_id)
99
- this._pending.set(request_id, Date.now())
100
- this.emit("permission", {
108
+ const payload = {
101
109
  request_id,
102
110
  session_id: body.session_id ?? null,
103
111
  cwd: body.cwd ?? null,
104
112
  tool_name: body.tool_name ?? "",
105
113
  input: body.tool_input ?? {},
106
- })
114
+ }
115
+ // payload も保持する (セッション切替でビュー再マウント時の re-hydrate / listPending 用)。
116
+ this._pending.set(request_id, { payload, at: Date.now() })
117
+ this.emit("permission", payload)
118
+ }
119
+
120
+ /**
121
+ * 指定セッション宛の未応答リクエストのペイロード一覧を返す (re-hydrate 用)。
122
+ *
123
+ * セッション切替でビューが再マウントすると、その時点で持っていた承認/質問カードは
124
+ * React state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
125
+ * 戻ってきたときに復元できるよう、browser が再購読時にこれを引いて未応答カードを
126
+ * 再配信する。session_id 一致を主、cwd 一致を従にして絞る。
127
+ *
128
+ * @param {{session_id?: string|null, cwd?: string|null}} q
129
+ * @returns {object[]} emit と同形のペイロード配列
130
+ */
131
+ listPending({ session_id, cwd } = {}) {
132
+ const out = []
133
+ for (const { payload } of this._pending.values()) {
134
+ const sidOk = session_id && payload.session_id === session_id
135
+ const cwdOk = cwd && payload.cwd === cwd
136
+ if (sidOk || cwdOk) out.push(payload)
137
+ }
138
+ return out
139
+ }
140
+
141
+ /** request を pending から完全に落とす (resume 等で「もう再配信しない」とき)。 */
142
+ drop(request_id) {
143
+ this._pending.delete(request_id)
144
+ this._seen.delete(request_id)
107
145
  }
108
146
 
109
147
  /**
@@ -150,11 +188,36 @@ export class TuiPermissionBridge extends EventEmitter {
150
188
  return true
151
189
  }
152
190
 
191
+ /**
192
+ * 書き込んだ ``.decision`` がまだ消費されていないか判定する (遅延回答 resume 用)。
193
+ *
194
+ * フックが生存していれば ~0.15s のポーリングで ``.decision`` を読んで即 unlink する
195
+ * (_poll_decision の _cleanup)。書き込み後しばらく経っても ``.decision`` が残っている
196
+ * = フックは既に死んでいる (Claude Code のフック timeout で kill / TTL 経過で abstain
197
+ * 済み) と判定でき、呼び出し側は「回答を新メッセージとしてセッションへ届ける resume」
198
+ * へフォールバックする。
199
+ *
200
+ * @param {string} request_id
201
+ * @returns {boolean} ``.decision`` が残っていれば true (= 未消費 = フック死亡)
202
+ */
203
+ hasUnconsumedDecision(request_id) {
204
+ return existsSync(path.join(this.dir, `${request_id}.decision`))
205
+ }
206
+
207
+ /** 未消費の ``.decision`` を掃除する (resume へフォールバックしたとき呼ぶ)。 */
208
+ async dropDecision(request_id) {
209
+ try {
210
+ await unlink(path.join(this.dir, `${request_id}.decision`))
211
+ } catch {
212
+ /* 既に無ければ no-op */
213
+ }
214
+ }
215
+
153
216
  /** TTL 超過の seen/pending を掃除する。 */
154
217
  _gc() {
155
218
  const now = Date.now()
156
- for (const [id, at] of this._pending) {
157
- if (now - at > PENDING_TTL_MS) {
219
+ for (const [id, entry] of this._pending) {
220
+ if (now - entry.at > PENDING_TTL_MS) {
158
221
  this._pending.delete(id)
159
222
  this._seen.delete(id)
160
223
  }