@cocorograph/hub-agent 0.6.49 → 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.49",
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
@@ -31,7 +31,11 @@ import {
31
31
  } from "./claude-history.mjs"
32
32
  import { listAgents } from "./agents.mjs"
33
33
  import { listSkills } from "./skills.mjs"
34
- import { listSessionStates } from "./state.mjs"
34
+ import {
35
+ capturePane,
36
+ detectPermissionModeFromText,
37
+ listSessionStates,
38
+ } from "./state.mjs"
35
39
  import {
36
40
  DEFAULT_PROFILE_ID,
37
41
  defaultConfigDir,
@@ -45,6 +49,7 @@ import {
45
49
  buildClaudeCmd,
46
50
  createSession as createTmuxSession,
47
51
  createWorktreeDir,
52
+ cyclePermissionMode,
48
53
  execTmux,
49
54
  killManySessions,
50
55
  killSession as killTmuxSession,
@@ -53,6 +58,7 @@ import {
53
58
  listWorktreeStubs,
54
59
  rebindClaudeSession,
55
60
  removeWorktree as removeWorktreeDir,
61
+ resumeWithMessage,
56
62
  } from "./tmux.mjs"
57
63
  import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
58
64
  import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
@@ -68,6 +74,28 @@ import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
68
74
 
69
75
  const logger = pino({ name: "hub-agent" })
70
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
+
71
99
  const BUNDLE_MANIFEST_PATH =
72
100
  process.env.HUB_BUNDLE_MANIFEST ||
73
101
  path.join(os.homedir(), ".claude", "scripts", "manifest.json")
@@ -711,6 +739,9 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
711
739
  for (const s of states) {
712
740
  let status = s.status
713
741
  let contextPct = s.context_pct
742
+ // 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
743
+ // tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
744
+ const permissionMode = s.permission_mode ?? null
714
745
  // チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
715
746
  // session の cwd に一致する新鮮なチャット信号があれば status/context% を
716
747
  // 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
@@ -738,17 +769,20 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
738
769
  if (
739
770
  !prev ||
740
771
  prev.status !== status ||
741
- prev.context_pct !== contextPct
772
+ prev.context_pct !== contextPct ||
773
+ prev.permission_mode !== permissionMode
742
774
  ) {
743
775
  lastByName.set(s.session_name, {
744
776
  status,
745
777
  context_pct: contextPct,
778
+ permission_mode: permissionMode,
746
779
  })
747
780
  client.send({
748
781
  type: "session.state",
749
782
  session_name: s.session_name,
750
783
  status,
751
784
  context_pct: contextPct,
785
+ permission_mode: permissionMode,
752
786
  })
753
787
  }
754
788
  }
@@ -1041,6 +1075,32 @@ async function dispatch(msg, ctx) {
1041
1075
  msg.updated_input,
1042
1076
  )
1043
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
+ }
1044
1104
  if (handledByTui) return
1045
1105
  if (!ctx.claudeBridge) return
1046
1106
  ctx.claudeBridge.permissionReply({
@@ -1127,6 +1187,106 @@ async function dispatch(msg, ctx) {
1127
1187
  // jsonl tail も即停止 (閲覧していない session を追従し続けない)。
1128
1188
  ctx.jsonlLiveWatchers?.unwatch({ session_id: msg.session_id })
1129
1189
  return
1190
+ case "claude.tui.cyclePermission": {
1191
+ // 権限バッジ押下 → 対話 claude TUI へ shift+tab を送って権限モードを循環。
1192
+ // 旧実装は frontend が raw pty.data (BACKTAB) を送り、各端末が楽観値を
1193
+ // 個別に 1 段進めるだけだったため、複数端末間でズレ、かつ tmux 上で直接
1194
+ // 切り替えた変更とも乖離した。ここで agent が実キー送出 → ペイン再読込で
1195
+ // 実モードを確定 → 全ブラウザへ claude.tui.permission を broadcast し、
1196
+ // 「実際に動いているターミナルの状態」を正本として全端末に同期する。
1197
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
1198
+ const sessionName =
1199
+ typeof msg.session_name === "string" ? msg.session_name : ""
1200
+ if (!sessionName) return
1201
+ ;(async () => {
1202
+ try {
1203
+ await cyclePermissionMode(sessionName, { logger })
1204
+ // TUI がフッターバナーを再描画するまで少し待ってから実モードを読む。
1205
+ await new Promise((r) => setTimeout(r, 250))
1206
+ const text = await capturePane(sessionName, { noCache: true })
1207
+ const mode = detectPermissionModeFromText(text)
1208
+ ctx.client.send({
1209
+ type: "claude.tui.permission",
1210
+ cwd: cwd || undefined,
1211
+ session_name: sessionName,
1212
+ permission_mode: mode,
1213
+ })
1214
+ logger.info(
1215
+ { session: sessionName, cwd, mode },
1216
+ "tui permission cycled → notified browser",
1217
+ )
1218
+ } catch (err) {
1219
+ logger.warn(
1220
+ { err: err?.message, session: sessionName },
1221
+ "claude.tui.cyclePermission failed",
1222
+ )
1223
+ }
1224
+ })()
1225
+ return
1226
+ }
1227
+ case "claude.tui.probePermission": {
1228
+ // 読み取り専用の権限モード問い合わせ (cold-load seed)。キーは送らず、ペインを
1229
+ // 読んで現在の実モードを claude.tui.permission として broadcast するだけ。
1230
+ // ブラウザがマウント直後に「今ターミナルが何モードか」を取得し、楽観値や
1231
+ // 古い jsonl 値とのズレを開始時点から揃えるために使う。検出不能 (claude 起動中・
1232
+ // 素のシェル) のときは mode=null となり、frontend 側は据え置く。
1233
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
1234
+ const sessionName =
1235
+ typeof msg.session_name === "string" ? msg.session_name : ""
1236
+ if (!sessionName) return
1237
+ ;(async () => {
1238
+ try {
1239
+ const text = await capturePane(sessionName, { noCache: true })
1240
+ const mode = detectPermissionModeFromText(text)
1241
+ // 検出できた時だけ通知する (null で badge を消さない)。
1242
+ if (!mode) return
1243
+ ctx.client.send({
1244
+ type: "claude.tui.permission",
1245
+ cwd: cwd || undefined,
1246
+ session_name: sessionName,
1247
+ permission_mode: mode,
1248
+ })
1249
+ } catch (err) {
1250
+ logger.warn(
1251
+ { err: err?.message, session: sessionName },
1252
+ "claude.tui.probePermission failed",
1253
+ )
1254
+ }
1255
+ })()
1256
+ return
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
+ }
1130
1290
  case "claude.tui.bind": {
1131
1291
  // T04784 会話継続バインディング: TUI モード突入時に 1 回送られる。
1132
1292
  // 1. 対象 cwd の最新 session_id を確定 (browser 指定があれば優先)。
package/src/state.mjs CHANGED
@@ -82,6 +82,43 @@ export function detectContextPctFromText(text) {
82
82
  return null
83
83
  }
84
84
 
85
+ // 対話 claude TUI のフッターに出る権限モードバナー (claude v2.1 系で実機確認)。
86
+ // shift+tab で循環: default(バナー無し) → accept edits → plan → auto → …
87
+ const PERMISSION_BANNERS = [
88
+ [/⏵⏵\s*accept edits on/i, "acceptEdits"],
89
+ [/⏸\s*plan mode on/i, "plan"],
90
+ [/⏵⏵\s*auto mode on/i, "auto"],
91
+ [/bypass permissions on/i, "bypassPermissions"],
92
+ ]
93
+
94
+ /**
95
+ * 対話 claude TUI のペインテキストから現在の権限モードを判定する。
96
+ *
97
+ * 全端末で「実際に動いているターミナルの権限状態」を同期するための正本。
98
+ * jsonl の permissionMode は送信ターン毎にしか記録されないため、shift+tab した
99
+ * だけ (未送信) の変更や tmux 上で直接切り替えた変更は jsonl に出ない。ペインの
100
+ * フッターバナーは shift+tab 直後に即更新されるので、これを正本にする。
101
+ *
102
+ * - 明示バナー (accept edits / plan / auto / bypass) があればそのモード。
103
+ * - バナーが無い場合、claude TUI のフッターが見えている時だけ "default" と判定する
104
+ * (素のシェルを default と誤検出しないためのガード)。
105
+ * - claude TUI と判別できなければ null (= 不明、呼び出し側は据え置き)。
106
+ *
107
+ * @param {string} text capturePane の結果 (ANSI 除去済み)
108
+ * @returns {string|null}
109
+ */
110
+ export function detectPermissionModeFromText(text) {
111
+ if (!text) return null
112
+ for (const [re, mode] of PERMISSION_BANNERS) {
113
+ if (re.test(text)) return mode
114
+ }
115
+ // バナー無し = default。ただし claude TUI のフッターが見えている時だけ assert する。
116
+ if (/← for agents|for shortcuts|shift\+tab to cycle/i.test(text)) {
117
+ return "default"
118
+ }
119
+ return null
120
+ }
121
+
85
122
  export async function listSessionNames(opts = {}) {
86
123
  const tmuxBin = opts.tmuxBin || "tmux"
87
124
  try {
@@ -165,6 +202,7 @@ export async function detectSessionState(sessionName, opts = {}) {
165
202
  const text = await capturePane(sessionName, opts)
166
203
  const defaultStatus = detectStatusFromText(text)
167
204
  const defaultContextPct = detectContextPctFromText(text)
205
+ const defaultPermissionMode = detectPermissionModeFromText(text)
168
206
 
169
207
  if (opts.plugins && opts.plugins.length) {
170
208
  const hookResult = await runHookChain(opts.plugins, "transformStatusDetection", {
@@ -177,11 +215,17 @@ export async function detectSessionState(sessionName, opts = {}) {
177
215
  return {
178
216
  status: hookResult.result.status || defaultStatus,
179
217
  context_pct: hookResult.result.context_pct ?? defaultContextPct,
218
+ permission_mode:
219
+ hookResult.result.permission_mode ?? defaultPermissionMode,
180
220
  }
181
221
  }
182
222
  }
183
223
 
184
- return { status: defaultStatus, context_pct: defaultContextPct }
224
+ return {
225
+ status: defaultStatus,
226
+ context_pct: defaultContextPct,
227
+ permission_mode: defaultPermissionMode,
228
+ }
185
229
  }
186
230
 
187
231
  /** 全 session の現在状態を取得する。cwd も含める (chat 信号照合用)。 */
package/src/tmux.mjs CHANGED
@@ -716,6 +716,99 @@ 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
+
765
+ /**
766
+ * 対話 claude TUI に shift+tab (BACKTAB) を 1 回送って権限モードを循環させる。
767
+ *
768
+ * 全端末で「実際に動いているターミナルの権限状態」を同期する仕組みの書込側
769
+ * (読込側は state.mjs の detectPermissionModeFromText)。フロントからの権限バッジ
770
+ * 押下は raw pty.data ではなく `claude.tui.cyclePermission` を送り、agent 側で
771
+ * 本関数を実行 → ペイン再読込で実モードを確定 → 全ブラウザへ broadcast する。
772
+ * これにより楽観値が端末間でズレず、jsonl 未記録 (未送信) の変更も即同期される。
773
+ *
774
+ * copy-mode 等に入っているとキーが奪われるので、入っていれば先に抜ける
775
+ * (フロントの cancelTmuxMode 相当を agent 側でも担保)。ベストエフォート。
776
+ *
777
+ * @param {string} name tmux セッション名
778
+ * @param {{logger?:object,tmuxBin?:string}} [opts]
779
+ * @returns {Promise<{ok:boolean, error?:string}>}
780
+ */
781
+ export async function cyclePermissionMode(name, opts = {}) {
782
+ const bin = tmuxBin(opts)
783
+ try {
784
+ // copy-mode 等に入っていると BTab が奪われるので、入っている時だけ抜ける。
785
+ try {
786
+ const { stdout } = await execFileP(bin, [
787
+ "display-message",
788
+ "-p",
789
+ "-t",
790
+ `${name}:`,
791
+ "-F",
792
+ "#{pane_in_mode}",
793
+ ])
794
+ if (stdout.trim() === "1") {
795
+ await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
796
+ }
797
+ } catch {
798
+ // pane_in_mode 取得失敗はベストエフォートで無視 (そのまま BTab を送る)。
799
+ }
800
+ // BTab = shift+tab。claude TUI が権限モードを 1 段循環する。
801
+ await execFileP(bin, ["send-keys", "-t", name, "BTab"])
802
+ return { ok: true }
803
+ } catch (err) {
804
+ opts.logger?.warn(
805
+ { session: name, err: err?.message },
806
+ "cyclePermissionMode failed",
807
+ )
808
+ return { ok: false, error: err?.message || String(err) }
809
+ }
810
+ }
811
+
719
812
  /**
720
813
  * tmux セッションの対話 claude を、指定 session_id に `--resume` で載せ替える
721
814
  * (T04784 TUI resume binding)。
@@ -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
  }