@cocorograph/hub-agent 0.8.120 → 0.8.121

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.120",
3
+ "version": "0.8.121",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,328 @@
1
+ import fs from "node:fs/promises"
2
+ import { constants } from "node:fs"
3
+ import path from "node:path"
4
+ import { createHash, randomUUID } from "node:crypto"
5
+ import { truncateString } from "./truncate-string.mjs"
6
+ import { paths } from "./config.mjs"
7
+
8
+ export const DELEGATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
9
+ export const DELEGATION_SESSION_LIMIT = 1000
10
+ export const DELEGATION_SOFT_BYTES = 4 * 1024 * 1024
11
+ export const DELEGATION_HARD_BYTES = 5 * 1024 * 1024
12
+ const TERMINAL_DELEGATE_PROMPT_MAX_LENGTH = 2_000
13
+ const TERMINAL_DELEGATE_AGENT_MAX_LENGTH = 200
14
+ const TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH = 2_000
15
+
16
+ function compactTerminalEvent(event, includePrompt = false) {
17
+ const storedEvent = {}
18
+ for (const field of [
19
+ "type",
20
+ "subtype",
21
+ "delegation_id",
22
+ "phase",
23
+ "background",
24
+ "timestamp",
25
+ ]) {
26
+ if (event[field] !== undefined) storedEvent[field] = event[field]
27
+ }
28
+
29
+ if (typeof event.agent === "string") {
30
+ storedEvent.agent = truncateString(
31
+ event.agent,
32
+ TERMINAL_DELEGATE_AGENT_MAX_LENGTH,
33
+ (length) => `\n\n[エージェント名はここで打ち切られています(全 ${length} 文字)]`,
34
+ )
35
+ }
36
+ if (typeof event.description === "string") {
37
+ storedEvent.description = truncateString(
38
+ event.description,
39
+ TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH,
40
+ (length) => `\n\n[説明はここで打ち切られています(全 ${length} 文字)]`,
41
+ )
42
+ }
43
+ if (includePrompt && typeof event.prompt === "string") {
44
+ storedEvent.prompt = truncateString(
45
+ event.prompt,
46
+ TERMINAL_DELEGATE_PROMPT_MAX_LENGTH,
47
+ (length) =>
48
+ `\n\n[終了済み委譲の依頼本文はここで打ち切られています(全 ${length} 文字)]`,
49
+ )
50
+ }
51
+
52
+ return storedEvent
53
+ }
54
+
55
+ const FIELDS = new Set([
56
+ "type", "subtype", "delegation_id", "phase", "agent", "description", "background",
57
+ "prompt", "pid", "pid_started_at", "timestamp",
58
+ ])
59
+ function validEvent(event) {
60
+ return event && typeof event === "object" && !Array.isArray(event) &&
61
+ Object.keys(event).every((key) => FIELDS.has(key)) &&
62
+ event.type === "system" && event.subtype === "codex_delegate" &&
63
+ typeof event.delegation_id === "string" && event.delegation_id.length > 0 && event.delegation_id.length <= 200 &&
64
+ ["started", "completed", "failed", "stopped"].includes(event.phase) &&
65
+ (event.phase !== "started" || (typeof event.agent === "string" && event.agent.length > 0)) &&
66
+ ["agent", "description", "prompt", "pid_started_at"].every((key) => event[key] === undefined || typeof event[key] === "string") &&
67
+ (event.background === undefined || typeof event.background === "boolean") &&
68
+ (event.pid === undefined || (Number.isInteger(event.pid) && event.pid > 0)) &&
69
+ typeof event.timestamp === "string" && Number.isFinite(Date.parse(event.timestamp))
70
+ }
71
+
72
+ /**
73
+ * 既定の保存先。`HUB_DELEGATION_EVENTS_DIR` があればそれを使う(テストが実運用の
74
+ * `~/.hub/delegation-events` へ書き込まないよう、test/setup-env.mjs が実行ごとの一時ディレクトリへ向ける)。
75
+ */
76
+ export function defaultDelegationEventsDir() {
77
+ return process.env.HUB_DELEGATION_EVENTS_DIR || path.join(paths.configDir, "delegation-events")
78
+ }
79
+
80
+ /** Single daemon writer. All decisions, snapshots and replay checks share this queue. */
81
+ export class DelegationEventStore {
82
+ constructor(dir = defaultDelegationEventsDir(), now = Date.now, logger) {
83
+ this.dir = dir
84
+ this.now = now
85
+ this.logger = logger
86
+ this.queue = Promise.resolve()
87
+ this.lastSweep = -Infinity
88
+ this.sweeping = false
89
+ }
90
+ _serial(operation) {
91
+ const pending = this.queue.then(operation)
92
+ this.queue = pending.catch(() => {})
93
+ return pending
94
+ }
95
+ _file(cwd, sessionId) {
96
+ if ([cwd, sessionId].some((value) => typeof value !== "string" || !value)) throw new Error("invalid delegation target")
97
+ return path.join(this.dir, createHash("sha256").update(JSON.stringify([cwd, sessionId])).digest("hex") + ".json")
98
+ }
99
+ async _checkDirectory() {
100
+ const info = await fs.lstat(this.dir)
101
+ if (!info.isDirectory() || info.isSymbolicLink() || process.getuid?.() == null ||
102
+ info.uid !== process.getuid() || (info.mode & 0o777) !== 0o700) {
103
+ throw new Error("unsafe delegation directory")
104
+ }
105
+ }
106
+ async _prepare() {
107
+ await fs.mkdir(this.dir, { recursive: true, mode: 0o700 })
108
+ await this._checkDirectory()
109
+ }
110
+ async _read(file, target) {
111
+ await this._checkDirectory()
112
+ let info
113
+ try { info = await fs.lstat(file) } catch (err) {
114
+ if (err.code === "ENOENT") return []
115
+ throw err
116
+ }
117
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
118
+ throw new Error("unsafe delegation store")
119
+ }
120
+ if (info.size > DELEGATION_HARD_BYTES) throw new Error("delegation store exceeds size limit")
121
+ const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
122
+ let snapshot
123
+ try {
124
+ const opened = await handle.stat()
125
+ if (opened.ino !== info.ino || opened.dev !== info.dev || opened.size > DELEGATION_HARD_BYTES ||
126
+ opened.uid !== info.uid || (opened.mode & 0o777) !== 0o600) throw new Error("delegation store changed while reading")
127
+ const buffer = Buffer.alloc(DELEGATION_HARD_BYTES + 1)
128
+ let length = 0
129
+ while (length < buffer.length) {
130
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null)
131
+ if (!bytesRead) break
132
+ length += bytesRead
133
+ }
134
+ if (length > DELEGATION_HARD_BYTES) throw new Error("delegation store exceeds size limit")
135
+ snapshot = JSON.parse(buffer.subarray(0, length).toString("utf8"))
136
+ } finally { await handle.close() }
137
+ if (!snapshot || snapshot.schema_version !== 1 || !Array.isArray(snapshot.target) ||
138
+ snapshot.target.length !== 2 || snapshot.target.some((v) => typeof v !== "string" || !v) ||
139
+ (target && JSON.stringify(snapshot.target) !== JSON.stringify(target)) ||
140
+ this._file(...snapshot.target) !== file || !Array.isArray(snapshot.rows)) throw new Error("invalid delegation snapshot")
141
+ const ids = new Set()
142
+ for (const row of snapshot.rows) {
143
+ if (!row || typeof row !== "object" || Array.isArray(row) ||
144
+ Object.keys(row).some((key) => !["id", "started", "startedAt", "terminal", "at"].includes(key)) ||
145
+ !Number.isFinite(row.at) || ids.has(row.id) || (!row.started && !row.terminal) ||
146
+ (row.started ? !Number.isFinite(row.startedAt) : row.startedAt !== undefined)) throw new Error("invalid delegation row")
147
+ ids.add(row.id)
148
+ for (const key of ["started", "terminal"]) {
149
+ if (row[key] !== undefined && (!validEvent(row[key]) || row[key].delegation_id !== row.id ||
150
+ (key === "started") !== (row[key].phase === "started"))) throw new Error("invalid delegation event")
151
+ }
152
+ }
153
+ return snapshot.rows
154
+ }
155
+ _prune(rows, kept = []) {
156
+ rows = rows.filter((row) => kept.includes(row) || !row.terminal || row.at > this.now() - DELEGATION_RETENTION_MS)
157
+ const oldest = rows.filter((row) => row.terminal).sort((a, b) => a.at - b.at)
158
+ const removed = new Set(oldest.filter((row) => !kept.includes(row))
159
+ .slice(0, Math.max(0, oldest.length - DELEGATION_SESSION_LIMIT)))
160
+ return rows.filter((row) => !removed.has(row))
161
+ }
162
+ rows(cwd, sessionId) {
163
+ return this._serial(async () => {
164
+ await this._prepare()
165
+ const file = this._file(cwd, sessionId)
166
+ return this._prune(await this._read(file, [cwd, sessionId]))
167
+ })
168
+ }
169
+ record(cwd, sessionId, event) {
170
+ // Snapshot caller-owned input before waiting for the writer.
171
+ const copy = structuredClone(event)
172
+ return this._serial(() => this._record(cwd, sessionId, copy))
173
+ }
174
+ async _record(cwd, sessionId, event) {
175
+ if (!validEvent(event)) throw new Error("invalid delegation event")
176
+ await this._prepare()
177
+ const file = this._file(cwd, sessionId)
178
+ const target = [cwd, sessionId]
179
+ const rows = this._prune(await this._read(file, target))
180
+ const result = this._apply(rows, target, event)
181
+ if (result.acceptance === "accept") await this._write(file, result.serialized)
182
+ return result.acceptance
183
+ }
184
+ // Apply to a copy so a rejected event cannot partially mutate the caller's rows.
185
+ _apply(sourceRows, target, event) {
186
+ const rows = structuredClone(sourceRows)
187
+ const result = this._mutate(rows, event)
188
+ if (result.acceptance !== "accept") return result
189
+ return this._finalize(rows, target, [result.row], event.phase === "started")
190
+ }
191
+ /** Mutate rows in place. Rejections return before touching anything. */
192
+ _mutate(rows, event) {
193
+ if (!validEvent(event)) throw new Error("invalid delegation event")
194
+ let row = rows.find((candidate) => candidate.id === event.delegation_id)
195
+ if (event.phase === "started") {
196
+ if (row?.terminal) return { acceptance: "stale" }
197
+ if (row?.started) return { acceptance: "duplicate" }
198
+ } else if (row?.terminal?.phase === event.phase) return { acceptance: "duplicate" }
199
+ if (!row) {
200
+ row = { id: event.delegation_id, at: this.now() }
201
+ rows.push(row)
202
+ }
203
+ row.at = this.now()
204
+ if (event.phase === "started") {
205
+ row.started = event
206
+ row.startedAt = row.at
207
+ } else {
208
+ if (row.started && !row.terminal) row.started = compactTerminalEvent(row.started, true)
209
+ row.terminal = compactTerminalEvent(event)
210
+ }
211
+ return { acceptance: "accept", row }
212
+ }
213
+ /** Prune, enforce the byte limit and serialize once. `kept` rows are never evicted. */
214
+ _finalize(rows, target, kept, started) {
215
+ rows = this._prune(rows, kept)
216
+ const limit = started ? DELEGATION_SOFT_BYTES : DELEGATION_HARD_BYTES
217
+ let bytes = Buffer.byteLength(JSON.stringify({ schema_version: 1, target, rows }))
218
+ if (bytes > limit) {
219
+ // Keep the incoming terminals: evicting them would immediately lose their tombstones.
220
+ const evictable = rows.filter((candidate) => !kept.includes(candidate) && candidate.terminal)
221
+ .sort((a, b) => a.at - b.at)
222
+ const removed = new Set()
223
+ // A kept row always remains, so each eviction drops exactly one separating comma.
224
+ while (bytes > limit && evictable.length) {
225
+ const oldest = evictable.shift()
226
+ removed.add(oldest)
227
+ bytes -= Buffer.byteLength(JSON.stringify(oldest)) + 1
228
+ }
229
+ rows = rows.filter((candidate) => !removed.has(candidate))
230
+ }
231
+ const serialized = JSON.stringify({ schema_version: 1, target, rows })
232
+ if (Buffer.byteLength(serialized) > limit) {
233
+ if (started) return { acceptance: "full" }
234
+ throw new Error("delegation store exceeds size limit")
235
+ }
236
+ return { acceptance: "accept", rows, serialized }
237
+ }
238
+ async _write(file, serialized) {
239
+ const tmp = file + "." + randomUUID() + ".tmp"
240
+ try {
241
+ await fs.writeFile(tmp, serialized, { flag: "wx", mode: 0o600 })
242
+ await this._checkDirectory()
243
+ await fs.rename(tmp, file)
244
+ } finally {
245
+ await fs.unlink(tmp).catch((err) => { if (err.code !== "ENOENT") throw err })
246
+ }
247
+ }
248
+ /** Reconcile the entire liveness plan against one fresh snapshot, then write at most once. */
249
+ replayPlan(cwd, sessionId, plan, emit) {
250
+ return this._serial(async () => {
251
+ const file = this._file(cwd, sessionId)
252
+ const target = [cwd, sessionId]
253
+ const rows = this._prune(await this._read(file, target))
254
+ const current = new Map(rows.map((row) => [row.id, JSON.stringify(row)]))
255
+ // One working copy for the whole plan; `current` keeps the snapshot used for matching.
256
+ const working = structuredClone(rows)
257
+ const kept = []
258
+ const deliveries = []
259
+ for (const { row, stopped } of plan) {
260
+ if (current.get(row.id) !== JSON.stringify(row)) continue
261
+ if (stopped) {
262
+ try {
263
+ const result = this._mutate(working, stopped)
264
+ if (result.acceptance === "accept") kept.push(result.row)
265
+ } catch (err) {
266
+ this.logger?.warn?.({ error: err?.message, delegation_id: row.id }, "delegation stopped persistence failed")
267
+ }
268
+ deliveries.push([stopped])
269
+ } else {
270
+ deliveries.push([row.started, row.terminal].filter(Boolean))
271
+ }
272
+ }
273
+ if (kept.length) {
274
+ try {
275
+ // Like sequential records, only the last accepted terminal is protected; earlier ones
276
+ // in the batch stay evictable so an oversized batch still persists what fits.
277
+ const { serialized } = this._finalize(working, target, kept.slice(-1), false)
278
+ await this._write(file, serialized)
279
+ } catch (err) {
280
+ this.logger?.warn?.({ error: err?.message }, "delegation stopped persistence failed")
281
+ }
282
+ }
283
+ for (const events of deliveries) {
284
+ try { for (const event of events) emit(event) } catch (err) {
285
+ this.logger?.warn?.({ error: err?.message, delegation_id: events[0].delegation_id }, "delegation row replay failed")
286
+ }
287
+ }
288
+ })
289
+ }
290
+ async sweep() {
291
+ if (this.sweeping || this.now() - this.lastSweep < 86400 * 1000) return
292
+ this.sweeping = true
293
+ try {
294
+ const names = await this._serial(async () => {
295
+ try { await this._checkDirectory() } catch (err) {
296
+ if (err.code === "ENOENT") return []
297
+ throw err
298
+ }
299
+ return fs.readdir(this.dir)
300
+ })
301
+ for (const name of names) {
302
+ try { await this._serial(() => this._sweepFile(name)) } catch (err) {
303
+ this.logger?.warn?.({ error: err?.message, file: name }, "delegation sweep file failed")
304
+ }
305
+ }
306
+ // Only a completed pass suppresses the next 24 hours; a directory failure retries.
307
+ this.lastSweep = this.now()
308
+ } finally {
309
+ this.sweeping = false
310
+ }
311
+ }
312
+ async _sweepFile(name) {
313
+ await this._checkDirectory()
314
+ const file = path.join(this.dir, name)
315
+ if (/^[a-f0-9]{64}\.json\.[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\.tmp$/.test(name)) {
316
+ const info = await fs.lstat(file).catch((err) => { if (err.code !== "ENOENT") throw err })
317
+ if (info?.isFile() && !info.isSymbolicLink() && info.uid === process.getuid?.() &&
318
+ info.mtimeMs <= this.now() - 3600 * 1000) await fs.unlink(file)
319
+ return
320
+ }
321
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) return
322
+ let rows
323
+ try { rows = await this._read(file) } catch { return }
324
+ if (rows.length && rows.every((row) => row.terminal && row.at <= this.now() - DELEGATION_RETENTION_MS)) {
325
+ await fs.unlink(file)
326
+ }
327
+ }
328
+ }
@@ -81,6 +81,8 @@ import path from "node:path"
81
81
  import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
