@cocorograph/hub-agent 0.8.29 → 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 +150 -13
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)
|
|
@@ -60,6 +61,8 @@
|
|
|
60
61
|
|
|
61
62
|
import { watch } from "node:fs"
|
|
62
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
|
/**
|
|
@@ -344,7 +473,7 @@ export class SiblingInjectBridge {
|
|
|
344
473
|
|
|
345
474
|
/**
|
|
346
475
|
* 要求ファイル 1 件を読み、トークン検証後に対象 cwd のセッションへ注入する。
|
|
347
|
-
*
|
|
476
|
+
* 成否によらず要求ファイルは削除する。delegate-event 以外は結果ファイルも書く。
|
|
348
477
|
* @param {string} filename `<request_id>.json`
|
|
349
478
|
*/
|
|
350
479
|
async _ingest(filename) {
|
|
@@ -383,6 +512,8 @@ export class SiblingInjectBridge {
|
|
|
383
512
|
agent,
|
|
384
513
|
description,
|
|
385
514
|
timestamp,
|
|
515
|
+
pid,
|
|
516
|
+
pid_started_at,
|
|
386
517
|
} = body || {}
|
|
387
518
|
// 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
|
|
388
519
|
// 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
|
|
@@ -402,6 +533,8 @@ export class SiblingInjectBridge {
|
|
|
402
533
|
(phase === "started" && (typeof agent !== "string" || !agent)) ||
|
|
403
534
|
(agent !== undefined && typeof agent !== "string") ||
|
|
404
535
|
(description !== undefined && typeof description !== "string") ||
|
|
536
|
+
(pid !== undefined && (!Number.isInteger(pid) || pid <= 0)) ||
|
|
537
|
+
(pid_started_at !== undefined && typeof pid_started_at !== "string") ||
|
|
405
538
|
(timestamp !== undefined &&
|
|
406
539
|
(typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp))))
|
|
407
540
|
const badBody = isDelegateEvent
|
|
@@ -461,19 +594,23 @@ export class SiblingInjectBridge {
|
|
|
461
594
|
phase,
|
|
462
595
|
...(agent !== undefined ? { agent } : {}),
|
|
463
596
|
...(description !== undefined ? { description } : {}),
|
|
597
|
+
...(pid !== undefined ? { pid } : {}),
|
|
598
|
+
...(pid_started_at !== undefined ? { pid_started_at } : {}),
|
|
464
599
|
timestamp: timestamp || new Date().toISOString(),
|
|
465
600
|
}
|
|
466
601
|
const r = this.claudeBridge?.emitToStream
|
|
467
602
|
? this.claudeBridge.emitToStream(cwd, target_session_id, event)
|
|
468
603
|
: { status: "not_found" }
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
+
}
|
|
477
614
|
}
|
|
478
615
|
} else if (isList) {
|
|
479
616
|
// 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
|
|
@@ -636,7 +773,7 @@ export class SiblingInjectBridge {
|
|
|
636
773
|
: "sibling-inject dispatched"
|
|
637
774
|
: "sibling-inject failed",
|
|
638
775
|
)
|
|
639
|
-
await this._writeResult(request_id, result)
|
|
776
|
+
if (!isDelegateEvent) await this._writeResult(request_id, result)
|
|
640
777
|
// 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
|
|
641
778
|
try {
|
|
642
779
|
await unlink(fp)
|