@cocorograph/hub-agent 0.8.90 → 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 +1 -1
- package/src/claude-stream-bridge.mjs +2 -2
- package/src/dispatch-scheduler.mjs +18 -1
- package/src/main.mjs +42 -11
- package/src/session-restore.mjs +130 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
934
|
+
sendBridgeMessage(client, { type: "claude.exit", stream_id, code, reason, session_id })
|
|
934
935
|
})
|
|
935
936
|
claudeBridge.on("sessionoptions", (message) => {
|
|
936
|
-
client
|
|
937
|
+
sendBridgeMessage(client, buildClaudeSessionOptionsPayload(message))
|
|
937
938
|
})
|
|
938
939
|
claudeBridge.on("error", ({ stream_id, session_id, error }) => {
|
|
939
|
-
client
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -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
|
+
}
|