@cocorograph/hub-agent 0.8.119 → 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 +1 -1
- package/src/delegation-event-store.mjs +328 -0
- package/src/main.mjs +27 -7
- package/src/sibling-inject.mjs +54 -196
- package/src/truncate-string.mjs +7 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -2862,6 +2862,7 @@ function logHistoryDelivery(ctx, via, stream_id, timing, payload, sendOk) {
|
|
|
2862
2862
|
ws_buffered_before: timing.ws_buffered_before ?? null,
|
|
2863
2863
|
payload_bytes: timing.payload_bytes ?? null,
|
|
2864
2864
|
stringify_ms: timing.stringify_ms ?? null,
|
|
2865
|
+
widened: timing.widened === true,
|
|
2865
2866
|
events: payload?.events?.length || 0, total_lines: payload?.total_lines || 0,
|
|
2866
2867
|
truncated: payload?.truncated === true, send_ok: Boolean(sendOk),
|
|
2867
2868
|
ws_buffered: ctx.client.bufferedAmount ?? null,
|
|
@@ -2897,10 +2898,18 @@ export function sendClaudeAttachHistory(ctx, stream_id, payload, timing) {
|
|
|
2897
2898
|
}
|
|
2898
2899
|
}
|
|
2899
2900
|
|
|
2900
|
-
// attach 時の先回り push
|
|
2901
|
-
//
|
|
2902
|
-
//
|
|
2903
|
-
|
|
2901
|
+
// attach 時の先回り push は末尾 100 元行に絞る (2026-10-27)。500 行だと初回 frame が
|
|
2902
|
+
// 中央値 375KB・最大 1.8MB になり、送信待ちと Hub/ブラウザの JSON 処理が ready→表示の
|
|
2903
|
+
// 遅れの大半を占めていた (100 行で中央値 72KB・最大 304KB、親の表示イベント 34〜54 件)。
|
|
2904
|
+
// ⚠️ ブラウザ側の窓 (INITIAL_CLAUDE_HISTORY_LINES=500) は変えない。追加取得は
|
|
2905
|
+
// 「現在の窓 × 2」を要求し、受け取った窓から今の先頭イベントより前だけを取り出すため、
|
|
2906
|
+
// push が 100 行でも次の要求は 1000 行になり境界を必ず含む。逆にブラウザ側を 100 にすると、
|
|
2907
|
+
// 旧 agent の 500 行 push の後に 200 行を要求して境界が見つからず追加取得が止まる。
|
|
2908
|
+
// ⚠️ 100 行の窓に境界候補 (照合キーを持つイベント) が無いと、旧ブラウザは追加取得が止まる。
|
|
2909
|
+
// Hub 側は候補ゼロの窓を権威的に取り込む対応を 2026-10-28 に本番反映済み (Hub PR #5700)。
|
|
2910
|
+
// 対応前の画面を開いたままのタブだけは再読み込みが要る。
|
|
2911
|
+
export const ATTACH_HISTORY_PUSH_LINES = 100
|
|
2912
|
+
export const ATTACH_HISTORY_PUSH_FALLBACK_LINES = 500
|
|
2904
2913
|
|
|
2905
2914
|
/**
|
|
2906
2915
|
* claude.attach の ready 直後にpushする履歴を組み立てる。
|
|
@@ -2943,14 +2952,25 @@ export async function loadClaudeAttachHistory({
|
|
|
2943
2952
|
const projectsRoot = resolved
|
|
2944
2953
|
? resolved.projectsRoot
|
|
2945
2954
|
: await getActiveProjectsRoot()
|
|
2946
|
-
const
|
|
2955
|
+
const fetchWindow = (maxLines) => fetchHistoryFn({
|
|
2947
2956
|
cwd,
|
|
2948
2957
|
session_id: sessionId,
|
|
2949
|
-
maxLines
|
|
2958
|
+
maxLines,
|
|
2950
2959
|
projectsRoot,
|
|
2951
2960
|
logger,
|
|
2952
2961
|
timing,
|
|
2953
|
-
})
|
|
2962
|
+
})
|
|
2963
|
+
const result = await timeHistoryPhase(timing, "fetch_ms", async () => {
|
|
2964
|
+
const narrow = await fetchWindow(ATTACH_HISTORY_PUSH_LINES)
|
|
2965
|
+
// ⚠️ 末尾 100 行が表示対象外の行 (progress 等) だけだと events が空になり、ブラウザの
|
|
2966
|
+
// 追加取得は「今の先頭イベント」を境界にするため境界が無く止まる (cross-review major)。
|
|
2967
|
+
// 表示イベントが 1 件も無く続きがあるときだけ、従来の 500 行で読み直す (従来と同等以上)。
|
|
2968
|
+
if (narrow?.truncated === true && !narrow.error && (narrow.events?.length || 0) === 0) {
|
|
2969
|
+
timing.widened = true
|
|
2970
|
+
return fetchWindow(ATTACH_HISTORY_PUSH_FALLBACK_LINES)
|
|
2971
|
+
}
|
|
2972
|
+
return narrow
|
|
2973
|
+
})
|
|
2954
2974
|
return withProfile({
|
|
2955
2975
|
type: "claude.history.response",
|
|
2956
2976
|
session_id: sessionId,
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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.
|
|
686
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|