@cocorograph/hub-agent 0.8.90 → 0.8.92

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.8.90",
3
+ "version": "0.8.92",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -203,13 +203,13 @@ const DEAD_TERMINAL_TTL_MS = readPositiveIntEnv(
203
203
  * タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm セッション(= SDK
204
204
  * 常駐 query プロセス)が N 個を超えて蓄積し得た。cwd 単位の LRU で最古 cwd から早期 reap する。
205
205
  * env HUB_AGENT_WARM_CWD_MAX で調整可能(codex と共有)。 */
206
- const WARM_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_CWD_MAX", 20)
206
+ export const WARM_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_CWD_MAX", 20)
207
207
 
208
208
  /** 1 cwd あたりの live セッション上限 K(既定 3)。旧仕様の per-cwd=1 を一般化したもの。
209
209
  * 「総量は増やさず配分の自由度だけ上げる」ため、総 live 上限 M(warm-bounds-total.mjs)と
210
210
  * セットで効かせる。⚠️ 既存 env(HUB_AGENT_WARM_CWD_MAX = 同時 warm 保持する cwd 数 N)の
211
211
  * 意味は変えない。新しい上限は新しい env 名で足す(codex 側の WARM_PER_CWD_MAX と対)。 */
212
- const WARM_PER_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_PER_CWD_MAX", 3)
212
+ export const WARM_PER_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_PER_CWD_MAX", 3)
213
213
 
214
214
  /** タスク実行中セッションを warm 境界の刈り取りから保護する際の**失効時間**(**鼓動が観測できる
215
215
  * 種別**用。既定 30 分)。「最後に生存を確認してから」の相対時間で、`task_progress` が届くたびに
@@ -89,7 +89,7 @@ function globalMutationMode(type) {
89
89
  // stream レーンへ、個別停止 (cwd を持つ) が cwd レーンへ散り、直列化されないまま同時に走る。
90
90
  // scheduler へ設定も HOME も読ませず、補完後の既定 cwd を呼び出し側から mode 別に渡してもらう
91
91
  // (補完の規則は CLI 種別で違うため、mode を知らない scheduler 側では決められない)。
92
- function attachLaneKey(mode, msg, defaultCwd = "") {
92
+ export function attachLaneKey(mode, msg, defaultCwd = "") {
93
93
  const cwd = (typeof msg?.cwd === "string" ? msg.cwd.trim() : "") || defaultCwd
94
94
  if (cwd) return `${mode}:cwd:${cwd}`
95
95
  const resumeId =
@@ -187,6 +187,7 @@ export class DispatchScheduler {
187
187
  // usage build は最大 25 秒かかり、同じ control lane の一覧要求 (当時は tmux.list_sessions) が
188
188
  // frontend の 10 秒打ち切りで捨てられていたため分離する (2026-09-04 本番計測)。
189
189
  if (type === "usage.request") return "usage"
190
+ if (type === "agent.sessions.restore") return "restore"
190
191
 
191
192
  if (type === "chat.session.stop") {
192
193
  if (msg.scope === "cwd") {
@@ -259,6 +260,22 @@ export class DispatchScheduler {
259
260
  })
260
261
  }
261
262
 
263
+ /**
264
+ * 指定レーンに処理を積み、結果(失敗も)を呼び出し元へ返す。
265
+ * ⚠️ 接続世代では破棄しない。唯一の利用者である会話復元は WS と無関係な bridge へ登録するので、
266
+ * 張り直しで待機中の行を黙って捨てると復元が途中で止まる(受付はプロセスあたり 1 回のため
267
+ * 送り直しでも戻らない)。旧世代の破棄は enqueue(WS 由来のメッセージ)だけの規則。
268
+ */
269
+ runInLane(lane, fn) {
270
+ const result = this._previousFor(lane).then(() => fn())
271
+ // 失敗した行が、後続の通常メッセージを止めないよう tail は成功で解決する。
272
+ const tail = result.catch(() => {}).finally(() => {
273
+ if (this.laneTails.get(lane) === tail) this.laneTails.delete(lane)
274
+ })
275
+ this.laneTails.set(lane, tail)
276
+ return result
277
+ }
278
+
262
279
  enqueue(msg) {
263
280
  const type = msg?.type
264
281
  const sequence = ++this.sequence
package/src/main.mjs CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  import { loadPlugins, runHookBroadcast } from "./plugin-loader.mjs"
46
46
  import { WsClient } from "./ws-client.mjs"
47
47
  import { DispatchScheduler } from "./dispatch-scheduler.mjs"
48
+ import { noteRestoreStop, restoreSessions, sendBridgeMessage } from "./session-restore.mjs"
48
49
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
49
50
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
50
51
  import { installClaudeDisplayMask } from "./claude-display-mask.mjs"
@@ -893,12 +894,12 @@ export async function startDaemon({ version, claudeSdk } = {}) {
893
894
  }
894
895
  }
895
896
  resolveChatContextPct(session, event)
896
- client.send({ type: "claude.event", stream_id, session_id, event })
897
+ sendBridgeMessage(client, { type: "claude.event", stream_id, session_id, event })
897
898
  })
