@cocorograph/hub-agent 0.8.89 → 0.8.91

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.89",
3
+ "version": "0.8.91",
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` が届くたびに
@@ -43,7 +43,7 @@ const SLOW_DISPATCH_MS = 1000
43
43
  * 調査対象そのものなので、速かった場合も含めて毎回記録する
44
44
  * (速いことの記録が無いと「詰まっていない」を示せない)。
45
45
  */
46
- const ALWAYS_TIMED_TYPES = new Set(["tmux.kill_session"])
46
+ const ALWAYS_TIMED_TYPES = new Set(["chat.session.stop"])
47
47
 
48
48
  export function isConnectionLifecycleMessage(type) {
49
49
  return LIFECYCLE_TYPES.has(type)
@@ -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 =
@@ -184,9 +184,10 @@ export class DispatchScheduler {
184
184
  const mutationMode = globalMutationMode(type)
185
185
  if (mutationMode) return `${mutationMode}:global`
186
186
 
187
- // usage build は最大 25 秒かかり、同じ control lane の tmux.list_sessions が
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") {
@@ -203,8 +204,7 @@ export class DispatchScheduler {
203
204
  }
204
205
  return "control"
205
206
  }
206
- // chat.stop_all は全レーンに跨るため control に置く。その時点で live な会話だけを
207
- // 止める契約であり、接続処理中のまだ live でない会話は対象外。sessions.request も control。
207
+ // chat.sessions.request は control。
208
208
  const mode = chatMode(type)
209
209
  if (!mode) return "control"
210
210
 
@@ -260,6 +260,22 @@ export class DispatchScheduler {
260
260
  })
261
261
  }
262
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
+
263
279
  enqueue(msg) {
264
280
  const type = msg?.type
265
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,22 @@ 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
+ available: (kind) => kind === "codex"
3120
+ ? codexAvailableForDispatch(ctx)
3121
+ : ctx.availableClis?.includes("claude") === true,
3122
+ logger: ctx.logger,
3123
+ })
3124
+ return
3125
+ }
3106
3126
  case "ready":
3107
3127
  ctx.logger.info({ msg }, "hub ready")
3108
3128
  await maybeSyncClaudeSettings(msg, ctx)
@@ -4364,14 +4384,18 @@ export async function dispatch(msg, ctx) {
4364
4384
  }
4365
4385
  case "chat.session.stop": {
4366
4386
  const result = { type: "chat.session.stop.result", request_id: msg.request_id, stopped: 0 }
4387
+ // 復元中の未処理行を取り消す記録は、入力検証を通った停止だけに付ける(エラーを返す要求に
4388
+ // 副作用を残さない)。停止件数 0 でも記録する(未登録の復元行を止めるため)。
4367
4389
  try {
4368
4390
  if (msg.scope === "cwd") {
4369
4391
  const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4370
4392
  if (msg.cli_kind !== "claude" && msg.cli_kind !== "codex") result.error = "unsupported cli_kind"
4371
4393
  else if (!cwd || !path.isAbsolute(cwd)) result.error = "cwd required"
4372
4394
  else if (msg.cli_kind === "claude") {
4395
+ noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
4373
4396
  result.stopped = await ctx.claudeBridge.stopSessionsFor({ cwd, reason: "chat-stop" })
4374
4397
  } else {
4398
+ noteRestoreStop(ctx.sessionRestoreState, "codex", cwd)
4375
4399
  result.stopped = await ctx.codexBridge.stopSessionsForCwd({ cwd, reason: "chat-stop" })
4376
4400
  }
4377
4401
  ctx.client.send(result)
@@ -4384,11 +4408,18 @@ export async function dispatch(msg, ctx) {
4384
4408
  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
4409
  result.error = "invalid session_id"
4386
4410
  } else {
4411
+ noteRestoreStop(ctx.sessionRestoreState, "claude", cwd)
4387
4412
  result.stopped = await ctx.claudeBridge.stopSessionForDeletion({ cwd, session_id, reason: "chat-stop" })
4388
4413
  }
4389
4414
  } else if (msg.cli_kind === "codex") {
4390
4415
  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" })
4416
+ else {
4417
+ // stream 停止は cwd を持たないので、停止対象の会話の cwd で記録する(無ければ記録しない:
4418
+ // 存在しない stream の停止は未登録の復元行を指せない)。
4419
+ const streamCwd = ctx.codexBridge.sessions?.get?.(msg.stream_id)?.cwd
4420
+ if (streamCwd) noteRestoreStop(ctx.sessionRestoreState, "codex", streamCwd)
4421
+ result.stopped = await ctx.codexBridge.stopSessionForStream({ stream_id: msg.stream_id, reason: "chat-stop" })
4422
+ }
4392
4423
  } else {
4393
4424
  result.error = "unsupported cli_kind"
4394
4425
  }
@@ -4398,43 +4429,6 @@ export async function dispatch(msg, ctx) {
4398
4429
  ctx.client.send(result)
4399
4430
  return
4400
4431
  }
4401
- case "chat.stop_all": {
4402
- // exit に載る停止理由。bridge 側の既定 ("tui-bind-stop" / "explicit-stop") は
4403
- // それぞれ TUI 切替・個別停止の名前なので、全停止はここで明示して区別する。
4404
- const STOP_ALL_REASON = "chat-stop-all"
4405
- const result = { type: "chat.stop_all.result", request_id: msg.request_id, stopped: 0, failed: [] }
4406
- try {
4407
- const live = describeLiveSessionsUnion({ claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge })
4408
- const claudeCwds = new Set()
4409
- const codexStreams = new Set()
4410
- for (const session of live) {
4411
- let target
4412
- try {
4413
- if (session.kind === "claude") {
4414
- const cwd = session.cwd
4415
- if (claudeCwds.has(cwd)) continue
4416
- claudeCwds.add(cwd)
4417
- // ⚠️ reason を省くと stopSessionsFor の既定 "tui-bind-stop" が exit に載り、
4418
- // 画面から止めた会話が「tmux へ切り替えたせい」で消えたように見える。
4419
- target = { cwd, reason: STOP_ALL_REASON }
4420
- result.stopped += await ctx.claudeBridge.stopSessionsFor(target)
4421
- } else if (session.kind === "codex") {
4422
- const stream_id = session.stream_id
4423
- if (codexStreams.has(stream_id)) continue
4424
- codexStreams.add(stream_id)
4425
- target = { stream_id, reason: STOP_ALL_REASON }
4426
- result.stopped += await ctx.codexBridge.stopSessionForStream(target)
4427
- }
4428
- } catch (err) {
4429
- result.failed.push({ ...target, reason: err?.message || String(err) })
4430
- }
4431
- }
4432
- } catch (err) {
4433
- result.error = err?.message || String(err)
4434
- }
4435
- ctx.client.send(result)
4436
- return
4437
- }
4438
4432
  case "chat.sessions.request": {
4439
4433
  try {
4440
4434
  const snapshot = await buildChatSessionsSnapshot({
@@ -0,0 +1,130 @@
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
+ /** 復元専用の購読は browser に存在しない。detach 後の遅延イベントも送信しない。 */
11
+ export function sendBridgeMessage(client, message) {
12
+ if (message.stream_id?.startsWith("restore:")) return
13
+ return client.send(message)
14
+ }
15
+
16
+ /**
17
+ * 復元中に届いた明示停止 (chat.session.stop) を記録する。未処理の復元行のうち、停止された
18
+ * cwd (cwd が無い停止は CLI 全体) の行を登録しないため。復元中以外は何もしない
19
+ * (停止記録は古い名簿にだけ効き、以後の通常 attach を妨げない)。
20
+ */
21
+ export function noteRestoreStop(state, cliKind, cwd) {
22
+ if (!state?.stopped) return
23
+ state.stopped.add(`${cliKind}:${typeof cwd === "string" && cwd ? cwd : "*"}`)
24
+ }
25
+
26
+ /**
27
+ * state は接続ではなく agent の寿命に属する。最初の呼び出しで同期的に消費する。
28
+ * ⚠️ WS の張り直しでは中断しない。登録先の bridge は接続と無関係に生きているため、中断すると
29
+ * 受付状態と Hub の「接続ごとに 1 回」の送信がかみ合わず、復元が途中で止まる (2 巡目 blocker)。
30
+ */
31
+ export async function restoreSessions(message, {
32
+ state, claudeBridge, codexBridge, available, logger,
33
+ runInLane = (_lane, fn) => fn(), statCwd = stat,
34
+ }) {
35
+ if (state.accepted) {
36
+ logger?.info?.({}, "agent.sessions.restore ignored: already accepted")
37
+ return
38
+ }
39
+ state.accepted = true
40
+ const bridges = { claude: claudeBridge, codex: codexBridge }
41
+ const logSkip = (row, reason) => logger?.info?.({
42
+ cli_kind: row?.cli_kind, cwd: row?.cwd, session_id: row?.session_id, reason,
43
+ }, "agent.sessions.restore skipped")
44
+ if (!Array.isArray(message.sessions)) {
45
+ logSkip(null, "sessions must be an array")
46
+ return
47
+ }
48
+ const snapshot = () => describeLiveSessionsUnion({ claudeBridge, codexBridge }).map((s) => ({
49
+ cli_kind: s.kind, cwd: s.cwd, session_id: s.session_id || s.thread_id,
50
+ }))
51
+ const same = (a, b) => a.cli_kind === b.cli_kind && a.cwd === b.cwd && a.session_id === b.session_id
52
+ const fits = (row, live) => {
53
+ const bridge = bridges[row.cli_kind]
54
+ const cwdMax = row.cli_kind === "claude" ? WARM_CWD_MAX : bridge._warmCwdMax
55
+ const perCwdMax = row.cli_kind === "claude" ? WARM_PER_CWD_MAX : bridge._warmPerCwdMax
56
+ const own = live.filter((s) => s.cli_kind === row.cli_kind)
57
+ return live.length < WARM_TOTAL_MAX &&
58
+ own.filter((s) => s.cwd === row.cwd).length < perCwdMax &&
59
+ new Set([...own.map((s) => s.cwd), row.cwd]).size <= cwdMax
60
+ }
61
+ state.stopped = new Set()
62
+ const stoppedFor = (row) =>
63
+ state.stopped.has(`${row.cli_kind}:${row.cwd}`) || state.stopped.has(`${row.cli_kind}:*`)
64
+ const selected = []
65
+ const cwdStats = new Map()
66
+ try {
67
+ for (const row of message.sessions) {
68
+ const current = snapshot()
69
+ // 選別後に通常 attach で live になった行を二重に数えない。
70
+ const live = [...current, ...selected.filter((r) => !current.some((s) => same(s, r)))]
71
+ if (live.length >= WARM_TOTAL_MAX) break
72
+ try {
73
+ if (!row || !["claude", "codex"].includes(row.cli_kind)) throw new Error("unknown CLI")
74
+ if (!bridges[row.cli_kind] || !available(row.cli_kind)) throw new Error("CLI unavailable")
75
+ if (typeof row.session_id !== "string" || !row.session_id.trim()) throw new Error("empty session_id")
76
+ if (typeof row.cwd !== "string" || !path.isAbsolute(row.cwd)) throw new Error("cwd must be absolute")
77
+ if (row.session_name !== chatRowSessionName(row.cwd, row.cli_kind)) throw new Error("session_name mismatch")
78
+ if (live.some((s) => same(s, row))) continue
79
+ if (!fits(row, live)) { logSkip(row, "live limit"); continue }
80
+ if (!cwdStats.has(row.cwd)) cwdStats.set(row.cwd, Promise.resolve().then(() => statCwd(row.cwd)))
81
+ if (!(await cwdStats.get(row.cwd)).isDirectory()) throw new Error("cwd is not a directory")
82
+ selected.push(row)
83
+ } catch (err) {
84
+ logSkip(row, err.message)
85
+ }
86
+ }
87
+ // 名簿は新しい順。生成順・活動時刻を既存の回収規則に合わせる。
88
+ for (const row of selected.reverse()) {
89
+ await runInLane(attachLaneKey(row.cli_kind, row), async () => {
90
+ // 復元待ちの間に利用者が止めた会話を、後から復活させない。
91
+ if (stoppedFor(row)) { logSkip(row, "stopped during restore"); return }
92
+ // 待機中に worktree 削除が完了していることがある(削除中ガードは完了で解除される)。
93
+ // キャッシュを使わず取り直す。⚠️ この確認は live 再確認より前に置き、再確認から attach までの
94
+ // 間に await を挟まない(chat-session-starter.mjs と同じ順序。間に待ちがあると二重に作る)。
95
+ try {
96
+ if (!(await statCwd(row.cwd)).isDirectory()) throw new Error("not a directory")
97
+ } catch {
98
+ logSkip(row, "cwd missing at restore")
99
+ return
100
+ }
101
+ if (stoppedFor(row)) { logSkip(row, "stopped during restore"); return }
102
+ const bridge = bridges[row.cli_kind]
103
+ const live = snapshot()
104
+ if (live.some((s) => same(s, row))) return
105
+ if (!fits(row, live)) { logSkip(row, "live limit changed"); return }
106
+ const stream_id = `restore:${randomUUID()}`
107
+ try {
108
+ await bridge.attach({
109
+ stream_id, cwd: row.cwd,
110
+ ...(row.cli_kind === "claude"
111
+ ? { resumeSessionId: row.session_id }
112
+ : { resumeThreadId: row.session_id }),
113
+ })
114
+ logger?.info?.({ cli_kind: row.cli_kind, cwd: row.cwd, session_id: row.session_id },
115
+ "agent.sessions.restore registered")
116
+ } catch (err) {
117
+ logSkip(row, err.message)
118
+ } finally {
119
+ try {
120
+ bridge.detach({ stream_id })
121
+ } catch (err) {
122
+ logSkip(row, `detach failed: ${err.message}`)
123
+ }
124
+ }
125
+ })
126
+ }
127
+ } finally {
128
+ state.stopped = null
129
+ }
130
+ }