82
82
  import { describeLiveSessionsUnion } from "./live-sessions.mjs"
83
83
  import { ReviewEventStore, validReviewEvent } from "./review-event-store.mjs"
84
+ import { truncateString } from "./truncate-string.mjs"
85
+ import { DelegationEventStore } from "./delegation-event-store.mjs"
84
86
  import { listChatTargets } from "./chat-targets.mjs"
85
87
 
86
88
  export const SIBLING_REQUESTS_DIR =
@@ -97,26 +99,13 @@ const SIBLING_SWEEP_DEBOUNCE_MS = 25
97
99
  */
98
100
  export const SIBLING_BACKSTOP_SWEEP_MS = 1000
99
101
 
100
- export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
101
- export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
102
102
  export const SIBLING_RESULT_RETENTION_MS = 24 * 60 * 60 * 1000
103
103
  const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
104
104
  const DELEGATE_PROMPT_MAX_LENGTH = 64_000
105
- const TERMINAL_DELEGATE_PROMPT_MAX_LENGTH = 2_000
106
- const TERMINAL_DELEGATE_AGENT_MAX_LENGTH = 200
107
- const TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH = 2_000
108
105
  const DELEGATION_ID_MAX_LENGTH = 200
109
106
 
110
107
  const execFile = promisify(execFileCallback)