898
899
  claudeBridge.on("permission", ({ stream_id, session_id, cwd, request_id, tool_name, input }) => {
899
900
  // 多端末共有: session_id を載せて backend が session group へ broadcast できるように
900
901
  // する (全端末に許可ダイアログ → 先着 1 件採用)。
901
- client.send({
902
+ sendBridgeMessage(client, {
902
903
  type: "claude.permission.request",
903
904
  stream_id,
904
905
  session_id,
@@ -915,7 +916,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
915
916
  // TUI 由来カードの撤去と同じ `claude.permission.cancel` 型を使う。
916
917
  for (const request_id of request_ids || []) {
917
918
  try {
918
- client.send({
919
+ sendBridgeMessage(client, {
919
920
  type: "claude.permission.cancel",
920
921
  stream_id,
921
922
  session_id,
@@ -930,13 +931,13 @@ export async function startDaemon({ version, claudeSdk } = {}) {
930
931
  },
931
932
  )
932
933
  claudeBridge.on("exit", ({ stream_id, code, reason, session_id, cwd }) => {
933
- client.send({ type: "claude.exit", stream_id, code, reason, session_id })
934
+ sendBridgeMessage(client, { type: "claude.exit", stream_id, code, reason, session_id })
934
935
  })
935
936
  claudeBridge.on("sessionoptions", (message) => {
936
- client.send(buildClaudeSessionOptionsPayload(message))
937
+ sendBridgeMessage(client, buildClaudeSessionOptionsPayload(message))
937
938
  })
938
939
  claudeBridge.on("error", ({ stream_id, session_id, error }) => {
939
- client.send({ type: "claude.error", stream_id, session_id, error })
940
+ sendBridgeMessage(client, { type: "claude.error", stream_id, session_id, error })
940
941
  })
941
942
  }
942
943
 
@@ -949,10 +950,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
949
950
  if (notification.method === "thread/tokenUsage/updated") {
950
951
  recordCodexLiveTokenUsage(producer_seq, notification.params?.tokenUsage)
951
952
  }
952
- client.send({ type: "codex.event", stream_id, thread_id, event: notification })
953
+ sendBridgeMessage(client, { type: "codex.event", stream_id, thread_id, event: notification })
953
954
  })
954
955
  codexBridge.on("permission", ({ stream_id, thread_id, cwd, request_id, method, params }) => {
955
- client.send({
956
+ sendBridgeMessage(client, {
956
957
  type: "codex.permission.request",
957
958
  stream_id,
958
959
  thread_id,
@@ -970,10 +971,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
970
971
  } catch {
971
972
  /* ignore */
972
973
  }
973
- client.send({ type: "codex.exit", stream_id, thread_id, code, reason })
974
+ sendBridgeMessage(client, { type: "codex.exit", stream_id, thread_id, code, reason })
974
975
  })
975
976
  codexBridge.on("error", ({ stream_id, thread_id, error }) => {
976
- client.send({ type: "codex.error", stream_id, thread_id, error })
977
+ sendBridgeMessage(client, { type: "codex.error", stream_id, thread_id, error })
977
978
  })
978
979
  }
979
980
 
@@ -991,6 +992,8 @@ export async function startDaemon({ version, claudeSdk } = {}) {
991
992
  // tmux/control・Claude・Codex・別 cwd を独立レーンへ分離する。
992
993
  const dispatchCtx = {
993
994
  ...ctx,
995
+ availableClis,
996
+ sessionRestoreState: { accepted: false },
994
997
  client,
995
998
  claudeBridge,
996
999
  codexBridge,
@@ -1008,6 +1011,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1008
1011
  // 持たせず、解決済みの値だけを渡す。
1009
1012
  defaultCwd: (mode) => defaultLaneCwd(ctx.config, mode),
1010
1013
  })
1014
+ dispatchCtx.dispatchScheduler = dispatchScheduler
1011
1015
  client.on("close", () => dispatchScheduler.advanceGeneration("ws_close"))
1012
1016
  client.on("open", () => dispatchScheduler.advanceGeneration("ws_open"))
1013
1017
  client.on("message", (msg) => dispatchScheduler.enqueue(msg))
@@ -3103,6 +3107,23 @@ export async function dispatch(msg, ctx) {
3103
3107
  const t = msg?.type || ""
3104
3108
  try {
3105
3109
  switch (t) {
3110
+ case "agent.sessions.restore": {
3111
+ const scheduler = ctx.dispatchScheduler
3112
+ ctx.sessionRestoreState ??= { accepted: false }
3113
+ await restoreSessions(msg, {
3114
+ state: ctx.sessionRestoreState,
3115
+ // 世代は行ごとに現在値を使う (張り直し後も新しい tail と直列化する)。
3116
+ runInLane: scheduler && ((lane, fn) => scheduler.runInLane(lane, fn)),
3117
+ claudeBridge: ctx.claudeBridge,
3118
+ codexBridge: ctx.codexBridge,
3119
+ claudeAttachOptions: () => resolveClaudeAttachOptions(ctx.config),
3120
+ available: (kind) => kind === "codex"
3121
+ ? codexAvailableForDispatch(ctx)
3122
+ : ctx.availableClis?.includes("claude") === true,
3123
+ logger: ctx.logger,
3124
+ })
3125
+ return
3126
+ }
3106
3127
  case "ready":
3107
3128
  ctx.logger.info({ msg }, "hub ready")
3108
3129
  await maybeSyncClaudeSettings(msg, ctx)
@@ -4364,14 +4385,18 @@ export async function dispatch(msg, ctx) {
4364
4385
  }
4365
4386
  case "chat.session.stop": {
4366
4387
  const result = { type: "chat.session.stop.result", request_id: msg.request_id, stopped: 0 }
4388
+ // 復元中の未処理行を取り消す記録は、入力検証を通った停止だけに付ける(エラーを返す要求に
4389
+ // 副作用を残さない)。停止件数 0 でも記録する(未登録の復元行を止めるため)。
4367
4390
  try {
4368
4391
  if (msg.scope === "cwd") {
4369
4392
  const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4370
4393
  if (msg.cli_kind !== "claude" && msg.cli_kind !== "codex") result.error = "unsupported cli_kind"
4371
4394
  else if (!cwd || !path.isAbsolute(cwd)) result.error = "cwd required"
4372
4395
  else if (msg.cli_kind === "claude") {
4396
+ noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
4373
4397
  result.stopped = await ctx.claudeBridge.stopSessionsFor({ cwd, reason: "chat-stop" })
4374
4398
  } else {
4399
+ noteRestoreStop(ctx.sessionRestoreState, "codex", cwd)
4375
4400
  result.stopped = await ctx.codexBridge.stopSessionsForCwd({ cwd, reason: "chat-stop" })
4376
4401
  }
4377
4402
  ctx.client.send(result)
@@ -4384,11 +4409,18 @@ export async function dispatch(msg, ctx) {
4384
4409
  else if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(session_id)) {
4385
4410
  result.error = "invalid session_id"
4386
4411
  } else {
4412
+ noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
4387
4413
  result.stopped = await ctx.claudeBridge.stopSessionForDeletion({ cwd, session_id, reason: "chat-stop" })
4388
4414
  }
4389
4415
  } else if (msg.cli_kind === "codex") {
4390
4416
  if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
4391
- else result.stopped = await ctx.codexBridge.stopSessionForStream({ stream_id: msg.stream_id, reason: "chat-stop" })
4417
+ else {
4418
+ // stream 停止は cwd を持たないので、停止対象の会話の cwd で記録する(無ければ記録しない:
4419
+ // 存在しない stream の停止は未登録の復元行を指せない)。
4420
+ const streamCwd = ctx.codexBridge.sessions?.get?.(msg.stream_id)?.cwd
4421
+ if (streamCwd) noteRestoreStop(ctx.sessionRestoreState, "codex", streamCwd)
4422
+ result.stopped = await ctx.codexBridge.stopSessionForStream({ stream_id: msg.stream_id, reason: "chat-stop" })
4423
+ }
4392
4424
  } else {
4393
4425
  result.error = "unsupported cli_kind"
4394
4426
  }
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { stat } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { chatRowSessionName } from "./chat-sessions-snapshot.mjs"
5
+ import { WARM_CWD_MAX, WARM_PER_CWD_MAX } from "./claude-stream-bridge.mjs"
6
+ import { WARM_TOTAL_MAX } from "./warm-bounds-total.mjs"
7
+ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
8
+ import { attachLaneKey } from "./dispatch-scheduler.mjs"
9
+
10
+ const nonEmpty = (v) => (typeof v === "string" && v ? v : undefined)
11
+
12
+ /** 復元専用の購読は browser に存在しない。detach 後の遅延イベントも送信しない。 */
13
+ export function sendBridgeMessage(client, message) {
14
+ if (message.stream_id?.startsWith("restore:")) return
15
+ return client.send(message)
16
+ }
17
+
18
+ /**
19
+ * 復元中に届いた明示停止 (chat.session.stop) を記録する。未処理の復元行のうち、停止された
20
+ * cwd (cwd が無い停止は CLI 全体) の行を登録しないため。復元中以外は何もしない
21
+ * (停止記録は古い名簿にだけ効き、以後の通常 attach を妨げない)。
22
+ */
23
+ export function noteRestoreStop(state, cliKind, cwd) {
24
+ if (!state?.stopped) return
25
+ state.stopped.add(`${cliKind}:${typeof cwd === "string" && cwd ? cwd : "*"}`)
26
+ }
27
+
28
+ /**
29
+ * state は接続ではなく agent の寿命に属する。最初の呼び出しで同期的に消費する。
30
+ * ⚠️ WS の張り直しでは中断しない。登録先の bridge は接続と無関係に生きているため、中断すると
31
+ * 受付状態と Hub の「接続ごとに 1 回」の送信がかみ合わず、復元が途中で止まる (2 巡目 blocker)。
32
+ */
33
+ export async function restoreSessions(message, {
34
+ state, claudeBridge, codexBridge, available, logger,
35
+ runInLane = (_lane, fn) => fn(), statCwd = stat,
36
+ claudeAttachOptions = () => ({}),
37
+ }) {
38
+ if (state.accepted) {
39
+ logger?.info?.({}, "agent.sessions.restore ignored: already accepted")
40
+ return
41
+ }
42
+ state.accepted = true
43
+ const bridges = { claude: claudeBridge, codex: codexBridge }
44
+ const logSkip = (row, reason) => logger?.info?.({
45
+ cli_kind: row?.cli_kind, cwd: row?.cwd, session_id: row?.session_id, reason,
46
+ }, "agent.sessions.restore skipped")
47
+ if (!Array.isArray(message.sessions)) {
48
+ logSkip(null, "sessions must be an array")
49
+ return
50
+ }
51
+ const snapshot = () => describeLiveSessionsUnion({ claudeBridge, codexBridge }).map((s) => ({
52
+ cli_kind: s.kind, cwd: s.cwd, session_id: s.session_id || s.thread_id,
53
+ }))
54
+ const same = (a, b) => a.cli_kind === b.cli_kind && a.cwd === b.cwd && a.session_id === b.session_id
55
+ const fits = (row, live) => {
56
+ const bridge = bridges[row.cli_kind]
57
+ const cwdMax = row.cli_kind === "claude" ? WARM_CWD_MAX : bridge._warmCwdMax
58
+ const perCwdMax = row.cli_kind === "claude" ? WARM_PER_CWD_MAX : bridge._warmPerCwdMax
59
+ const own = live.filter((s) => s.cli_kind === row.cli_kind)
60
+ return live.length < WARM_TOTAL_MAX &&
61
+ own.filter((s) => s.cwd === row.cwd).length < perCwdMax &&
62
+ new Set([...own.map((s) => s.cwd), row.cwd]).size <= cwdMax
63
+ }
64
+ state.stopped = new Set()
65
+ const stoppedFor = (row) =>
66
+ state.stopped.has(`${row.cli_kind}:${row.cwd}`) || state.stopped.has(`${row.cli_kind}:*`)
67
+ const selected = []
68
+ const cwdStats = new Map()
69
+ try {
70
+ for (const row of message.sessions) {
71
+ const current = snapshot()
72
+ // 選別後に通常 attach で live になった行を二重に数えない。
73
+ const live = [...current, ...selected.filter((r) => !current.some((s) => same(s, r)))]
74
+ if (live.length >= WARM_TOTAL_MAX) break
75
+ try {
76
+ if (!row || !["claude", "codex"].includes(row.cli_kind)) throw new Error("unknown CLI")
77
+ if (!bridges[row.cli_kind] || !available(row.cli_kind)) throw new Error("CLI unavailable")
78
+ if (typeof row.session_id !== "string" || !row.session_id.trim()) throw new Error("empty session_id")
79
+ if (typeof row.cwd !== "string" || !path.isAbsolute(row.cwd)) throw new Error("cwd must be absolute")
80
+ if (row.session_name !== chatRowSessionName(row.cwd, row.cli_kind)) throw new Error("session_name mismatch")
81
+ if (live.some((s) => same(s, row))) continue
82
+ if (!fits(row, live)) { logSkip(row, "live limit"); continue }
83
+ if (!cwdStats.has(row.cwd)) cwdStats.set(row.cwd, Promise.resolve().then(() => statCwd(row.cwd)))
84
+ if (!(await cwdStats.get(row.cwd)).isDirectory()) throw new Error("cwd is not a directory")
85
+ selected.push(row)
86
+ } catch (err) {
87
+ logSkip(row, err.message)
88
+ }
89
+ }
90
+ // 名簿は新しい順。生成順・活動時刻を既存の回収規則に合わせる。
91
+ for (const row of selected.reverse()) {
92
+ await runInLane(attachLaneKey(row.cli_kind, row), async () => {
93
+ // 復元待ちの間に利用者が止めた会話を、後から復活させない。
94
+ if (stoppedFor(row)) { logSkip(row, "stopped during restore"); return }
95
+ // 待機中に worktree 削除が完了していることがある(削除中ガードは完了で解除される)。
96
+ // キャッシュを使わず取り直す。⚠️ この確認は live 再確認より前に置き、再確認から attach までの
97
+ // 間に await を挟まない(chat-session-starter.mjs と同じ順序。間に待ちがあると二重に作る)。
98
+ try {
99
+ if (!(await statCwd(row.cwd)).isDirectory()) throw new Error("not a directory")
100
+ } catch {
101
+ logSkip(row, "cwd missing at restore")
102
+ return
103
+ }
104
+ if (stoppedFor(row)) { logSkip(row, "stopped during restore"); return }
105
+ const bridge = bridges[row.cli_kind]
106
+ const live = snapshot()
107
+ if (live.some((s) => same(s, row))) return
108
+ if (!fits(row, live)) { logSkip(row, "live limit changed"); return }
109
+ const stream_id = `restore:${randomUUID()}`
110
+ try {
111
+ await bridge.attach({
112
+ stream_id, cwd: row.cwd,
113
+ // ⚠️ Claude は通常 attach と同じ既定 (agent.json の model / permissionMode 等) を渡す。
114
+ // 起動後の再接続は live 側の設定を優先し、bypassPermissions は起動時にしか入らないため、
115
+ // ここで欠けると復元した会話だけ権限モードが既定に落ちる (0.8.91 の実測)。
116
+ ...(row.cli_kind === "claude"
117
+ ? { ...claudeAttachOptions(), resumeSessionId: row.session_id }
118
+ // Codex は承認ポリシー / サンドボックスを thread 開始時にしか受け取らない。Hub が
119
+ // Cockpit の既定を行に載せる (空なら載せない = config.toml 既定。通常 attach と同じ)。
120
+ : {
121
+ resumeThreadId: row.session_id,
122
+ approvalPolicy: nonEmpty(row.approval_policy),
123
+ sandbox: nonEmpty(row.sandbox),
124
+ }),
125
+ })
126
+ logger?.info?.({ cli_kind: row.cli_kind, cwd: row.cwd, session_id: row.session_id },
127
+ "agent.sessions.restore registered")
128
+ } catch (err) {
129
+ logSkip(row, err.message)
130
+ } finally {
131
+ try {
132
+ bridge.detach({ stream_id })
133
+ } catch (err) {
134
+ logSkip(row, `detach failed: ${err.message}`)
135
+ }
136
+ }
137
+ })
138
+ }
139
+ } finally {
140
+ state.stopped = null
141
+ }
142
+ }