@cocorograph/hub-agent 0.8.28 → 0.8.30
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/main.mjs +10 -0
- package/src/sibling-inject.mjs +190 -16
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -3727,6 +3727,16 @@ async function dispatch(msg, ctx) {
|
|
|
3727
3727
|
// 挙動へフォールバックする (混在期間も安全に段階デプロイできる)。
|
|
3728
3728
|
supports_queue_snapshot: true,
|
|
3729
3729
|
})
|
|
3730
|
+
if (resumeSessionId) {
|
|
3731
|
+
void ctx.siblingInjectBridge
|
|
3732
|
+
?.replayActiveDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
|
|
3733
|
+
.catch((err) => {
|
|
3734
|
+
ctx.logger?.warn?.(
|
|
3735
|
+
{ err: err?.message || String(err), stream_id, session_id: resumeSessionId },
|
|
3736
|
+
"failed to replay active delegations after Claude attach",
|
|
3737
|
+
)
|
|
3738
|
+
})
|
|
3739
|
+
}
|
|
3730
3740
|
// dispatch全体はWSメッセージ順を守る直列キュー上にある。ここで履歴I/Oをawaitすると
|
|
3731
3741
|
// 同一接続のpermission/input等まで停止するため、readyの同期送信後は非同期でpushする。
|
|
3732
3742
|
void historyPromise
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -32,10 +32,11 @@
|
|
|
32
32
|
* - 一覧: `{op:"list", cwd, target_cli_kind, token}` → `{ok, live_sessions}` (--list 用)。
|
|
33
33
|
* - 全一覧: `{op:"list-all", token}` → `{ok, live_sessions}` (全 cwd・両 CLI。クロス cwd
|
|
34
34
|
* 宛先探索用、2026-07-23。cwd/target_cli_kind 不要、token のみ必須)。
|
|
35
|
-
* 処理後は必ず削除し (成否問わず)
|
|
35
|
+
* 処理後は必ず削除し (成否問わず)、通常 IPC は呼び出し側が待ち受けられるよう
|
|
36
36
|
* `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?,
|
|
37
37
|
* candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
|
|
38
38
|
* 「候補を実行可能な `--to` として並べてよいか」を hint の文言に依存せず判定するための boolean)。
|
|
39
|
+
* `delegate-event` だけは fire-and-forget のため応答ファイルを書かない。
|
|
39
40
|
*
|
|
40
41
|
* ⚠️ codex の `delivery_state` は 4 値ある (2026-08-05 に `queued` を追加):
|
|
41
42
|
* - `delivered` … userMessage echo まで確認済み (ok:true)
|
|
@@ -59,7 +60,9 @@
|
|
|
59
60
|
*/
|
|
60
61
|
|
|
61
62
|
import { watch } from "node:fs"
|
|
62
|
-
import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
|
|
63
|
+
import { chmod, lstat, mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
|
|
64
|
+
import { execFile as execFileCallback } from "node:child_process"
|
|
65
|
+
import { promisify } from "node:util"
|
|
63
66
|
import path from "node:path"
|
|
64
67
|
|
|
65
68
|
import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
|
|
@@ -68,6 +71,16 @@ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
|
|
|
68
71
|
export const SIBLING_REQUESTS_DIR =
|
|
69
72
|
process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
|
|
70
73
|
|
|
74
|
+
export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
|
|
75
|
+
export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
76
|
+
|
|
77
|
+
const execFile = promisify(execFileCallback)
|
|
78
|
+
|
|
79
|
+
async function defaultPidStartedAt(pid) {
|
|
80
|
+
const { stdout } = await execFile("ps", ["-o", "lstart=", "-p", String(pid)])
|
|
81
|
+
return stdout.replace(/\r?\n$/, "")
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
const DELIVERY_UNCONFIRMED_HINT =
|
|
72
85
|
"turn RPC は受理されましたが userMessage echo を確認できませんでした。既に届いている可能性があり、" +
|
|
73
86
|
"再送すると重複配送になります。再送前に対象セッションの受信状態または rollout 監査を確認してください。"
|
|
@@ -248,9 +261,19 @@ function queuedResult(r) {
|
|
|
248
261
|
|
|
249
262
|
export class SiblingInjectBridge {
|
|
250
263
|
/**
|
|
251
|
-
* @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object
|
|
264
|
+
* @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object,
|
|
265
|
+
* now?: () => number, processKill?: (pid: number, signal: number) => void,
|
|
266
|
+
* pidStartedAt?: (pid: number) => Promise<string>}} [opts]
|
|
252
267
|
*/
|
|
253
|
-
constructor({
|
|
268
|
+
constructor({
|
|
269
|
+
dir = SIBLING_REQUESTS_DIR,
|
|
270
|
+
claudeBridge,
|
|
271
|
+
codexBridge,
|
|
272
|
+
logger,
|
|
273
|
+
now = Date.now,
|
|
274
|
+
processKill = process.kill.bind(process),
|
|
275
|
+
pidStartedAt = defaultPidStartedAt,
|
|
276
|
+
} = {}) {
|
|
254
277
|
this.dir = dir
|
|
255
278
|
this.claudeBridge = claudeBridge
|
|
256
279
|
this.codexBridge = codexBridge
|
|
@@ -258,6 +281,112 @@ export class SiblingInjectBridge {
|
|
|
258
281
|
/** @type {Set<string>} 二重処理防止 (request_id) */
|
|
259
282
|
this._seen = new Set()
|
|
260
283
|
this._watcher = null
|
|
284
|
+
this._now = now
|
|
285
|
+
this._processKill = processKill
|
|
286
|
+
this._pidStartedAt = pidStartedAt
|
|
287
|
+
/** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
|
|
288
|
+
this._activeDelegations = new Map()
|
|
289
|
+
/** @type {Map<string, Map<string, number>>} 終端→開始の逆転で遅着 started を復活させない墓標。 */
|
|
290
|
+
this._terminalDelegations = new Map()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_delegationSessionKey(cwd, sessionId) {
|
|
294
|
+
return `${cwd}\u0000${sessionId}`
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_pruneDelegations() {
|
|
298
|
+
const cutoff = this._now() - DELEGATION_LEDGER_RETENTION_MS
|
|
299
|
+
for (const [sessionKey, entries] of this._activeDelegations) {
|
|
300
|
+
for (const [delegationId, entry] of entries) {
|
|
301
|
+
if (entry.recordedAt < cutoff) entries.delete(delegationId)
|
|
302
|
+
}
|
|
303
|
+
if (entries.size === 0) this._activeDelegations.delete(sessionKey)
|
|
304
|
+
}
|
|
305
|
+
for (const [sessionKey, entries] of this._terminalDelegations) {
|
|
306
|
+
for (const [delegationId, recordedAt] of entries) {
|
|
307
|
+
if (recordedAt < cutoff) entries.delete(delegationId)
|
|
308
|
+
}
|
|
309
|
+
if (entries.size === 0) this._terminalDelegations.delete(sessionKey)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_recordDelegateEvent(cwd, sessionId, event) {
|
|
314
|
+
this._pruneDelegations()
|
|
315
|
+
const sessionKey = this._delegationSessionKey(cwd, sessionId)
|
|
316
|
+
if (event.phase !== "started") {
|
|
317
|
+
this._activeDelegations.get(sessionKey)?.delete(event.delegation_id)
|
|
318
|
+
if (this._activeDelegations.get(sessionKey)?.size === 0) {
|
|
319
|
+
this._activeDelegations.delete(sessionKey)
|
|
320
|
+
}
|
|
321
|
+
let terminalEntries = this._terminalDelegations.get(sessionKey)
|
|
322
|
+
if (!terminalEntries) {
|
|
323
|
+
terminalEntries = new Map()
|
|
324
|
+
this._terminalDelegations.set(sessionKey, terminalEntries)
|
|
325
|
+
}
|
|
326
|
+
terminalEntries.delete(event.delegation_id)
|
|
327
|
+
terminalEntries.set(event.delegation_id, this._now())
|
|
328
|
+
while (terminalEntries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
|
|
329
|
+
terminalEntries.delete(terminalEntries.keys().next().value)
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
if (this._terminalDelegations.get(sessionKey)?.has(event.delegation_id)) return
|
|
334
|
+
let entries = this._activeDelegations.get(sessionKey)
|
|
335
|
+
if (!entries) {
|
|
336
|
+
entries = new Map()
|
|
337
|
+
this._activeDelegations.set(sessionKey, entries)
|
|
338
|
+
}
|
|
339
|
+
entries.delete(event.delegation_id)
|
|
340
|
+
entries.set(event.delegation_id, { event, recordedAt: this._now() })
|
|
341
|
+
while (entries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
|
|
342
|
+
entries.delete(entries.keys().next().value)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async _delegateProcessAlive(event) {
|
|
347
|
+
if (!Number.isInteger(event.pid) || event.pid <= 0 || !event.pid_started_at) return true
|
|
348
|
+
try {
|
|
349
|
+
this._processKill(event.pid, 0)
|
|
350
|
+
} catch (err) {
|
|
351
|
+
if (err?.code === "ESRCH") return false
|
|
352
|
+
if (err?.code !== "EPERM") return true
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
return (await this._pidStartedAt(event.pid)) === event.pid_started_at
|
|
356
|
+
} catch {
|
|
357
|
+
return true
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** attach/再接続時に同じ cwd+session の進行中委譲だけを再送する。 */
|
|
362
|
+
async replayActiveDelegations(cwd, sessionId) {
|
|
363
|
+
if (!cwd || !sessionId) return
|
|
364
|
+
this._pruneDelegations()
|
|
365
|
+
const sessionKey = this._delegationSessionKey(cwd, sessionId)
|
|
366
|
+
const entries = this._activeDelegations.get(sessionKey)
|
|
367
|
+
if (!entries) return
|
|
368
|
+
for (const [delegationId, entry] of [...entries]) {
|
|
369
|
+
const alive = await this._delegateProcessAlive(entry.event)
|
|
370
|
+
// 生存確認中に終端が届いた場合、古い snapshot の started/stopped を後から流さない。
|
|
371
|
+
if (this._activeDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
|
|
372
|
+
if (alive) {
|
|
373
|
+
this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.event)
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
const stopped = {
|
|
377
|
+
type: "system",
|
|
378
|
+
subtype: "codex_delegate",
|
|
379
|
+
delegation_id: delegationId,
|
|
380
|
+
phase: "stopped",
|
|
381
|
+
...(entry.event.agent !== undefined ? { agent: entry.event.agent } : {}),
|
|
382
|
+
...(entry.event.description !== undefined
|
|
383
|
+
? { description: entry.event.description }
|
|
384
|
+
: {}),
|
|
385
|
+
timestamp: new Date(this._now()).toISOString(),
|
|
386
|
+
}
|
|
387
|
+
this._recordDelegateEvent(cwd, sessionId, stopped)
|
|
388
|
+
this.claudeBridge?.emitToStream?.(cwd, sessionId, stopped)
|
|
389
|
+
}
|
|
261
390
|
}
|
|
262
391
|
|
|
263
392
|
/**
|
|
@@ -268,9 +397,46 @@ export class SiblingInjectBridge {
|
|
|
268
397
|
return { claudeBridge: this.claudeBridge, codexBridge: this.codexBridge }
|
|
269
398
|
}
|
|
270
399
|
|
|
400
|
+
async _lstatDir() {
|
|
401
|
+
return lstat(this.dir)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** IPC ディレクトリを所有者限定にし、安全性を確認できなければ例外で閉じる。 */
|
|
405
|
+
async _prepareDir() {
|
|
406
|
+
try {
|
|
407
|
+
await this._lstatDir()
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if (err?.code !== "ENOENT") throw err
|
|
410
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 })
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const info = await this._lstatDir()
|
|
414
|
+
if (info.isSymbolicLink()) {
|
|
415
|
+
throw new Error("IPC directory must not be a symbolic link")
|
|
416
|
+
}
|
|
417
|
+
if (!info.isDirectory()) {
|
|
418
|
+
throw new Error("IPC path is not a directory")
|
|
419
|
+
}
|
|
420
|
+
const uid = process.getuid?.()
|
|
421
|
+
if (uid == null || info.uid !== uid) {
|
|
422
|
+
throw new Error("IPC directory is not owned by the current user")
|
|
423
|
+
}
|
|
424
|
+
if ((info.mode & 0o777) !== 0o700) {
|
|
425
|
+
await chmod(this.dir, 0o700)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
271
429
|
/** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
|
|
272
430
|
async start() {
|
|
273
|
-
|
|
431
|
+
try {
|
|
432
|
+
await this._prepareDir()
|
|
433
|
+
} catch (err) {
|
|
434
|
+
this.logger?.warn(
|
|
435
|
+
{ err: err?.message, dir: this.dir },
|
|
436
|
+
"sibling-inject IPC directory is unsafe, IPC disabled",
|
|
437
|
+
)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
274
440
|
await this._sweep()
|
|
275
441
|
try {
|
|
276
442
|
this._watcher = watch(this.dir, (_event, filename) => {
|
|
@@ -307,7 +473,7 @@ export class SiblingInjectBridge {
|
|
|
307
473
|
|
|
308
474
|
/**
|
|
309
475
|
* 要求ファイル 1 件を読み、トークン検証後に対象 cwd のセッションへ注入する。
|
|
310
|
-
*
|
|
476
|
+
* 成否によらず要求ファイルは削除する。delegate-event 以外は結果ファイルも書く。
|
|
311
477
|
* @param {string} filename `<request_id>.json`
|
|
312
478
|
*/
|
|
313
479
|
async _ingest(filename) {
|
|
@@ -346,6 +512,8 @@ export class SiblingInjectBridge {
|
|
|
346
512
|
agent,
|
|
347
513
|
description,
|
|
348
514
|
timestamp,
|
|
515
|
+
pid,
|
|
516
|
+
pid_started_at,
|
|
349
517
|
} = body || {}
|
|
350
518
|
// 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
|
|
351
519
|
// 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
|
|
@@ -365,6 +533,8 @@ export class SiblingInjectBridge {
|
|
|
365
533
|
(phase === "started" && (typeof agent !== "string" || !agent)) ||
|
|
366
534
|
(agent !== undefined && typeof agent !== "string") ||
|
|
367
535
|
(description !== undefined && typeof description !== "string") ||
|
|
536
|
+
(pid !== undefined && (!Number.isInteger(pid) || pid <= 0)) ||
|
|
537
|
+
(pid_started_at !== undefined && typeof pid_started_at !== "string") ||
|
|
368
538
|
(timestamp !== undefined &&
|
|
369
539
|
(typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp))))
|
|
370
540
|
const badBody = isDelegateEvent
|
|
@@ -424,19 +594,23 @@ export class SiblingInjectBridge {
|
|
|
424
594
|
phase,
|
|
425
595
|
...(agent !== undefined ? { agent } : {}),
|
|
426
596
|
...(description !== undefined ? { description } : {}),
|
|
597
|
+
...(pid !== undefined ? { pid } : {}),
|
|
598
|
+
...(pid_started_at !== undefined ? { pid_started_at } : {}),
|
|
427
599
|
timestamp: timestamp || new Date().toISOString(),
|
|
428
600
|
}
|
|
429
601
|
const r = this.claudeBridge?.emitToStream
|
|
430
602
|
? this.claudeBridge.emitToStream(cwd, target_session_id, event)
|
|
431
603
|
: { status: "not_found" }
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
604
|
+
if (r.status === "delivered") {
|
|
605
|
+
this._recordDelegateEvent(cwd, target_session_id, event)
|
|
606
|
+
result = { ok: true, delivered_to: r.session }
|
|
607
|
+
} else {
|
|
608
|
+
result = {
|
|
609
|
+
ok: false,
|
|
610
|
+
error: AMBIGUOUS_TARGET_ERROR,
|
|
611
|
+
...(r.candidates ? { candidates: r.candidates } : {}),
|
|
612
|
+
}
|
|
613
|
+
}
|
|
440
614
|
}
|
|
441
615
|
} else if (isList) {
|
|
442
616
|
// 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
|
|
@@ -599,7 +773,7 @@ export class SiblingInjectBridge {
|
|
|
599
773
|
: "sibling-inject dispatched"
|
|
600
774
|
: "sibling-inject failed",
|
|
601
775
|
)
|
|
602
|
-
await this._writeResult(request_id, result)
|
|
776
|
+
if (!isDelegateEvent) await this._writeResult(request_id, result)
|
|
603
777
|
// 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
|
|
604
778
|
try {
|
|
605
779
|
await unlink(fp)
|
|
@@ -640,7 +814,7 @@ export class SiblingInjectBridge {
|
|
|
640
814
|
const fp = path.join(this.dir, `${request_id}.result.json`)
|
|
641
815
|
const tmp = `${fp}.tmp`
|
|
642
816
|
try {
|
|
643
|
-
await writeFile(tmp, JSON.stringify(result))
|
|
817
|
+
await writeFile(tmp, JSON.stringify(result), { mode: 0o600 })
|
|
644
818
|
await rename(tmp, fp)
|
|
645
819
|
} catch (err) {
|
|
646
820
|
this.logger?.warn({ err: err?.message, request_id }, "sibling-inject result write failed")
|