111
108
 
112
- function truncateString(value, maxLength, truncationNote) {
113
- if (value.length <= maxLength) return value
114
- let end = maxLength
115
- const lastCodeUnit = value.charCodeAt(end - 1)
116
- if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1
117
- return value.slice(0, end) + truncationNote(value.length)
118
- }
119
-
120
109
  function truncateDelegatePrompt(prompt) {
121
110
  return truncateString(
122
111
  prompt,
@@ -125,45 +114,6 @@ function truncateDelegatePrompt(prompt) {
125
114
  )
126
115
  }
127
116
 
128
- function compactTerminalEvent(event, includePrompt = false) {
129
- const storedEvent = {}
130
- for (const field of [
131
- "type",
132
- "subtype",
133
- "delegation_id",
134
- "phase",
135
- "background",
136
- "timestamp",
137
- ]) {
138
- if (event[field] !== undefined) storedEvent[field] = event[field]
139
- }
140
-
141
- if (typeof event.agent === "string") {
142
- storedEvent.agent = truncateString(
143
- event.agent,
144
- TERMINAL_DELEGATE_AGENT_MAX_LENGTH,
145
- (length) => `\n\n[エージェント名はここで打ち切られています(全 ${length} 文字)]`,
146
- )
147
- }
148
- if (typeof event.description === "string") {
149
- storedEvent.description = truncateString(
150
- event.description,
151
- TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH,
152
- (length) => `\n\n[説明はここで打ち切られています(全 ${length} 文字)]`,
153
- )
154
- }
155
- if (includePrompt && typeof event.prompt === "string") {
156
- storedEvent.prompt = truncateString(
157
- event.prompt,
158
- TERMINAL_DELEGATE_PROMPT_MAX_LENGTH,
159
- (length) =>
160
- `\n\n[終了済み委譲の依頼本文はここで打ち切られています(全 ${length} 文字)]`,
161
- )
162
- }
163
-
164
- return storedEvent
165
- }
166
-
167
117
  /**
168
118
  * `ps` の起動時刻を送受信双方で比較する契約形へ正規化する。
169
119
  * 先頭・末尾の Unicode 空白だけを除去し、日時本体の内部空白は変更しない。
@@ -455,6 +405,7 @@ export class SiblingInjectBridge {
455
405
  isInputBlocked,
456
406
  starter,
457
407
  watchFn = watch,
408
+ delegationDir,
458
409
  } = {}) {
459
410
  this.dir = dir
460
411
  this.claudeBridge = claudeBridge
@@ -478,10 +429,7 @@ export class SiblingInjectBridge {
478
429
  this._starter = starter
479
430
  this._watchFn = watchFn
480
431
  this._newTurnInFlight = 0
481
- /** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
482
- this._activeDelegations = new Map()
483
- /** @type {Map<string, Map<string, {startedEvent?: object, terminalEvent: object, startedAt?: number, recordedAt: number}>>} 終了済み委譲の再送情報を兼ねた墓標。 */
484
- this._terminalDelegations = new Map()
432
+ this.delegationEvents = new DelegationEventStore(delegationDir, now, logger)
485
433
  }
