@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.28",
3
+ "version": "0.8.30",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
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
@@ -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}} [opts]
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({ dir = SIBLING_REQUESTS_DIR, claudeBridge, codexBridge, logger } = {}) {
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
- await mkdir(this.dir, { recursive: true })
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
- result =
433
- r.status === "delivered"
434
- ? { ok: true, delivered_to: r.session }
435
- : {
436
- ok: false,
437
- error: AMBIGUOUS_TARGET_ERROR,
438
- ...(r.candidates ? { candidates: r.candidates } : {}),
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")