@cocorograph/hub-agent 0.6.50 → 0.6.53

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.53",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -256,22 +256,50 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
256
256
  * - `viewingSessionId`: フロントが今表示している (閲覧ハートビートが運ぶ) session_id。
257
257
  * - `newestSessionId`: cwd 配下で実際に最新 (mtime 降順先頭) の session_id。
258
258
  * - `lastNotifiedNewId`: 同一 new への多重通知を防ぐため、最後に通知した new_session_id。
259
+ * - `lastNotifiedAt` / `now`: 再通知スロットリング用のタイムスタンプ (ms)。両方与えられた
260
+ * ときだけ時間判定が効く (無ければ従来どおり「同一 new は一度きり」)。
261
+ * - `reNotifyMs`: 同一 new でも再通知を許す最小間隔 (既定 4000ms)。
262
+ *
263
+ * 回転とみなす条件:
264
+ * 最新が存在し、閲覧中 id と異なり、かつ次のいずれか:
265
+ * (a) 最後に通知した new と異なる (= さらに別 new へ回転した) → 即通知。
266
+ * (b) 最後に通知した new と同じだが、`reNotifyMs` 以上経過している → **再通知**。
267
+ *
268
+ * (b) が肝。回転通知は one-shot broadcast で、多タブ・再接続・hidden タブ等で取りこぼすと
269
+ * クライアントが旧 session に**恒久固着**する (ゾンビ閲覧者バグ)。viewer が依然 stale な
270
+ * session_id を報告し続けている (= newest と不一致) という事実は「まだ切り替わっていない」
271
+ * 証拠なので、スロットリングしつつ繰り返し通知して救済する。切り替わった viewer は次の
272
+ * ハートビートで viewingSessionId === newest を報告し、条件 (最新≠閲覧中) を満たさなくなる
273
+ * ため自然に再通知が止まる。`reNotifyMs` をハートビート間隔 (5s) よりやや短くしておくと、
274
+ * 固着している間は毎ハートビートで 1 回ずつ通知が飛ぶ。
259
275
  *
260
- * 回転とみなす条件: 最新が存在し、閲覧中 id と異なり、かつ直近で同じ new を通知済みでない。
261
276
  * 注意: 過去セッションを意図的に開いている (= newest 非追従) ビューでは呼び出し側が
262
277
  * `follow_newest=false` でこの判定自体をスキップすること (ピン留め閲覧を勝手に最新へ
263
278
  * 引きずらないため)。本関数は「追従中ビュー」前提で newest とのズレだけを見る。
264
279
  *
265
- * @param {{viewingSessionId?: string|null, newestSessionId?: string|null, lastNotifiedNewId?: string|null}} args
280
+ * @param {{viewingSessionId?: string|null, newestSessionId?: string|null, lastNotifiedNewId?: string|null, lastNotifiedAt?: number|null, now?: number|null, reNotifyMs?: number}} args
266
281
  * @returns {{rotated: boolean, newSessionId?: string}}
267
282
  */
268
283
  export function decideSessionRotation({
269
284
  viewingSessionId,
270
285
  newestSessionId,
271
286
  lastNotifiedNewId,
287
+ lastNotifiedAt,
288
+ now,
289
+ reNotifyMs = 4000,
272
290
  } = {}) {
273
291
  if (!newestSessionId || !viewingSessionId) return { rotated: false }
274
292
  if (newestSessionId === viewingSessionId) return { rotated: false }
275
- if (newestSessionId === lastNotifiedNewId) return { rotated: false }
293
+ if (newestSessionId === lastNotifiedNewId) {
294
+ // 同一 new。時刻情報が揃っていて再通知間隔を超えていれば、固着救済のため再通知する。
295
+ if (
296
+ typeof now === "number" &&
297
+ typeof lastNotifiedAt === "number" &&
298
+ now - lastNotifiedAt >= reNotifyMs
299
+ ) {
300
+ return { rotated: true, newSessionId: newestSessionId }
301
+ }
302
+ return { rotated: false }
303
+ }
276
304
  return { rotated: true, newSessionId: newestSessionId }
277
305
  }
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({
@@ -1099,13 +1148,22 @@ async function dispatch(msg, ctx) {
1099
1148
  })
1100
1149
  const newestId = sessions?.[0]?.session_id || null
1101
1150
  const key = viewName || viewCwd
1151
+ const prev = ctx.tuiRotationNotified.get(key)
1152
+ const now = Date.now()
1102
1153
  const { rotated, newSessionId } = decideSessionRotation({
1103
1154
  viewingSessionId: viewSid,
1104
1155
  newestSessionId: newestId,
1105
- lastNotifiedNewId: ctx.tuiRotationNotified.get(key),
1156
+ // 旧形式 (文字列) と新形式 ({newId, ts}) の両対応。
1157
+ lastNotifiedNewId:
1158
+ prev && typeof prev === "object" ? prev.newId : prev,
1159
+ lastNotifiedAt:
1160
+ prev && typeof prev === "object" ? prev.ts : null,
1161
+ now,
1106
1162
  })
1107
1163
  if (!rotated) return
1108
- ctx.tuiRotationNotified.set(key, newSessionId)
1164
+ // {newId, ts} で保存し、同一 new への再通知をスロットリングする
1165
+ // (固着した viewer は throttle 間隔ごとに 1 回ずつ救済通知を受ける)。
1166
+ ctx.tuiRotationNotified.set(key, { newId: newSessionId, ts: now })
1109
1167
  // 冪等マップも新 id へ更新し、将来の remount bind が旧 id へ
1110
1168
  // respawn (claude 再起動) してしまうのを防ぐ。
1111
1169
  if (viewName) ctx.tuiReboundSessions.set(viewName, newSessionId)
@@ -1206,6 +1264,38 @@ async function dispatch(msg, ctx) {
1206
1264
  })()
1207
1265
  return
1208
1266
  }
1267
+ case "claude.tui.rehydratePermissions": {
1268
+ // セッション切替でビューが再マウントすると、その時点の承認/質問カードは React
1269
+ // state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
1270
+ // browser が再購読時にこれを送り、該当セッションの未応答リクエストを再 push して
1271
+ // カードを復元する (frontend は request_id で重複排除)。
1272
+ if (!ctx.tuiPermissionBridge) return
1273
+ const sid =
1274
+ typeof msg.session_id === "string" ? msg.session_id : null
1275
+ const reqCwd = typeof msg.cwd === "string" ? msg.cwd : null
1276
+ const pend = ctx.tuiPermissionBridge.listPending({
1277
+ session_id: sid,
1278
+ cwd: reqCwd,
1279
+ })
1280
+ for (const p of pend) {
1281
+ ctx.client.send({
1282
+ type: "claude.permission.request",
1283
+ stream_id: null,
1284
+ session_id: p.session_id,
1285
+ cwd: p.cwd,
1286
+ request_id: p.request_id,
1287
+ tool_name: p.tool_name,
1288
+ input: p.input,
1289
+ })
1290
+ }
1291
+ if (pend.length) {
1292
+ logger.info(
1293
+ { session_id: sid, cwd: reqCwd, count: pend.length },
1294
+ "tui permission re-hydrated to browser",
1295
+ )
1296
+ }
1297
+ return
1298
+ }
1209
1299
  case "claude.tui.bind": {
1210
1300
  // T04784 会話継続バインディング: TUI モード突入時に 1 回送られる。
1211
1301
  // 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
  }