486
434
 
487
435
  /** restart coordinator が通常/明示注入の bridge await 中を観測する。 */
@@ -510,93 +458,6 @@ export class SiblingInjectBridge {
510
458
  }
511
459
  }
512
460
 
513
- _delegationSessionKey(cwd, sessionId) {
514
- return `${cwd}\u0000${sessionId}`
515
- }
516
-
517
- _pruneDelegations() {
518
- const cutoff = this._now() - DELEGATION_LEDGER_RETENTION_MS
519
- for (const [sessionKey, entries] of this._activeDelegations) {
520
- for (const [delegationId, entry] of entries) {
521
- if (entry.recordedAt < cutoff) entries.delete(delegationId)
522
- }
523
- if (entries.size === 0) this._activeDelegations.delete(sessionKey)
524
- }
525
- for (const [sessionKey, entries] of this._terminalDelegations) {
526
- for (const [delegationId, entry] of entries) {
527
- if (entry.recordedAt < cutoff) entries.delete(delegationId)
528
- }
529
- if (entries.size === 0) this._terminalDelegations.delete(sessionKey)
530
- }
531
- }
532
-
533
- _recordDelegateEvent(cwd, sessionId, event, { prune = true } = {}) {
534
- if (prune) this._pruneDelegations()
535
- const sessionKey = this._delegationSessionKey(cwd, sessionId)
536
- if (event.phase !== "started") {
537
- const activeEntry = this._activeDelegations.get(sessionKey)?.get(event.delegation_id)
538
- const existingTerminalEntry = this._terminalDelegations
539
- .get(sessionKey)
540
- ?.get(event.delegation_id)
541
- this._activeDelegations.get(sessionKey)?.delete(event.delegation_id)
542
- if (this._activeDelegations.get(sessionKey)?.size === 0) {
543
- this._activeDelegations.delete(sessionKey)
544
- }
545
- let terminalEntries = this._terminalDelegations.get(sessionKey)
546
- if (!terminalEntries) {
547
- terminalEntries = new Map()
548
- this._terminalDelegations.set(sessionKey, terminalEntries)
549
- }
550
- terminalEntries.delete(event.delegation_id)
551
- terminalEntries.set(event.delegation_id, {
552
- ...(activeEntry
553
- ? {
554
- startedEvent: compactTerminalEvent(activeEntry.event, true),
555
- startedAt: activeEntry.recordedAt,
556
- }
557
- : existingTerminalEntry?.startedEvent && existingTerminalEntry.startedAt !== undefined
558
- ? {
559
- startedEvent: existingTerminalEntry.startedEvent,
560
- startedAt: existingTerminalEntry.startedAt,
561
- }
562
- : {}),
563
- terminalEvent: compactTerminalEvent(event),
564
- recordedAt: this._now(),
565
- })
566
- while (terminalEntries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
567
- terminalEntries.delete(terminalEntries.keys().next().value)
568
- }
569
- return
570
- }
571
- if (this._terminalDelegations.get(sessionKey)?.has(event.delegation_id)) return
572
- let entries = this._activeDelegations.get(sessionKey)
573
- if (!entries) {
574
- entries = new Map()
575
- this._activeDelegations.set(sessionKey, entries)
576
- }
577
- entries.delete(event.delegation_id)
578
- entries.set(event.delegation_id, { event, recordedAt: this._now() })
579
- while (entries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
580
- entries.delete(entries.keys().next().value)
581
- }
582
- }
583
-
584
- _delegateEventAcceptance(cwd, sessionId, event) {
585
- this._pruneDelegations()
586
- const sessionKey = this._delegationSessionKey(cwd, sessionId)
587
- const terminal = this._terminalDelegations.get(sessionKey)?.get(event.delegation_id)
588
- if (event.phase === "started") {
589
- if (terminal) return "stale"
590
- return this._activeDelegations.get(sessionKey)?.has(event.delegation_id)
591
- ? "duplicate"
592
- : "accept"
593
- }
594
- if (!terminal) return "accept"
595
- // terminal 同士は後から届いた実イベントを最新終端として上書きできる。
596
- // 例: replay が PID 消失から stopped を合成した後に producer の completed が届く。
597
- return terminal.terminalEvent?.phase === event.phase ? "duplicate" : "accept"
598
- }
599
-
600
461
  _liveClaudeSession(cwd, sessionId) {
601
462
  const matches = describeLiveSessionsUnion(this._bridges(), {
602
463
  cwd,
@@ -638,52 +499,36 @@ export class SiblingInjectBridge {
638
499
  /** attach/再接続時に同じ cwd+session の進行中・終了済み委譲を開始順で再送する。 */
639
500
  async replayDelegations(cwd, sessionId) {
640
501
  if (!cwd || !sessionId) return
641
- this._pruneDelegations()
642
- const sessionKey = this._delegationSessionKey(cwd, sessionId)
643
- const replayEntries = [
644
- ...[...(this._activeDelegations.get(sessionKey) || [])].map(([delegationId, entry]) => ({
645
- delegationId,
646
- entry,
647
- kind: "active",
648
- startedAt: entry.recordedAt,
649
- })),
650
- ...[...(this._terminalDelegations.get(sessionKey) || [])]
651
- .filter(([, entry]) => entry.startedEvent && entry.startedAt !== undefined)
652
- .map(([delegationId, entry]) => ({
653
- delegationId,
654
- entry,
655
- kind: "terminal",
656
- startedAt: entry.startedAt,
657
- })),
658
- ].sort((a, b) => a.startedAt - b.startedAt)
659
-
660
- for (const { delegationId, entry, kind } of replayEntries) {
661
- if (kind === "terminal") {
662
- if (this._terminalDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
663
- this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.startedEvent)
664
- this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.terminalEvent)
665
- continue
666
- }
667
- const alive = await this._delegateProcessAlive(entry.event)
668
- // 生存確認中に終端が届いた場合、古い snapshot の started/stopped を後から流さない。
669
- if (this._activeDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
670
- if (alive) {
671
- this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.event)
672
- continue
673
- }
674
- const stopped = {
675
- type: "system",
676
- subtype: "codex_delegate",
677
- delegation_id: delegationId,
678
- phase: "stopped",
679
- ...(entry.event.agent !== undefined ? { agent: entry.event.agent } : {}),
680
- ...(entry.event.description !== undefined
681
- ? { description: entry.event.description }
682
- : {}),
683
- timestamp: new Date(this._now()).toISOString(),
502
+ try {
503
+ const snapshot = await this.delegationEvents.rows(cwd, sessionId)
504
+ const rows = snapshot.filter((row) => row.started).sort((a, b) => a.startedAt - b.startedAt)
505
+ const plan = []
506
+ for (const row of rows) {
507
+ let alive = true
508
+ try {
509
+ alive = row.terminal || await this._delegateProcessAlive(row.started)
510
+ } catch (err) {
511
+ this.logger?.warn?.({ error: err?.message, delegation_id: row.id }, "delegation PID check failed")
512
+ }
513
+ try {
514
+ const stopped = alive ? null : {
515
+ type: "system",
516
+ subtype: "codex_delegate",
517
+ delegation_id: row.id,
518
+ phase: "stopped",
519
+ ...(row.started.agent !== undefined ? { agent: row.started.agent } : {}),
520
+ ...(row.started.description !== undefined ? { description: row.started.description } : {}),
521
+ timestamp: new Date(this._now()).toISOString(),
522
+ }
523
+ plan.push({ row, stopped })
524
+ } catch (err) {
525
+ this.logger?.warn?.({ error: err?.message, delegation_id: row.id }, "delegation row replay failed")
526
+ }
684
527
  }
685
- this._recordDelegateEvent(cwd, sessionId, stopped)
686
- this.claudeBridge?.emitToStream?.(cwd, sessionId, stopped)
528
+ await this.delegationEvents.replayPlan(cwd, sessionId, plan,
529
+ (event) => this.claudeBridge?.emitToStream?.(cwd, sessionId, event))
530
+ } catch (err) {
531
+ this.logger?.warn?.({ error: err?.message }, "delegation replay failed")
687
532
  }
688
533
  }
689
534
 
@@ -794,6 +639,9 @@ export class SiblingInjectBridge {
794
639
 
795
640
  /** 起動時 / 取りこぼし保険: dir 内の全要求ファイルを読む。 */
796
641
  async _sweep({ startup = false } = {}) {
642
+ void this.delegationEvents.sweep().catch((err) => {
643
+ this.logger?.warn?.({ error: err?.message }, "delegation sweep failed")
644
+ })
797
645
  let names = []
798
646
  try {
799
647
  names = await readdir(this.dir)
@@ -1044,29 +892,39 @@ export class SiblingInjectBridge {
1044
892
  ...(pid_started_at !== undefined ? { pid_started_at } : {}),
1045
893
  timestamp: timestamp || new Date().toISOString(),
1046
894
  }
1047
- // 墓標確認から配送成功時の台帳反映まで await を挟まない同期区間に閉じる。
1048
- // 配送失敗した started は台帳へ残さず、終端後に遅着した started は配送前に拒否する。
1049
- const acceptance = this._delegateEventAcceptance(cwd, target_session_id, event)
895
+ let acceptance
896
+ let persisted = true
897
+ try {
898
+ acceptance = await this.delegationEvents.record(cwd, target_session_id, event)
899
+ } catch (err) {
900
+ persisted = false
901
+ this.logger?.warn?.({ request_id, error: err?.message }, "delegation persistence failed")
902
+ }
1050
903
  if (acceptance === "duplicate") {
1051
904
  // ACK は「過去に受理した」だけでなく、対象 stream が現在も live であることも示す。
1052
905
  // 台帳だけで成功を返すと、宛先消失後の retry を受理済みと誤認させる。
1053
906
  const live = this._liveClaudeSession(cwd, target_session_id)
1054
- result = live.status === "delivered"
907
+ // 保存後に未配送のまま切断した可能性があるため、duplicate も再配送する。
908
+ const delivered = live.status === "delivered"
909
+ ? this.claudeBridge?.emitToStream?.(cwd, target_session_id, event) || { status: "not_found" }
910
+ : live
911
+ result = delivered.status === "delivered"
1055
912
  ? { ok: true, duplicate: true }
1056
- : this._delegateTargetFailure(live)
913
+ : this._delegateTargetFailure(delivered)
1057
914
  } else if (acceptance === "stale") {
1058
915
  result = { ok: false, error: "stale_delegate_event" }
916
+ } else if (acceptance === "full") {
917
+ result = { ok: false, error: "delegation_store_full" }
1059
918
  } else {
1060
919
  const r = this.claudeBridge?.emitToStream
1061
920
  ? this.claudeBridge.emitToStream(cwd, target_session_id, event)
1062
921
  : { status: "not_found" }
1063
922
  if (r.status === "delivered") {
1064
- // acceptance 直前に prune 済み。同じ同期区間で二重 prune しない。
1065
- this._recordDelegateEvent(cwd, target_session_id, event, { prune: false })
1066
923
  result = { ok: true, delivered_to: r.session }
1067
924
  } else {
1068
- result = this._delegateTargetFailure(r)
925
+ result = { ...this._delegateTargetFailure(r), persisted }
1069
926
  }
927
+ if (!persisted) result.persisted = false
1070
928
  }
1071
929
  }
1072
930
  } else if (isList) {
@@ -0,0 +1,7 @@
1
+ export function truncateString(value, maxLength, truncationNote) {
2
+ if (value.length <= maxLength) return value
3
+ let end = maxLength
4
+ const lastCodeUnit = value.charCodeAt(end - 1)
5
+ if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1
6
+ return value.slice(0, end) + truncationNote(value.length)
7
+ }