@cocorograph/hub-agent 0.8.120 → 0.8.122
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/codex-appserver-client.mjs +2 -0
- package/src/codex-stream-bridge.mjs +2 -2
- package/src/delegate-display-auth.mjs +80 -0
- package/src/delegation-event-store.mjs +332 -0
- package/src/main.mjs +9 -0
- package/src/sibling-inject.mjs +101 -202
- package/src/sibling-token.mjs +10 -0
- package/src/truncate-string.mjs +7 -0
package/package.json
CHANGED
|
@@ -98,6 +98,8 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
98
98
|
"COCKPIT_SIBLING_SELF_KIND",
|
|
99
99
|
"COCKPIT_SIBLING_CWD",
|
|
100
100
|
"COCKPIT_SIBLING_TOKEN",
|
|
101
|
+
"COCKPIT_DELEGATE_DISPLAY_TOKEN",
|
|
102
|
+
"COCKPIT_DELEGATE_EVENT_ACK_VERSION",
|
|
101
103
|
]) {
|
|
102
104
|
const val = spawnEnv[key]
|
|
103
105
|
// TOML 文字列としてクォート (パスの空白/特殊文字対策)。値に " は含まれない
|
|
@@ -4204,7 +4204,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
4204
4204
|
}
|
|
4205
4205
|
|
|
4206
4206
|
/** Display events never start a turn and only target an exact thread in this cwd. */
|
|
4207
|
-
emitToStream(cwd, sessionId, event) {
|
|
4207
|
+
emitToStream(cwd, sessionId, event, notificationName = "cockpit/review_event") {
|
|
4208
4208
|
if (!cwd || !sessionId) return { status: "not_found" }
|
|
4209
4209
|
const matches = [...this._liveSessions()].filter((s) => s.cwd === cwd && s.threadId === sessionId)
|
|
4210
4210
|
if (matches.length !== 1) return { status: matches.length ? "ambiguous" : "not_found" }
|
|
@@ -4214,7 +4214,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
4214
4214
|
producer_seq: session.seq,
|
|
4215
4215
|
thread_id: session.threadId,
|
|
4216
4216
|
cwd: session.cwd,
|
|
4217
|
-
notification: { method:
|
|
4217
|
+
notification: { method: notificationName, params: event },
|
|
4218
4218
|
})
|
|
4219
4219
|
return { status: "delivered", session: this._describe(session) }
|
|
4220
4220
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"
|
|
4
|
+
import { paths } from "./config.mjs"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Publish a complete key atomically: write a temp file in the same directory, then link(2) it to
|
|
8
|
+
* the final name. link fails with EEXIST if another process won, so the published file is always
|
|
9
|
+
* complete and never overwritten (overwriting would invalidate tokens already handed out).
|
|
10
|
+
* An existing malformed key is NOT regenerated here; key() rejects it so the failure stays visible.
|
|
11
|
+
*/
|
|
12
|
+
function publishKey(dir, file) {
|
|
13
|
+
const tmp = path.join(dir, `key.${process.pid}.${randomBytes(8).toString("hex")}.tmp`)
|
|
14
|
+
try {
|
|
15
|
+
const fd = fs.openSync(tmp, "wx", 0o600)
|
|
16
|
+
try {
|
|
17
|
+
fs.writeFileSync(fd, randomBytes(32))
|
|
18
|
+
fs.fsyncSync(fd)
|
|
19
|
+
} finally { fs.closeSync(fd) }
|
|
20
|
+
try {
|
|
21
|
+
fs.linkSync(tmp, file)
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (err.code !== "EEXIST") throw new Error("delegate display auth key creation failed")
|
|
24
|
+
}
|
|
25
|
+
} finally {
|
|
26
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function key() {
|
|
31
|
+
const dir = process.env.HUB_DELEGATE_DISPLAY_AUTH_DIR || path.join(paths.configDir, "delegate-display-auth")
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
33
|
+
const directory = fs.lstatSync(dir)
|
|
34
|
+
if (!directory.isDirectory() || directory.isSymbolicLink() || process.getuid?.() == null ||
|
|
35
|
+
directory.uid !== process.getuid() || (directory.mode & 0o777) !== 0o700) {
|
|
36
|
+
throw new Error("unsafe delegate display auth directory")
|
|
37
|
+
}
|
|
38
|
+
const file = path.join(dir, "key")
|
|
39
|
+
let info
|
|
40
|
+
try {
|
|
41
|
+
info = fs.lstatSync(file)
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code !== "ENOENT") throw err
|
|
44
|
+
publishKey(dir, file)
|
|
45
|
+
info = fs.lstatSync(file)
|
|
46
|
+
}
|
|
47
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid() ||
|
|
48
|
+
(info.mode & 0o777) !== 0o600 || info.size !== 32) {
|
|
49
|
+
throw new Error("unsafe delegate display auth key")
|
|
50
|
+
}
|
|
51
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
52
|
+
try {
|
|
53
|
+
const opened = fs.fstatSync(fd)
|
|
54
|
+
if (!opened.isFile() || opened.ino !== info.ino || opened.dev !== info.dev ||
|
|
55
|
+
opened.uid !== info.uid || (opened.mode & 0o777) !== 0o600 || opened.size !== 32) {
|
|
56
|
+
throw new Error("delegate display auth key changed while reading")
|
|
57
|
+
}
|
|
58
|
+
const bytes = Buffer.alloc(33)
|
|
59
|
+
let length = 0
|
|
60
|
+
while (length < bytes.length) {
|
|
61
|
+
const count = fs.readSync(fd, bytes, length, bytes.length - length, null)
|
|
62
|
+
if (!count) break
|
|
63
|
+
length += count
|
|
64
|
+
}
|
|
65
|
+
if (length !== 32) throw new Error("invalid delegate display auth key length")
|
|
66
|
+
return bytes.subarray(0, 32)
|
|
67
|
+
} finally { fs.closeSync(fd) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function displayTokenForCwd(cwd) {
|
|
71
|
+
if (typeof cwd !== "string" || !cwd) throw new Error("invalid delegate display cwd")
|
|
72
|
+
return "v1." + createHmac("sha256", key()).update("delegate-event\0" + cwd).digest("base64url")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function verifyDisplayToken(cwd, token) {
|
|
76
|
+
if (typeof cwd !== "string" || !cwd || typeof token !== "string" || !/^v1\.[A-Za-z0-9_-]{43}$/.test(token)) return false
|
|
77
|
+
const expected = Buffer.from(displayTokenForCwd(cwd))
|
|
78
|
+
const actual = Buffer.from(token)
|
|
79
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
|
80
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
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
|
+
_target(cwd, sessionId, kind = "claude") {
|
|
96
|
+
if (!["claude", "codex"].includes(kind)) throw new Error("invalid delegation kind")
|
|
97
|
+
return kind === "codex" ? [cwd, sessionId, kind] : [cwd, sessionId]
|
|
98
|
+
}
|
|
99
|
+
_file(cwd, sessionId, kind = "claude") {
|
|
100
|
+
if ([cwd, sessionId].some((value) => typeof value !== "string" || !value)) throw new Error("invalid delegation target")
|
|
101
|
+
return path.join(this.dir, createHash("sha256").update(JSON.stringify(this._target(cwd, sessionId, kind))).digest("hex") + ".json")
|
|
102
|
+
}
|
|
103
|
+
async _checkDirectory() {
|
|
104
|
+
const info = await fs.lstat(this.dir)
|
|
105
|
+
if (!info.isDirectory() || info.isSymbolicLink() || process.getuid?.() == null ||
|
|
106
|
+
info.uid !== process.getuid() || (info.mode & 0o777) !== 0o700) {
|
|
107
|
+
throw new Error("unsafe delegation directory")
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async _prepare() {
|
|
111
|
+
await fs.mkdir(this.dir, { recursive: true, mode: 0o700 })
|
|
112
|
+
await this._checkDirectory()
|
|
113
|
+
}
|
|
114
|
+
async _read(file, target) {
|
|
115
|
+
await this._checkDirectory()
|
|
116
|
+
let info
|
|
117
|
+
try { info = await fs.lstat(file) } catch (err) {
|
|
118
|
+
if (err.code === "ENOENT") return []
|
|
119
|
+
throw err
|
|
120
|
+
}
|
|
121
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
|
|
122
|
+
throw new Error("unsafe delegation store")
|
|
123
|
+
}
|
|
124
|
+
if (info.size > DELEGATION_HARD_BYTES) throw new Error("delegation store exceeds size limit")
|
|
125
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
126
|
+
let snapshot
|
|
127
|
+
try {
|
|
128
|
+
const opened = await handle.stat()
|
|
129
|
+
if (opened.ino !== info.ino || opened.dev !== info.dev || opened.size > DELEGATION_HARD_BYTES ||
|
|
130
|
+
opened.uid !== info.uid || (opened.mode & 0o777) !== 0o600) throw new Error("delegation store changed while reading")
|
|
131
|
+
const buffer = Buffer.alloc(DELEGATION_HARD_BYTES + 1)
|
|
132
|
+
let length = 0
|
|
133
|
+
while (length < buffer.length) {
|
|
134
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null)
|
|
135
|
+
if (!bytesRead) break
|
|
136
|
+
length += bytesRead
|
|
137
|
+
}
|
|
138
|
+
if (length > DELEGATION_HARD_BYTES) throw new Error("delegation store exceeds size limit")
|
|
139
|
+
snapshot = JSON.parse(buffer.subarray(0, length).toString("utf8"))
|
|
140
|
+
} finally { await handle.close() }
|
|
141
|
+
if (!snapshot || snapshot.schema_version !== 1 || !Array.isArray(snapshot.target) ||
|
|
142
|
+
!(snapshot.target.length === 2 || (snapshot.target.length === 3 && snapshot.target[2] === "codex")) || snapshot.target.some((v) => typeof v !== "string" || !v) ||
|
|
143
|
+
(target && JSON.stringify(snapshot.target) !== JSON.stringify(target)) ||
|
|
144
|
+
this._file(...snapshot.target) !== file || !Array.isArray(snapshot.rows)) throw new Error("invalid delegation snapshot")
|
|
145
|
+
const ids = new Set()
|
|
146
|
+
for (const row of snapshot.rows) {
|
|
147
|
+
if (!row || typeof row !== "object" || Array.isArray(row) ||
|
|
148
|
+
Object.keys(row).some((key) => !["id", "started", "startedAt", "terminal", "at"].includes(key)) ||
|
|
149
|
+
!Number.isFinite(row.at) || ids.has(row.id) || (!row.started && !row.terminal) ||
|
|
150
|
+
(row.started ? !Number.isFinite(row.startedAt) : row.startedAt !== undefined)) throw new Error("invalid delegation row")
|
|
151
|
+
ids.add(row.id)
|
|
152
|
+
for (const key of ["started", "terminal"]) {
|
|
153
|
+
if (row[key] !== undefined && (!validEvent(row[key]) || row[key].delegation_id !== row.id ||
|
|
154
|
+
(key === "started") !== (row[key].phase === "started"))) throw new Error("invalid delegation event")
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return snapshot.rows
|
|
158
|
+
}
|
|
159
|
+
_prune(rows, kept = []) {
|
|
160
|
+
rows = rows.filter((row) => kept.includes(row) || !row.terminal || row.at > this.now() - DELEGATION_RETENTION_MS)
|
|
161
|
+
const oldest = rows.filter((row) => row.terminal).sort((a, b) => a.at - b.at)
|
|
162
|
+
const removed = new Set(oldest.filter((row) => !kept.includes(row))
|
|
163
|
+
.slice(0, Math.max(0, oldest.length - DELEGATION_SESSION_LIMIT)))
|
|
164
|
+
return rows.filter((row) => !removed.has(row))
|
|
165
|
+
}
|
|
166
|
+
rows(cwd, sessionId, kind = "claude") {
|
|
167
|
+
return this._serial(async () => {
|
|
168
|
+
await this._prepare()
|
|
169
|
+
const file = this._file(cwd, sessionId, kind)
|
|
170
|
+
return this._prune(await this._read(file, this._target(cwd, sessionId, kind)))
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
record(cwd, sessionId, event, kind = "claude") {
|
|
174
|
+
// Snapshot caller-owned input before waiting for the writer.
|
|
175
|
+
const copy = structuredClone(event)
|
|
176
|
+
return this._serial(() => this._record(cwd, sessionId, copy, kind))
|
|
177
|
+
}
|
|
178
|
+
async _record(cwd, sessionId, event, kind = "claude") {
|
|
179
|
+
if (!validEvent(event)) throw new Error("invalid delegation event")
|
|
180
|
+
await this._prepare()
|
|
181
|
+
const file = this._file(cwd, sessionId, kind)
|
|
182
|
+
const target = this._target(cwd, sessionId, kind)
|
|
183
|
+
const rows = this._prune(await this._read(file, target))
|
|
184
|
+
const result = this._apply(rows, target, event)
|
|
185
|
+
if (result.acceptance === "accept") await this._write(file, result.serialized)
|
|
186
|
+
return result.acceptance
|
|
187
|
+
}
|
|
188
|
+
// Apply to a copy so a rejected event cannot partially mutate the caller's rows.
|
|
189
|
+
_apply(sourceRows, target, event) {
|
|
190
|
+
const rows = structuredClone(sourceRows)
|
|
191
|
+
const result = this._mutate(rows, event)
|
|
192
|
+
if (result.acceptance !== "accept") return result
|
|
193
|
+
return this._finalize(rows, target, [result.row], event.phase === "started")
|
|
194
|
+
}
|
|
195
|
+
/** Mutate rows in place. Rejections return before touching anything. */
|
|
196
|
+
_mutate(rows, event) {
|
|
197
|
+
if (!validEvent(event)) throw new Error("invalid delegation event")
|
|
198
|
+
let row = rows.find((candidate) => candidate.id === event.delegation_id)
|
|
199
|
+
if (event.phase === "started") {
|
|
200
|
+
if (row?.terminal) return { acceptance: "stale" }
|
|
201
|
+
if (row?.started) return { acceptance: "duplicate" }
|
|
202
|
+
} else if (row?.terminal?.phase === event.phase) return { acceptance: "duplicate" }
|
|
203
|
+
if (!row) {
|
|
204
|
+
row = { id: event.delegation_id, at: this.now() }
|
|
205
|
+
rows.push(row)
|
|
206
|
+
}
|
|
207
|
+
row.at = this.now()
|
|
208
|
+
if (event.phase === "started") {
|
|
209
|
+
row.started = event
|
|
210
|
+
row.startedAt = row.at
|
|
211
|
+
} else {
|
|
212
|
+
if (row.started && !row.terminal) row.started = compactTerminalEvent(row.started, true)
|
|
213
|
+
row.terminal = compactTerminalEvent(event)
|
|
214
|
+
}
|
|
215
|
+
return { acceptance: "accept", row }
|
|
216
|
+
}
|
|
217
|
+
/** Prune, enforce the byte limit and serialize once. `kept` rows are never evicted. */
|
|
218
|
+
_finalize(rows, target, kept, started) {
|
|
219
|
+
rows = this._prune(rows, kept)
|
|
220
|
+
const limit = started ? DELEGATION_SOFT_BYTES : DELEGATION_HARD_BYTES
|
|
221
|
+
let bytes = Buffer.byteLength(JSON.stringify({ schema_version: 1, target, rows }))
|
|
222
|
+
if (bytes > limit) {
|
|
223
|
+
// Keep the incoming terminals: evicting them would immediately lose their tombstones.
|
|
224
|
+
const evictable = rows.filter((candidate) => !kept.includes(candidate) && candidate.terminal)
|
|
225
|
+
.sort((a, b) => a.at - b.at)
|
|
226
|
+
const removed = new Set()
|
|
227
|
+
// A kept row always remains, so each eviction drops exactly one separating comma.
|
|
228
|
+
while (bytes > limit && evictable.length) {
|
|
229
|
+
const oldest = evictable.shift()
|
|
230
|
+
removed.add(oldest)
|
|
231
|
+
bytes -= Buffer.byteLength(JSON.stringify(oldest)) + 1
|
|
232
|
+
}
|
|
233
|
+
rows = rows.filter((candidate) => !removed.has(candidate))
|
|
234
|
+
}
|
|
235
|
+
const serialized = JSON.stringify({ schema_version: 1, target, rows })
|
|
236
|
+
if (Buffer.byteLength(serialized) > limit) {
|
|
237
|
+
if (started) return { acceptance: "full" }
|
|
238
|
+
throw new Error("delegation store exceeds size limit")
|
|
239
|
+
}
|
|
240
|
+
return { acceptance: "accept", rows, serialized }
|
|
241
|
+
}
|
|
242
|
+
async _write(file, serialized) {
|
|
243
|
+
const tmp = file + "." + randomUUID() + ".tmp"
|
|
244
|
+
try {
|
|
245
|
+
await fs.writeFile(tmp, serialized, { flag: "wx", mode: 0o600 })
|
|
246
|
+
await this._checkDirectory()
|
|
247
|
+
await fs.rename(tmp, file)
|
|
248
|
+
} finally {
|
|
249
|
+
await fs.unlink(tmp).catch((err) => { if (err.code !== "ENOENT") throw err })
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Reconcile the entire liveness plan against one fresh snapshot, then write at most once. */
|
|
253
|
+
replayPlan(cwd, sessionId, plan, emit, kind = "claude") {
|
|
254
|
+
return this._serial(async () => {
|
|
255
|
+
const file = this._file(cwd, sessionId, kind)
|
|
256
|
+
const target = this._target(cwd, sessionId, kind)
|
|
257
|
+
const rows = this._prune(await this._read(file, target))
|
|
258
|
+
const current = new Map(rows.map((row) => [row.id, JSON.stringify(row)]))
|
|
259
|
+
// One working copy for the whole plan; `current` keeps the snapshot used for matching.
|
|
260
|
+
const working = structuredClone(rows)
|
|
261
|
+
const kept = []
|
|
262
|
+
const deliveries = []
|
|
263
|
+
for (const { row, stopped } of plan) {
|
|
264
|
+
if (current.get(row.id) !== JSON.stringify(row)) continue
|
|
265
|
+
if (stopped) {
|
|
266
|
+
try {
|
|
267
|
+
const result = this._mutate(working, stopped)
|
|
268
|
+
if (result.acceptance === "accept") kept.push(result.row)
|
|
269
|
+
} catch (err) {
|
|
270
|
+
this.logger?.warn?.({ error: err?.message, delegation_id: row.id }, "delegation stopped persistence failed")
|
|
271
|
+
}
|
|
272
|
+
deliveries.push([stopped])
|
|
273
|
+
} else {
|
|
274
|
+
deliveries.push([row.started, row.terminal].filter(Boolean))
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (kept.length) {
|
|
278
|
+
try {
|
|
279
|
+
// Like sequential records, only the last accepted terminal is protected; earlier ones
|
|
280
|
+
// in the batch stay evictable so an oversized batch still persists what fits.
|
|
281
|
+
const { serialized } = this._finalize(working, target, kept.slice(-1), false)
|
|
282
|
+
await this._write(file, serialized)
|
|
283
|
+
} catch (err) {
|
|
284
|
+
this.logger?.warn?.({ error: err?.message }, "delegation stopped persistence failed")
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const events of deliveries) {
|
|
288
|
+
try { for (const event of events) emit(event) } catch (err) {
|
|
289
|
+
this.logger?.warn?.({ error: err?.message, delegation_id: events[0].delegation_id }, "delegation row replay failed")
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
async sweep() {
|
|
295
|
+
if (this.sweeping || this.now() - this.lastSweep < 86400 * 1000) return
|
|
296
|
+
this.sweeping = true
|
|
297
|
+
try {
|
|
298
|
+
const names = await this._serial(async () => {
|
|
299
|
+
try { await this._checkDirectory() } catch (err) {
|
|
300
|
+
if (err.code === "ENOENT") return []
|
|
301
|
+
throw err
|
|
302
|
+
}
|
|
303
|
+
return fs.readdir(this.dir)
|
|
304
|
+
})
|
|
305
|
+
for (const name of names) {
|
|
306
|
+
try { await this._serial(() => this._sweepFile(name)) } catch (err) {
|
|
307
|
+
this.logger?.warn?.({ error: err?.message, file: name }, "delegation sweep file failed")
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Only a completed pass suppresses the next 24 hours; a directory failure retries.
|
|
311
|
+
this.lastSweep = this.now()
|
|
312
|
+
} finally {
|
|
313
|
+
this.sweeping = false
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async _sweepFile(name) {
|
|
317
|
+
await this._checkDirectory()
|
|
318
|
+
const file = path.join(this.dir, name)
|
|
319
|
+
if (/^[a-f0-9]{64}\.json\.[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\.tmp$/.test(name)) {
|
|
320
|
+
const info = await fs.lstat(file).catch((err) => { if (err.code !== "ENOENT") throw err })
|
|
321
|
+
if (info?.isFile() && !info.isSymbolicLink() && info.uid === process.getuid?.() &&
|
|
322
|
+
info.mtimeMs <= this.now() - 3600 * 1000) await fs.unlink(file)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name)) return
|
|
326
|
+
let rows
|
|
327
|
+
try { rows = await this._read(file) } catch { return }
|
|
328
|
+
if (rows.length && rows.every((row) => row.terminal && row.at <= this.now() - DELEGATION_RETENTION_MS)) {
|
|
329
|
+
await fs.unlink(file)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -4365,6 +4365,15 @@ export async function dispatch(msg, ctx) {
|
|
|
4365
4365
|
} catch (err) {
|
|
4366
4366
|
ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
|
|
4367
4367
|
}
|
|
4368
|
+
try {
|
|
4369
|
+
if (info.threadId) {
|
|
4370
|
+
void ctx.siblingInjectBridge
|
|
4371
|
+
?.replayDelegations?.(resolvedCwd || process.env.HOME || process.cwd(), info.threadId, "codex")
|
|
4372
|
+
?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay delegation events"))
|
|
4373
|
+
}
|
|
4374
|
+
} catch (err) {
|
|
4375
|
+
ctx.logger?.warn?.({ err: err?.message }, "failed to replay delegation events")
|
|
4376
|
+
}
|
|
4368
4377
|
const session = ctx.codexBridge.sessions?.get(stream_id)
|
|
4369
4378
|
if (session && info.resuming) {
|
|
4370
4379
|
void loadResumeChatContext(session, "codex").catch(() => {})
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
* 判断材料として **外部契約に残す**。
|
|
72
72
|
*/
|
|
73
73
|
|
|
74
|
+
import { verifyDisplayToken } from "./delegate-display-auth.mjs"
|
|
74
75
|
import { watch } from "node:fs"
|
|
75
76
|
import { chmod, lstat, mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
|
|
76
77
|
import { execFile as execFileCallback } from "node:child_process"
|
|
@@ -81,6 +82,8 @@ import path from "node:path"
|
|
|
81
82
|
import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
|
|
82
83
|
import { describeLiveSessionsUnion } from "./live-sessions.mjs"
|
|
83
84
|
import { ReviewEventStore, validReviewEvent } from "./review-event-store.mjs"
|
|
85
|
+
import { truncateString } from "./truncate-string.mjs"
|
|
86
|
+
import { DelegationEventStore } from "./delegation-event-store.mjs"
|
|
84
87
|
import { listChatTargets } from "./chat-targets.mjs"
|
|
85
88
|
|
|
86
89
|
export const SIBLING_REQUESTS_DIR =
|
|
@@ -97,26 +100,13 @@ const SIBLING_SWEEP_DEBOUNCE_MS = 25
|
|
|
97
100
|
*/
|
|
98
101
|
export const SIBLING_BACKSTOP_SWEEP_MS = 1000
|
|
99
102
|
|
|
100
|
-
export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
|
|
101
|
-
export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
102
103
|
export const SIBLING_RESULT_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
103
104
|
const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
|
|
104
105
|
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
106
|
const DELEGATION_ID_MAX_LENGTH = 200
|
|
109
107
|
|
|
110
108
|
const execFile = promisify(execFileCallback)
|
|
111
109
|
|
|
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
110
|
function truncateDelegatePrompt(prompt) {
|
|
121
111
|
return truncateString(
|
|
122
112
|
prompt,
|
|
@@ -125,45 +115,6 @@ function truncateDelegatePrompt(prompt) {
|
|
|
125
115
|
)
|
|
126
116
|
}
|
|
127
117
|
|
|
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
118
|
/**
|
|
168
119
|
* `ps` の起動時刻を送受信双方で比較する契約形へ正規化する。
|
|
169
120
|
* 先頭・末尾の Unicode 空白だけを除去し、日時本体の内部空白は変更しない。
|
|
@@ -455,6 +406,7 @@ export class SiblingInjectBridge {
|
|
|
455
406
|
isInputBlocked,
|
|
456
407
|
starter,
|
|
457
408
|
watchFn = watch,
|
|
409
|
+
delegationDir,
|
|
458
410
|
} = {}) {
|
|
459
411
|
this.dir = dir
|
|
460
412
|
this.claudeBridge = claudeBridge
|
|
@@ -478,10 +430,7 @@ export class SiblingInjectBridge {
|
|
|
478
430
|
this._starter = starter
|
|
479
431
|
this._watchFn = watchFn
|
|
480
432
|
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()
|
|
433
|
+
this.delegationEvents = new DelegationEventStore(delegationDir, now, logger)
|
|
485
434
|
}
|
|
486
435
|
|
|
487
436
|
/** restart coordinator が通常/明示注入の bridge await 中を観測する。 */
|
|
@@ -510,91 +459,10 @@ export class SiblingInjectBridge {
|
|
|
510
459
|
}
|
|
511
460
|
}
|
|
512
461
|
|
|
513
|
-
|
|
514
|
-
|
|
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"
|
|
462
|
+
_emitDelegation(kind, cwd, sessionId, event) {
|
|
463
|
+
const bridge = kind === "codex" ? this.codexBridge : this.claudeBridge
|
|
464
|
+
return bridge?.emitToStream?.(cwd, sessionId, event,
|
|
465
|
+
...(kind === "codex" ? ["cockpit/delegate_event"] : [])) || { status: "not_found" }
|
|
598
466
|
}
|
|
599
467
|
|
|
600
468
|
_liveClaudeSession(cwd, sessionId) {
|
|
@@ -636,54 +504,38 @@ export class SiblingInjectBridge {
|
|
|
636
504
|
}
|
|
637
505
|
|
|
638
506
|
/** attach/再接続時に同じ cwd+session の進行中・終了済み委譲を開始順で再送する。 */
|
|
639
|
-
async replayDelegations(cwd, sessionId) {
|
|
507
|
+
async replayDelegations(cwd, sessionId, kind = "claude") {
|
|
640
508
|
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(),
|
|
509
|
+
try {
|
|
510
|
+
const snapshot = await this.delegationEvents.rows(cwd, sessionId, kind)
|
|
511
|
+
const rows = snapshot.filter((row) => row.started).sort((a, b) => a.startedAt - b.startedAt)
|
|
512
|
+
const plan = []
|
|
513
|
+
for (const row of rows) {
|
|
514
|
+
let alive = true
|
|
515
|
+
try {
|
|
516
|
+
alive = row.terminal || await this._delegateProcessAlive(row.started)
|
|
517
|
+
} catch (err) {
|
|
518
|
+
this.logger?.warn?.({ error: err?.message, delegation_id: row.id }, "delegation PID check failed")
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
const stopped = alive ? null : {
|
|
522
|
+
type: "system",
|
|
523
|
+
subtype: "codex_delegate",
|
|
524
|
+
delegation_id: row.id,
|
|
525
|
+
phase: "stopped",
|
|
526
|
+
...(row.started.agent !== undefined ? { agent: row.started.agent } : {}),
|
|
527
|
+
...(row.started.description !== undefined ? { description: row.started.description } : {}),
|
|
528
|
+
timestamp: new Date(this._now()).toISOString(),
|
|
529
|
+
}
|
|
530
|
+
plan.push({ row, stopped })
|
|
531
|
+
} catch (err) {
|
|
532
|
+
this.logger?.warn?.({ error: err?.message, delegation_id: row.id }, "delegation row replay failed")
|
|
533
|
+
}
|
|
684
534
|
}
|
|
685
|
-
this.
|
|
686
|
-
|
|
535
|
+
await this.delegationEvents.replayPlan(cwd, sessionId, plan,
|
|
536
|
+
(event) => this._emitDelegation(kind, cwd, sessionId, event), kind)
|
|
537
|
+
} catch (err) {
|
|
538
|
+
this.logger?.warn?.({ error: err?.message }, "delegation replay failed")
|
|
687
539
|
}
|
|
688
540
|
}
|
|
689
541
|
|
|
@@ -794,6 +646,9 @@ export class SiblingInjectBridge {
|
|
|
794
646
|
|
|
795
647
|
/** 起動時 / 取りこぼし保険: dir 内の全要求ファイルを読む。 */
|
|
796
648
|
async _sweep({ startup = false } = {}) {
|
|
649
|
+
void this.delegationEvents.sweep().catch((err) => {
|
|
650
|
+
this.logger?.warn?.({ error: err?.message }, "delegation sweep failed")
|
|
651
|
+
})
|
|
797
652
|
let names = []
|
|
798
653
|
try {
|
|
799
654
|
names = await readdir(this.dir)
|
|
@@ -901,6 +756,8 @@ export class SiblingInjectBridge {
|
|
|
901
756
|
pid,
|
|
902
757
|
pid_started_at,
|
|
903
758
|
request_ack,
|
|
759
|
+
display_auth,
|
|
760
|
+
ack_capability,
|
|
904
761
|
} = body || {}
|
|
905
762
|
// 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
|
|
906
763
|
// 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
|
|
@@ -910,6 +767,15 @@ export class SiblingInjectBridge {
|
|
|
910
767
|
const isList = op === "list"
|
|
911
768
|
const isExplicit = op === "inject"
|
|
912
769
|
const isDelegateEvent = op === "delegate-event"
|
|
770
|
+
const ackV2 = isDelegateEvent && request_ack === true && ack_capability === "persisted-delivered-v2"
|
|
771
|
+
const delegateKind = target_cli_kind === undefined ? "claude" : target_cli_kind
|
|
772
|
+
let displayAuthorized = false
|
|
773
|
+
const hasDisplayAuth = isDelegateEvent && Object.hasOwn(body, "display_auth")
|
|
774
|
+
if (hasDisplayAuth) {
|
|
775
|
+
try { displayAuthorized = verifyDisplayToken(cwd, display_auth) } catch {
|
|
776
|
+
this.logger?.warn?.({}, "delegate display auth unavailable")
|
|
777
|
+
}
|
|
778
|
+
}
|
|
913
779
|
const isReviewEvent = op === "review-event"
|
|
914
780
|
const hasRequestAck = Object.prototype.hasOwnProperty.call(body || {}, "request_ack")
|
|
915
781
|
const isRestart = op === "restart"
|
|
@@ -917,6 +783,13 @@ export class SiblingInjectBridge {
|
|
|
917
783
|
const badKind = !["claude", "codex", "grok"].includes(target_cli_kind)
|
|
918
784
|
const delegatePhases = new Set(["started", "completed", "failed", "stopped"])
|
|
919
785
|
const badDelegateBody =
|
|
786
|
+
!["claude", "codex"].includes(delegateKind) ||
|
|
787
|
+
(target_session_id !== undefined && typeof target_session_id !== "string") ||
|
|
788
|
+
Object.keys(body || {}).some((key) => ![
|
|
789
|
+
"op", "cwd", "target_cli_kind", "token", "target_session_id", "delegation_id", "phase",
|
|
790
|
+
"agent", "description", "background", "prompt", "timestamp", "pid", "pid_started_at",
|
|
791
|
+
"request_ack", "display_auth", "ack_capability",
|
|
792
|
+
].includes(key)) ||
|
|
920
793
|
typeof delegation_id !== "string" ||
|
|
921
794
|
!delegation_id ||
|
|
922
795
|
delegation_id.length > DELEGATION_ID_MAX_LENGTH ||
|
|
@@ -934,7 +807,7 @@ export class SiblingInjectBridge {
|
|
|
934
807
|
const badBody = isReviewEvent
|
|
935
808
|
? !validReviewEvent(body.event) || typeof target_session_id !== "string" || !target_session_id ||
|
|
936
809
|
typeof cwd !== "string" || !path.isAbsolute(cwd) ||
|
|
937
|
-
Object.keys(body).some((key) => !["op", "token", "cwd", "target_cli_kind", "target_session_id", "event"].includes(key)) ||
|
|
810
|
+
Object.keys(body).some((key) => !["op", "token", "cwd", "target_cli_kind", "target_session_id", "event", "display_auth", "ack_capability"].includes(key)) ||
|
|
938
811
|
!requestInfo.isFile() || requestInfo.isSymbolicLink() || requestInfo.uid !== process.getuid?.() ||
|
|
939
812
|
(requestInfo.mode & 0o777) !== 0o600
|
|
940
813
|
: isDelegateEvent
|
|
@@ -985,7 +858,7 @@ export class SiblingInjectBridge {
|
|
|
985
858
|
}
|
|
986
859
|
} else if (!cwd || (!isDelegateEvent && badKind) || badBody) {
|
|
987
860
|
result = { ok: false, error: "invalid_request" }
|
|
988
|
-
} else if (!verifySiblingTokenAny(token)) {
|
|
861
|
+
} else if (hasDisplayAuth ? !displayAuthorized : !verifySiblingTokenAny(token)) {
|
|
989
862
|
// 2026-07-23 統一再設計 1-2: 認可を「ターゲット cwd 所有」→「いずれかの cwd の有効トークン
|
|
990
863
|
// 所有 (= 同一ユーザーの spawn 済みセッション)」へ緩和し、クロス cwd 連携を解禁する。
|
|
991
864
|
// cwd は引き続きターゲット解決のスコープとして使う (誤 cwd 着弾・情報漏洩は解決側で担保)。
|
|
@@ -1044,29 +917,51 @@ export class SiblingInjectBridge {
|
|
|
1044
917
|
...(pid_started_at !== undefined ? { pid_started_at } : {}),
|
|
1045
918
|
timestamp: timestamp || new Date().toISOString(),
|
|
1046
919
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
920
|
+
let acceptance
|
|
921
|
+
let persisted = true
|
|
922
|
+
try {
|
|
923
|
+
acceptance = await this.delegationEvents.record(cwd, target_session_id, event, delegateKind)
|
|
924
|
+
} catch (err) {
|
|
925
|
+
persisted = false
|
|
926
|
+
this.logger?.warn?.({ request_id, error: err?.message }, "delegation persistence failed")
|
|
927
|
+
}
|
|
928
|
+
if (ackV2) {
|
|
929
|
+
persisted = persisted && acceptance !== "full"
|
|
930
|
+
const delivery = acceptance === "stale" ? { status: "not_found" }
|
|
931
|
+
: this._emitDelegation(delegateKind, cwd, target_session_id, event)
|
|
932
|
+
const delivered = delivery.status === "delivered"
|
|
933
|
+
result = !persisted
|
|
934
|
+
? { ok: false, ack_version: 2, persisted: false, delivered,
|
|
935
|
+
error: acceptance === "full" ? "delegation_store_full" : "delegation_persistence_failed", retryable: true }
|
|
936
|
+
: { ok: true, ack_version: 2, persisted: true, delivered,
|
|
937
|
+
...(acceptance === "duplicate" ? { duplicate: true } : {}),
|
|
938
|
+
...(acceptance === "stale" ? { stale: true } : {}),
|
|
939
|
+
...(!delivered && acceptance !== "stale" && acceptance !== "duplicate"
|
|
940
|
+
? { delivery_status: delivery.status } : {}) }
|
|
941
|
+
} else if (acceptance === "duplicate") {
|
|
1051
942
|
// ACK は「過去に受理した」だけでなく、対象 stream が現在も live であることも示す。
|
|
1052
943
|
// 台帳だけで成功を返すと、宛先消失後の retry を受理済みと誤認させる。
|
|
1053
|
-
const live = this._liveClaudeSession(cwd, target_session_id)
|
|
1054
|
-
|
|
944
|
+
const live = delegateKind === "claude" ? this._liveClaudeSession(cwd, target_session_id)
|
|
945
|
+
: { status: "delivered" }
|
|
946
|
+
// 保存後に未配送のまま切断した可能性があるため、duplicate も再配送する。
|
|
947
|
+
const delivered = live.status === "delivered"
|
|
948
|
+
? this._emitDelegation(delegateKind, cwd, target_session_id, event)
|
|
949
|
+
: live
|
|
950
|
+
result = delivered.status === "delivered"
|
|
1055
951
|
? { ok: true, duplicate: true }
|
|
1056
|
-
: this._delegateTargetFailure(
|
|
952
|
+
: this._delegateTargetFailure(delivered)
|
|
1057
953
|
} else if (acceptance === "stale") {
|
|
1058
954
|
result = { ok: false, error: "stale_delegate_event" }
|
|
955
|
+
} else if (acceptance === "full") {
|
|
956
|
+
result = { ok: false, error: "delegation_store_full" }
|
|
1059
957
|
} else {
|
|
1060
|
-
const r = this.
|
|
1061
|
-
? this.claudeBridge.emitToStream(cwd, target_session_id, event)
|
|
1062
|
-
: { status: "not_found" }
|
|
958
|
+
const r = this._emitDelegation(delegateKind, cwd, target_session_id, event)
|
|
1063
959
|
if (r.status === "delivered") {
|
|
1064
|
-
// acceptance 直前に prune 済み。同じ同期区間で二重 prune しない。
|
|
1065
|
-
this._recordDelegateEvent(cwd, target_session_id, event, { prune: false })
|
|
1066
960
|
result = { ok: true, delivered_to: r.session }
|
|
1067
961
|
} else {
|
|
1068
|
-
result = this._delegateTargetFailure(r)
|
|
962
|
+
result = { ...this._delegateTargetFailure(r), persisted }
|
|
1069
963
|
}
|
|
964
|
+
if (!persisted) result.persisted = false
|
|
1070
965
|
}
|
|
1071
966
|
}
|
|
1072
967
|
} else if (isList) {
|
|
@@ -1251,6 +1146,10 @@ export class SiblingInjectBridge {
|
|
|
1251
1146
|
}
|
|
1252
1147
|
}
|
|
1253
1148
|
|
|
1149
|
+
if (ackV2 && result.ack_version !== 2) {
|
|
1150
|
+
result = { ok: false, ack_version: 2, persisted: false, delivered: false,
|
|
1151
|
+
error: result.error, retryable: false }
|
|
1152
|
+
}
|
|
1254
1153
|
this.logger?.[result.ok ? "info" : "warn"]?.(
|
|
1255
1154
|
{
|
|
1256
1155
|
request_id,
|
package/src/sibling-token.mjs
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* 範囲がこの cwd 限定トークンより大きくなるため。
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { displayTokenForCwd } from "./delegate-display-auth.mjs"
|
|
28
|
+
|
|
27
29
|
import { randomBytes } from "node:crypto"
|
|
28
30
|
|
|
29
31
|
/** @type {Map<string, string>} cwd -> token */
|
|
@@ -126,6 +128,14 @@ export function buildSiblingEnv(cwd, { selfKind, delegateEventAck = false, strip
|
|
|
126
128
|
...(delegateEventAck ? { COCKPIT_DELEGATE_EVENT_ACK: "1" } : {}),
|
|
127
129
|
COCKPIT_SIBLING_SELF_KIND: selfKind,
|
|
128
130
|
}
|
|
131
|
+
delete env.COCKPIT_DELEGATE_DISPLAY_TOKEN
|
|
132
|
+
delete env.COCKPIT_DELEGATE_EVENT_ACK_VERSION
|
|
133
|
+
try {
|
|
134
|
+
env.COCKPIT_DELEGATE_DISPLAY_TOKEN = displayTokenForCwd(cwd)
|
|
135
|
+
env.COCKPIT_DELEGATE_EVENT_ACK_VERSION = "2"
|
|
136
|
+
} catch {
|
|
137
|
+
console.warn("delegate display auth unavailable; display capability omitted")
|
|
138
|
+
}
|
|
129
139
|
// ACK を付けない CLI では、継承元に ACK があっても子へ持ち込まない。
|
|
130
140
|
if (!delegateEventAck) delete env.COCKPIT_DELEGATE_EVENT_ACK
|
|
131
141
|
if (stripParentSessionIds) {
|
|
@@ -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
|
+
}
|