@cocorograph/hub-agent 0.8.47 → 0.8.49

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/bin/hub-agent.mjs CHANGED
@@ -37,8 +37,10 @@ import { resolveSyncTargets, syncBundleTargets } from "../src/hub-bundle.mjs"
37
37
  import { loadPlugins } from "../src/plugin-loader.mjs"
38
38
  import { uninstallPlugin } from "../src/plugin-uninstall.mjs"
39
39
  import {
40
+ canSuggestForcedRestart,
40
41
  installService,
41
42
  restartService,
43
+ restartWithPolicy,
42
44
  uninstallService,
43
45
  } from "../src/service-install.mjs"
44
46
 
@@ -231,18 +233,45 @@ program
231
233
 
232
234
  program
233
235
  .command("restart")
234
- .description("install-service 済みの daemon を再起動 (macOS=launchctl kickstart / Linux=systemctl --user restart)")
235
- .action(async () => {
236
+ .description("実行中の作業完了後に daemon を安全に再起動 (--force は即時再起動)")
237
+ .option("--force", "サービスマネージャーで即時再起動する")
238
+ .action(async (opts) => {
236
239
  try {
237
- const r = await restartService()
238
- console.log(`restarted: ${r.platform} ${r.path}`)
239
- if (r.platform === "darwin") {
240
- console.log(`label: ${r.label}`)
240
+ const restart = await restartWithPolicy({
241
+ force: !!opts.force,
242
+ deferredRestart: async () => {
243
+ const { requestDeferredRestart } = await import("../src/sibling-inject.mjs")
244
+ return requestDeferredRestart()
245
+ },
246
+ immediateRestart: restartService,
247
+ })
248
+ if (restart.mode === "immediate") {
249
+ const r = restart.result
250
+ console.log(`restarted immediately: ${r.platform} ${r.path}`)
251
+ if (r.platform === "darwin") {
252
+ console.log(`label: ${r.label}`)
253
+ } else {
254
+ console.log(`unit: ${r.unit}`)
255
+ }
241
256
  } else {
242
- console.log(`unit: ${r.unit}`)
257
+ console.log("restart accepted: daemon will restart after active work completes")
243
258
  }
244
259
  } catch (err) {
245
260
  console.error(`restart failed: ${err.message}`)
261
+ if (err.code === "service_not_installed") {
262
+ console.error("Run `hub-agent install-service` before restarting.")
263
+ } else if (!opts.force && canSuggestForcedRestart(err)) {
264
+ if (err.code === "restart_rejected" &&
265
+ (err.daemonError === "invalid_request" || err.daemonError === "restart_unavailable")) {
266
+ console.error(
267
+ "The daemon may be an older version or the request contract may be unsupported. " +
268
+ "No immediate fallback was attempted.",
269
+ )
270
+ }
271
+ console.error(
272
+ "Check ~/.hub/agent.log first. Use `hub-agent restart --force` only if interrupting active work is acceptable.",
273
+ )
274
+ }
246
275
  process.exit(1)
247
276
  }
248
277
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.47",
3
+ "version": "0.8.49",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "start": "node bin/hub-agent.mjs start",
21
- "test": "node --test test/*.test.mjs",
21
+ "test": "node --test --test-concurrency=1 test/*.test.mjs",
22
22
  "check:publish-on-main": "node scripts/check-publish-on-main.mjs",
23
23
  "prepublishOnly": "node scripts/check-publish-on-main.mjs && npm test"
24
24
  },
@@ -3703,13 +3703,26 @@ export class ClaudeStreamBridge extends EventEmitter {
3703
3703
  * 観測は **通知の台帳**で行い、権威照会 `thread/backgroundTerminals/list` は使わない
3704
3704
  * (実験 API の有効化が要るうえ、threadId ごとの照会なので子スレッドの背景コマンドを取りこぼす)。 */
3705
3705
  _isSessionProtected(session, now = Date.now()) {
3706
- if (!session) return false
3706
+ return this._hasSessionWork(session, now) || this._liveTerminalCount(session) >= 2
3707
+ }
3708
+
3709
+ /** warm保護から購読数だけを除いた、実作業の共通判定。 */
3710
+ _hasSessionWork(session, now = Date.now()) {
3711
+ if (!session || session._closed) return false
3707
3712
  if (session._busy) return true
3708
- if (Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0) {
3709
- return true
3713
+ if (Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0) return true
3714
+ return !!session._hasLiveTask?.(now)
3715
+ }
3716
+
3717
+ /**
3718
+ * daemon の deferred restart が待つべき実作業があるか。
3719
+ * 多端末 subscribe は warm 保持には必要だが作業ではないため、ここでは数えない。
3720
+ */
3721
+ hasActiveWork(now = Date.now()) {
3722
+ for (const { session } of this._liveCandidatesForCwd()) {
3723
+ if (this._hasSessionWork(session, now)) return true
3710
3724
  }
3711
- if (session._hasLiveTask?.(now)) return true
3712
- return this._liveTerminalCount(session) >= 2
3725
+ return false
3713
3726
  }
3714
3727
 
3715
3728
  /** 子⑤: warm 境界超過セッションを reap する(detach の softDetach onTimeout と同じ撤去処理)。 */
@@ -3567,6 +3567,17 @@ export class CodexStreamBridge extends EventEmitter {
3567
3567
  return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
3568
3568
  }
3569
3569
 
3570
+ /**
3571
+ * daemon の deferred restart が待つべき実作業があるか。
3572
+ * warm 保持とは異なり、購読端末数や detach 状態は作業として数えない。
3573
+ */
3574
+ hasActiveWork(now = Date.now()) {
3575
+ for (const session of this._liveSessions()) {
3576
+ if (this._isProtected(session, now)) return true
3577
+ }
3578
+ return false
3579
+ }
3580
+
3570
3581
  _hasLiveSubagentTurn(session, now = Date.now()) {
3571
3582
  const registry = session?.subagentInterruptRegistry
3572
3583
  if (!registry || registry.size === 0) return false
package/src/main.mjs CHANGED
@@ -748,6 +748,112 @@ export async function handleBundleSyncRequest(msg, ctx, opts = {}) {
748
748
  }
749
749
  }
750
750
 
751
+ /**
752
+ * deferred restart の待機状態を一元管理する。要求は同期的に pending 化して即 ACK でき、
753
+ * Claude/Codex bridge が追跡する turn・queue・command・subagent/background task が
754
+ * idle → settle → 再確認の後だけ通常 shutdown へ進む。upload/bundle sync はこの
755
+ * bridge work 契約に含まれず、再送可能な別経路として扱う。
756
+ */
757
+ export class DeferredRestartCoordinator {
758
+ constructor({
759
+ claudeBridge,
760
+ codexBridge,
761
+ shutdown,
762
+ logger,
763
+ settleMs = 500,
764
+ pollMs = 50,
765
+ setTimer = setTimeout,
766
+ clearTimer = clearTimeout,
767
+ lifecycle = { restartPending: false, shutdownStarted: false },
768
+ workProviders = [],
769
+ } = {}) {
770
+ this.claudeBridge = claudeBridge
771
+ this.codexBridge = codexBridge
772
+ this.shutdown = shutdown
773
+ this.logger = logger
774
+ this.settleMs = settleMs
775
+ this.pollMs = pollMs
776
+ this.setTimer = setTimer
777
+ this.clearTimer = clearTimer
778
+ this.lifecycle = lifecycle
779
+ this.workProviders = workProviders
780
+ this.timer = null
781
+ this.settling = false
782
+ this.workCheckFailures = 0
783
+ this.shutdownInvoked = false
784
+ }
785
+
786
+ request() {
787
+ if (!this.lifecycle.restartPending) {
788
+ this.lifecycle.restartPending = true
789
+ this.logger?.info?.({}, "deferred restart accepted")
790
+ this._schedule(0)
791
+ }
792
+ return { ok: true, restart_pending: true }
793
+ }
794
+
795
+ _hasActiveWork() {
796
+ return !!(
797
+ this.claudeBridge?.hasActiveWork?.() ||
798
+ this.codexBridge?.hasActiveWork?.() ||
799
+ this.workProviders.some((provider) => provider?.hasActiveWork?.())
800
+ )
801
+ }
802
+
803
+ _schedule(delayMs) {
804
+ if (this.timer || this.shutdownInvoked || this.lifecycle.shutdownStarted) return
805
+ this.timer = this.setTimer(() => {
806
+ this.timer = null
807
+ void this._check()
808
+ }, delayMs)
809
+ }
810
+
811
+ async _check() {
812
+ if (!this.lifecycle.restartPending || this.shutdownInvoked || this.lifecycle.shutdownStarted) return
813
+ let active
814
+ try {
815
+ active = this._hasActiveWork()
816
+ } catch (err) {
817
+ // 判定不能時に停止へ倒すと作業を切るため、安全側に待って再試行する。
818
+ this.workCheckFailures += 1
819
+ if ((this.workCheckFailures & (this.workCheckFailures - 1)) === 0) {
820
+ this.logger?.warn?.(
821
+ { err: err?.message, failures: this.workCheckFailures },
822
+ "deferred restart work check failed",
823
+ )
824
+ }
825
+ this.settling = false
826
+ this._schedule(Math.min(this.pollMs * (2 ** (this.workCheckFailures - 1)), 5_000))
827
+ return
828
+ }
829
+ this.workCheckFailures = 0
830
+ if (active) {
831
+ this.settling = false
832
+ this._schedule(this.pollMs)
833
+ return
834
+ }
835
+ if (!this.settling) {
836
+ this.settling = true
837
+ this._schedule(this.settleMs)
838
+ return
839
+ }
840
+ // settle 後も idle。main のshutdown が共有 lifecycle を hook await より前に
841
+ // 更新する。coordinator は二重呼び出しだけを独立に防ぐ。
842
+ this.shutdownInvoked = true
843
+ this.logger?.info?.({}, "deferred restart reached idle settle; shutting down")
844
+ try {
845
+ await this.shutdown?.("deferred-restart")
846
+ } catch (err) {
847
+ this.logger?.error?.({ err: err?.message }, "deferred restart shutdown failed")
848
+ }
849
+ }
850
+
851
+ stop() {
852
+ if (this.timer) this.clearTimer(this.timer)
853
+ this.timer = null
854
+ }
855
+ }
856
+
751
857
  export async function startDaemon({ version, claudeSdk } = {}) {
752
858
  const config = await readConfig()
753
859
  if (!config) {
@@ -757,7 +863,9 @@ export async function startDaemon({ version, claudeSdk } = {}) {
757
863
  }
758
864
 
759
865
  const plugins = await loadPlugins(logger)
760
- const ctx = { logger, config, plugins }
866
+ // ctx と dispatchCtx の shallow copy を跨いで共有する lifecycle 正本。
867
+ const lifecycle = { restartPending: false, shutdownStarted: false }
868
+ const ctx = { logger, config, plugins, lifecycle }
761
869
 
762
870
  // 起動時: 保存済み active プロファイルの CLAUDE_CONFIG_DIR を反映してから
763
871
  // Claude SDK を初期化する。profiles.json が無ければ既定 (~/.claude) のまま。
@@ -1268,8 +1376,14 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1268
1376
  // 相互依頼のためのローカル IPC 受信口。cwd スコープ capability トークン
1269
1377
  // (src/sibling-token.mjs) で検証した要求のみ claudeBridge/codexBridge.sendToCwd()
1270
1378
  // へ橋渡しする。
1271
- const siblingInjectBridge = new SiblingInjectBridge({ claudeBridge, codexBridge, logger })
1272
- await siblingInjectBridge.start()
1379
+ let restartCoordinator = null
1380
+ const siblingInjectBridge = new SiblingInjectBridge({
1381
+ claudeBridge,
1382
+ codexBridge,
1383
+ logger,
1384
+ onRestartRequested: () => restartCoordinator.request(),
1385
+ isInputBlocked: () => lifecycle.restartPending || lifecycle.shutdownStarted,
1386
+ })
1273
1387
  ctx.siblingInjectBridge = siblingInjectBridge
1274
1388
 
1275
1389
  // モデル能力レジストリ (2026-07-11): SDK supportedModels() を起動時に引き、effort
@@ -1293,12 +1407,19 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1293
1407
  }
1294
1408
 
1295
1409
  const shutdown = async (signal) => {
1410
+ if (lifecycle.shutdownStarted) return
1411
+ // hook await より前に立てる。deferred/OS signal の競合でも shutdown は一度だけ。
1412
+ lifecycle.shutdownStarted = true
1413
+ // 新しいローカル注入要求も hook 実行中に turn を開始できないよう入口を先に閉じる。
1414
+ siblingInjectBridge.stop()
1296
1415
  logger.info({ signal }, "shutting down")
1416
+ logger.info({ signal }, "agent stop hooks starting")
1297
1417
  await runHookBroadcast(plugins, "onAgentStop", ctx)
1418
+ logger.info({ signal }, "agent stop hooks completed")
1298
1419
  stateLoop.stop()
1299
1420
  sessionEventLoop?.stop?.()
1300
1421
  readinessLoop?.stop?.()
1301
- siblingInjectBridge.stop()
1422
+ restartCoordinator?.stop?.()
1302
1423
  claudeBridge?.shutdown?.()
1303
1424
  codexBridge?.shutdown?.()
1304
1425
  client.stop()
@@ -1306,6 +1427,15 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1306
1427
  clearInterval(keepaliveTimer)
1307
1428
  process.exit(0)
1308
1429
  }
1430
+ restartCoordinator = new DeferredRestartCoordinator({
1431
+ claudeBridge,
1432
+ codexBridge,
1433
+ shutdown,
1434
+ logger,
1435
+ lifecycle,
1436
+ workProviders: [siblingInjectBridge],
1437
+ })
1438
+ await siblingInjectBridge.start()
1309
1439
  process.on("SIGINT", () => shutdown("SIGINT"))
1310
1440
  process.on("SIGTERM", () => shutdown("SIGTERM"))
1311
1441
  // dispatch から呼べるように shutdown を ctx に載せる (agent.shutdown 用)。
@@ -2968,6 +3098,17 @@ const codexInflightByInputId = new Map()
2968
3098
  // 同一 input_id がキューに 2 件積まれて drain 後も 1 件残る (送信待ちチップが消えない)。
2969
3099
  const claudeInflightByInputId = new Map()
2970
3100
 
3101
+ function inputLifecycleRejection(ctx) {
3102
+ if (!ctx.lifecycle?.restartPending && !ctx.lifecycle?.shutdownStarted) return null
3103
+ const reason = ctx.lifecycle.restartPending ? "restart_pending" : "agent_shutting_down"
3104
+ return {
3105
+ reason,
3106
+ message: reason === "restart_pending"
3107
+ ? "restart pending: new input was not accepted"
3108
+ : "agent shutdown in progress: new input was not accepted",
3109
+ }
3110
+ }
3111
+
2971
3112
  /**
2972
3113
  * SDK チャット配達保証 (S2, 2026-07-05): `claude.input` を処理し、browser が付与した
2973
3114
  * `input_id` があれば `claude.input.ack` / `claude.input.nack` を返す。無ければ従来
@@ -3011,6 +3152,21 @@ export function handleClaudeChatInput(msg, ctx) {
3011
3152
  )
3012
3153
  return claudeInflightByInputId.get(input_id)
3013
3154
  }
3155
+ const lifecycleRejection = inputLifecycleRejection(ctx)
3156
+ if (lifecycleRejection) {
3157
+ const { reason, message } = lifecycleRejection
3158
+ if (input_id) {
3159
+ ctx.client.send({ type: "claude.input.nack", stream_id, input_id, reason })
3160
+ } else {
3161
+ ctx.client.send({
3162
+ type: "claude.error",
3163
+ stream_id,
3164
+ error: message,
3165
+ reason,
3166
+ })
3167
+ }
3168
+ return undefined
3169
+ }
3014
3170
  const p = ctx.claudeBridge
3015
3171
  .input({
3016
3172
  stream_id,
@@ -3261,6 +3417,20 @@ export function handleCodexChatInput(msg, ctx) {
3261
3417
  ctx.logger?.info?.({ stream_id, input_id }, "codex.input in-flight dedup (join, no re-dispatch)")
3262
3418
  return codexInflightByInputId.get(input_id)
3263
3419
  }
3420
+ const lifecycleRejection = inputLifecycleRejection(ctx)
3421
+ if (lifecycleRejection) {
3422
+ const { reason, message } = lifecycleRejection
3423
+ if (input_id) {
3424
+ ctx.client.send({ type: "codex.input.nack", stream_id, input_id, reason })
3425
+ }
3426
+ ctx.client.send({
3427
+ type: "codex.error",
3428
+ stream_id,
3429
+ error: message,
3430
+ reason,
3431
+ })
3432
+ return undefined
3433
+ }
3264
3434
  const p = ctx.codexBridge
3265
3435
  .input({
3266
3436
  stream_id,
@@ -314,28 +314,19 @@ export async function installService({ bin } = {}) {
314
314
  throw new Error(`unsupported platform: ${process.platform}`)
315
315
  }
316
316
 
317
- /**
318
- * install-service 済みの hub-agent を再起動する。
319
- *
320
- * - macOS: `launchctl kickstart -k gui/<uid>/<label>` で SIGTERM + 再起動
321
- * - Linux: `systemctl --user restart hub-agent.service`
322
- *
323
- * service が install されていない場合は分かりやすい error を投げる。
324
- * (cockpit web UI から `hub-agent restart` 案内が出ても、enroll 直後の
325
- * ユーザーが叩くケースを想定)
326
- */
327
- export async function restartService() {
317
+ /** install-service 済みかを検査し、後続のrestartが使うservice descriptorを返す。 */
318
+ export async function requireInstalledService() {
328
319
  if (process.platform === "darwin") {
329
320
  const dest = macPlistPath()
330
321
  try {
331
322
  await fs.access(dest)
332
323
  } catch {
333
- throw new Error(
324
+ const err = new Error(
334
325
  `service not installed (${dest} not found). run \`hub-agent install-service\` first`,
335
326
  )
327
+ err.code = "service_not_installed"
328
+ throw err
336
329
  }
337
- const uid = ensureUnixUid()
338
- run("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`])
339
330
  return { platform: "darwin", path: dest, label: PLIST_LABEL }
340
331
  }
341
332
 
@@ -344,15 +335,58 @@ export async function restartService() {
344
335
  try {
345
336
  await fs.access(dest)
346
337
  } catch {
347
- throw new Error(
338
+ const err = new Error(
348
339
  `service not installed (${dest} not found). run \`hub-agent install-service\` first`,
349
340
  )
341
+ err.code = "service_not_installed"
342
+ throw err
350
343
  }
351
- run("systemctl", ["--user", "restart", SYSTEMD_UNIT_NAME])
352
344
  return { platform: "linux", path: dest, unit: SYSTEMD_UNIT_NAME }
353
345
  }
354
346
 
355
- throw new Error(`unsupported platform: ${process.platform}`)
347
+ const err = new Error(`unsupported platform: ${process.platform}`)
348
+ err.code = "unsupported_platform"
349
+ throw err
350
+ }
351
+
352
+ /** CLI が作業中断を伴う --force を案内してよい失敗か。 */
353
+ export function canSuggestForcedRestart(err) {
354
+ return err?.code !== "service_not_installed" && err?.code !== "unsupported_platform"
355
+ }
356
+
357
+ /** 検査済みdescriptor、またはローカル検査結果のserviceを即時再起動する。 */
358
+ export async function restartService(service = null) {
359
+ const installed = service || await requireInstalledService()
360
+ if (installed.platform === "darwin") {
361
+ const uid = ensureUnixUid()
362
+ run("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`])
363
+ } else if (installed.platform === "linux") {
364
+ run("systemctl", ["--user", "restart", SYSTEMD_UNIT_NAME])
365
+ } else {
366
+ const err = new Error(`unsupported platform: ${installed.platform}`)
367
+ err.code = "unsupported_platform"
368
+ throw err
369
+ }
370
+ return installed
371
+ }
372
+
373
+ /** CLI の restart 方針をテスト可能な形に分離する。即時経路は force 明示時だけ選ぶ。 */
374
+ export async function restartWithPolicy({
375
+ force = false,
376
+ deferredRestart,
377
+ immediateRestart = restartService,
378
+ requireInstalled = requireInstalledService,
379
+ } = {}) {
380
+ // deferred でも daemon は正常終了するため、KeepAlive/Restart の登録が無ければ先に拒否する。
381
+ // 判定結果を即時経路へ渡し、同じ filesystem access を二重実行しない。
382
+ const service = await requireInstalled()
383
+ if (force) {
384
+ return { mode: "immediate", result: await immediateRestart(service) }
385
+ }
386
+ if (typeof deferredRestart !== "function") {
387
+ throw new Error("deferred restart transport is unavailable")
388
+ }
389
+ return { mode: "deferred", result: await deferredRestart(), service }
356
390
  }
357
391
 
358
392
  export async function uninstallService() {
@@ -20,8 +20,10 @@
20
20
  * postinstall 等) はどの cwd のトークンも持たないため排除される核心は維持。ターゲット
21
21
  * 解決は引き続き要求の cwd でスコープする (誤 cwd 着弾・別 workspace 情報漏洩を防ぐ)。
22
22
  * トークンが一致しない要求は無視し (実行せず削除)、warn ログのみ残す。
23
+ * 例外は同一UIDのサービス再起動 control (`op:"restart"`) だけで、本文を持たず、所有者限定
24
+ * directory/file (0700/0600) を境界にする。他の op の token 要件は緩めない。
23
25
  *
24
- * 契約: request file `<request_id>.json` は op で 3 形態 (2026-07-19 / T06271):
26
+ * 契約: request file `<request_id>.json` は op で用途を明示する (2026-07-19 / T06271):
25
27
  * - 通常注入 (op 無し): `{cwd, target_cli_kind, text, token}`
26
28
  * cwd 内が 1 本なら注入、複数なら黙って推測せず `ambiguous_target` + 候補一覧。
27
29
  * - 明示注入: `{op:"inject", cwd, target_cli_kind, token, target_session_id, message}`
@@ -32,6 +34,8 @@
32
34
  * - 一覧: `{op:"list", cwd, target_cli_kind, token}` → `{ok, live_sessions}` (--list 用)。
33
35
  * - 全一覧: `{op:"list-all", token}` → `{ok, live_sessions}` (全 cwd・両 CLI。クロス cwd
34
36
  * 宛先探索用、2026-07-23。cwd/target_cli_kind 不要、token のみ必須)。
37
+ * - 安全な再起動: `{op:"restart"}` → `{ok:true, restart_pending:true}`。本文と token を持たず、
38
+ * 所有者限定の IPC directory/request file (0700/0600) だけを境界として扱う。
35
39
  * 処理後は必ず削除し (成否問わず)、通常 IPC は呼び出し側が待ち受けられるよう
36
40
  * `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?,
37
41
  * candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
@@ -60,8 +64,9 @@
60
64
  */
61
65
 
62
66
  import { watch } from "node:fs"
63
- import { chmod, lstat, mkdir, readFile, writeFile, rename, stat, unlink, readdir } from "node:fs/promises"
67
+ import { chmod, lstat, mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
64
68
  import { execFile as execFileCallback } from "node:child_process"
69
+ import { randomUUID } from "node:crypto"
65
70
  import { promisify } from "node:util"
66
71
  import path from "node:path"
67
72
 
@@ -71,6 +76,10 @@ import { describeLiveSessionsUnion } from "./live-sessions.mjs"
71
76
  export const SIBLING_REQUESTS_DIR =
72
77
  process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
73
78
 
79
+ const RESTART_REQUEST_TIMEOUT_MS = 5_000
80
+ const RESTART_RESULT_POLL_MS = 20
81
+ const SIBLING_SWEEP_DEBOUNCE_MS = 25
82
+
74
83
  export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
75
84
  export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
76
85
  const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
@@ -155,6 +164,69 @@ export async function defaultPidStartedAt(pid, exec = execFile) {
155
164
  return normalizeProcessStartedAt(stdout)
156
165
  }
157
166
 
167
+ async function assertOwnedIpcPath(fp, { type, mode }) {
168
+ const info = await lstat(fp)
169
+ if (info.isSymbolicLink()) throw new Error(`${type} must not be a symbolic link`)
170
+ if (type === "IPC directory" ? !info.isDirectory() : !info.isFile()) {
171
+ throw new Error(`${type} has an unexpected file type`)
172
+ }
173
+ const uid = process.getuid?.()
174
+ if (uid == null || info.uid !== uid) throw new Error(`${type} is not owned by the current user`)
175
+ if ((info.mode & 0o777) !== mode) {
176
+ throw new Error(`${type} permissions must be ${mode.toString(8).padStart(4, "0")}`)
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 所有者限定ファイル IPC で、稼働中 daemon へ deferred restart を要求する。
182
+ * restart op だけは capability token を使わず、既存 IPC の所有者・mode を認可境界にする。
183
+ */
184
+ export async function requestDeferredRestart({
185
+ dir = SIBLING_REQUESTS_DIR,
186
+ timeoutMs = RESTART_REQUEST_TIMEOUT_MS,
187
+ pollMs = RESTART_RESULT_POLL_MS,
188
+ } = {}) {
189
+ await assertOwnedIpcPath(dir, { type: "IPC directory", mode: 0o700 })
190
+ const requestId = `restart-${process.pid}-${randomUUID()}`
191
+ const requestPath = path.join(dir, `${requestId}.json`)
192
+ const resultPath = path.join(dir, `${requestId}.result.json`)
193
+ const tmpPath = `${requestPath}.tmp`
194
+ try {
195
+ await writeFile(tmpPath, JSON.stringify({ op: "restart" }), { mode: 0o600, flag: "wx" })
196
+ await rename(tmpPath, requestPath)
197
+ const deadline = Date.now() + timeoutMs
198
+ while (Date.now() < deadline) {
199
+ try {
200
+ await assertOwnedIpcPath(resultPath, { type: "IPC result", mode: 0o600 })
201
+ const result = JSON.parse(await readFile(resultPath, "utf-8"))
202
+ if (!result?.ok) {
203
+ const daemonError = result?.error || "unknown"
204
+ const rejected = new Error(`daemon rejected deferred restart: ${daemonError}`)
205
+ // filesystem ENOENT 等とdaemon契約エラーのnamespaceを分離する。
206
+ rejected.code = "restart_rejected"
207
+ rejected.daemonError = daemonError
208
+ throw rejected
209
+ }
210
+ return result
211
+ } catch (err) {
212
+ if (err?.code !== "ENOENT") throw err
213
+ }
214
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
215
+ }
216
+ const timeout = new Error(
217
+ "deferred restart acknowledgement timed out. If the daemon was just starting, the request " +
218
+ "may have been discarded; wait a few seconds and retry. It may also already have been accepted, " +
219
+ "so check the daemon log before deciding whether interruption with --force is acceptable.",
220
+ )
221
+ timeout.code = "restart_timeout"
222
+ throw timeout
223
+ } finally {
224
+ await unlink(tmpPath).catch(() => {})
225
+ await unlink(requestPath).catch(() => {})
226
+ await unlink(resultPath).catch(() => {})
227
+ }
228
+ }
229
+
158
230
  const DELIVERY_UNCONFIRMED_HINT =
159
231
  "turn RPC は受理されましたが userMessage echo を確認できませんでした。既に届いている可能性があり、" +
160
232
  "再送すると重複配送になります。再送前に対象セッションの受信状態または rollout 監査を確認してください。"
@@ -337,7 +409,8 @@ export class SiblingInjectBridge {
337
409
  /**
338
410
  * @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object,
339
411
  * now?: () => number, processKill?: (pid: number, signal: number) => void,
340
- * pidStartedAt?: (pid: number) => Promise<string>}} [opts]
412
+ * pidStartedAt?: (pid: number) => Promise<string>, onRestartRequested?: () => object,
413
+ * isInputBlocked?: () => boolean, watchFn?: typeof watch}} [opts]
341
414
  */
342
415
  constructor({
343
416
  dir = SIBLING_REQUESTS_DIR,
@@ -347,6 +420,9 @@ export class SiblingInjectBridge {
347
420
  now = Date.now,
348
421
  processKill = process.kill.bind(process),
349
422
  pidStartedAt = defaultPidStartedAt,
423
+ onRestartRequested,
424
+ isInputBlocked,
425
+ watchFn = watch,
350
426
  } = {}) {
351
427
  this.dir = dir
352
428
  this.claudeBridge = claudeBridge
@@ -355,15 +431,46 @@ export class SiblingInjectBridge {
355
431
  /** @type {Set<string>} 二重処理防止 (request_id) */
356
432
  this._seen = new Set()
357
433
  this._watcher = null
434
+ this._sweepTimer = null
358
435
  this._now = now
359
436
  this._processKill = processKill
360
437
  this._pidStartedAt = pidStartedAt
438
+ this._onRestartRequested = onRestartRequested
439
+ this._isInputBlocked = isInputBlocked
440
+ this._watchFn = watchFn
441
+ this._newTurnInFlight = 0
361
442
  /** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
362
443
  this._activeDelegations = new Map()
363
444
  /** @type {Map<string, Map<string, {startedEvent?: object, terminalEvent: object, startedAt?: number, recordedAt: number}>>} 終了済み委譲の再送情報を兼ねた墓標。 */
364
445
  this._terminalDelegations = new Map()
365
446
  }
366
447
 
448
+ /** restart coordinator が通常/明示注入の bridge await 中を観測する。 */
449
+ hasActiveWork() {
450
+ return this._newTurnInFlight > 0
451
+ }
452
+
453
+ async _withNewTurnInFlight(operation) {
454
+ this._newTurnInFlight += 1
455
+ try {
456
+ return await operation()
457
+ } finally {
458
+ this._newTurnInFlight -= 1
459
+ }
460
+ }
461
+
462
+ async _sendNewTurn(operation, fields) {
463
+ try {
464
+ return await this._withNewTurnInFlight(operation)
465
+ } catch (err) {
466
+ this.logger?.error?.(
467
+ { ...fields, err: err?.message, stack: err?.stack },
468
+ "sibling-inject bridge delivery failed",
469
+ )
470
+ return { status: "internal_error" }
471
+ }
472
+ }
473
+
367
474
  _delegationSessionKey(cwd, sessionId) {
368
475
  return `${cwd}\u0000${sessionId}`
369
476
  }
@@ -551,6 +658,7 @@ export class SiblingInjectBridge {
551
658
 
552
659
  /** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
553
660
  async start() {
661
+ this.stop()
554
662
  try {
555
663
  await this._prepareDir()
556
664
  } catch (err) {
@@ -560,14 +668,19 @@ export class SiblingInjectBridge {
560
668
  )
561
669
  return
562
670
  }
563
- await this._sweep()
671
+ await this._sweep({ startup: true })
564
672
  try {
565
- this._watcher = watch(this.dir, (_event, filename) => {
566
- if (filename && filename.endsWith(".json") && !filename.endsWith(".result.json")) {
567
- this._ingest(filename).catch((err) => {
568
- this.logger?.warn({ err: err?.message, filename }, "sibling-inject ingest failed")
673
+ this._watcher = this._watchFn(this.dir, () => {
674
+ // fs.watch のfilenameはOS都合で欠落・統合されうる。tmp作成とrenameを
675
+ // 短くまとめ、通知された1件でディレクトリ全体を正本として再走査する。
676
+ if (this._sweepTimer) clearTimeout(this._sweepTimer)
677
+ this._sweepTimer = setTimeout(() => {
678
+ this._sweepTimer = null
679
+ this._sweep().catch((err) => {
680
+ this.logger?.warn({ err: err?.message }, "sibling-inject event sweep failed")
569
681
  })
570
- }
682
+ }, SIBLING_SWEEP_DEBOUNCE_MS)
683
+ this._sweepTimer.unref?.()
571
684
  })
572
685
  } catch (err) {
573
686
  this.logger?.warn(
@@ -578,7 +691,7 @@ export class SiblingInjectBridge {
578
691
  }
579
692
 
580
693
  /** 起動時 / 取りこぼし保険: dir 内の全要求ファイルを読む。 */
581
- async _sweep() {
694
+ async _sweep({ startup = false } = {}) {
582
695
  let names = []
583
696
  try {
584
697
  names = await readdir(this.dir)
@@ -587,7 +700,7 @@ export class SiblingInjectBridge {
587
700
  }
588
701
  for (const n of names) {
589
702
  if (n.endsWith(".json") && !n.endsWith(".result.json")) {
590
- await this._ingest(n).catch((err) => {
703
+ await this._ingest(n, { startup }).catch((err) => {
591
704
  this.logger?.warn({ err: err?.message, filename: n }, "sibling-inject sweep failed")
592
705
  })
593
706
  }
@@ -599,17 +712,18 @@ export class SiblingInjectBridge {
599
712
  * 成否によらず要求ファイルは削除する。delegate-event 以外は結果ファイルも書く。
600
713
  * @param {string} filename `<request_id>.json`
601
714
  */
602
- async _ingest(filename) {
715
+ async _ingest(filename, { startup = false } = {}) {
603
716
  const request_id = filename.slice(0, -".json".length)
604
717
  if (this._seen.has(request_id)) return
605
718
  this._seen.add(request_id)
606
719
  const fp = path.join(this.dir, filename)
607
720
  let body
721
+ let requestInfo
608
722
  try {
609
- const fileStat = await stat(fp)
610
- if (fileStat.size > SIBLING_REQUEST_MAX_BYTES) {
723
+ requestInfo = await lstat(fp)
724
+ if (requestInfo.size > SIBLING_REQUEST_MAX_BYTES) {
611
725
  this.logger?.warn(
612
- { filename, size: fileStat.size, max_size: SIBLING_REQUEST_MAX_BYTES },
726
+ { filename, size: requestInfo.size, max_size: SIBLING_REQUEST_MAX_BYTES },
613
727
  "sibling-inject: request file rejected because it exceeds the size limit",
614
728
  )
615
729
  // ⚠️ **結果ファイルを必ず書く**。ここで書かずに戻ると、結果をポーリングしている
@@ -620,7 +734,7 @@ export class SiblingInjectBridge {
620
734
  await this._writeResult(request_id, {
621
735
  ok: false,
622
736
  error: "request_too_large",
623
- size: fileStat.size,
737
+ size: requestInfo.size,
624
738
  max_size: SIBLING_REQUEST_MAX_BYTES,
625
739
  })
626
740
  try {
@@ -637,10 +751,11 @@ export class SiblingInjectBridge {
637
751
  return
638
752
  }
639
753
 
640
- // 契約の 3 形態を op で判別する:
754
+ // 注入・一覧・control の契約形を op で判別する:
641
755
  // - op:"list" → 候補列挙 (top-level text 無し)
642
756
  // - op:"inject" → 明示宛先 (target_session_id + message、top-level text は **持たない**)
643
757
  // - op 無し → 通常 (cwd 最新、top-level text あり) ← 旧 agent と後方互換
758
+ // - op:"restart" → 本文なしの deferred restart control
644
759
  // ⚠️ 明示宛先を「op:inject + message (top-level text 無し)」にするのが fail-closed の肝
645
760
  // (T06271 レビュー major①): 旧 hub-agent は `!text` で invalid_request を返すため、
646
761
  // 未知フィールドを無視して sendToCwd 推測注入する事故が **単一リクエストで原理的に
@@ -672,6 +787,7 @@ export class SiblingInjectBridge {
672
787
  const isList = op === "list"
673
788
  const isExplicit = op === "inject"
674
789
  const isDelegateEvent = op === "delegate-event"
790
+ const isRestart = op === "restart"
675
791
  let result
676
792
  const badKind = target_cli_kind !== "claude" && target_cli_kind !== "codex"
677
793
  const delegatePhases = new Set(["started", "completed", "failed", "stopped"])
@@ -696,7 +812,29 @@ export class SiblingInjectBridge {
696
812
  : isExplicit
697
813
  ? !target_session_id || !message // 明示は id と message 必須 (空指定 fail-open を防ぐ)
698
814
  : !text // 通常は top-level text 必須 (=旧契約)
699
- if (isListAll) {
815
+ if (isRestart) {
816
+ // restart は同一ユーザーが所有する 0700/0600 IPC だけを認可境界にする専用 control op。
817
+ // 本文・token・cwd 等を許すと他 op の契約へ認証緩和が波及するため、op 単独だけを受理する。
818
+ const uid = process.getuid?.()
819
+ const unsafeRequestFile =
820
+ requestInfo.isSymbolicLink() ||
821
+ !requestInfo.isFile() ||
822
+ uid == null ||
823
+ requestInfo.uid !== uid ||
824
+ (requestInfo.mode & 0o777) !== 0o600
825
+ const hasBody = Object.keys(body || {}).some((key) => key !== "op")
826
+ if (unsafeRequestFile || hasBody) {
827
+ result = { ok: false, error: "invalid_request" }
828
+ } else if (startup) {
829
+ // 前プロセスがACK前に落ちて残した要求を、次のdaemon起動時に勝手に実行しない。
830
+ // live watcher到着分だけがrestartを開始できる。
831
+ result = { ok: false, error: "restart_stale" }
832
+ } else if (typeof this._onRestartRequested !== "function") {
833
+ result = { ok: false, error: "restart_unavailable" }
834
+ } else {
835
+ result = this._onRestartRequested()
836
+ }
837
+ } else if (isListAll) {
700
838
  // op:"list-all" (2026-07-23 統一再設計 1-2: クロス cwd 宛先探索): 全 cwd・両 CLI の
701
839
  // ライブセッションを列挙する。cwd / target_cli_kind / 本文は不要 (token のみ必須)。
702
840
  // 認可は verifySiblingTokenAny (同一ユーザーの spawn 済みセッションであれば全 cwd を
@@ -783,15 +921,23 @@ export class SiblingInjectBridge {
783
921
  cliKind: target_cli_kind,
784
922
  }),
785
923
  }
924
+ } else if (this._isInputBlocked?.()) {
925
+ // token検証後にのみ状態を返す。delegate-event/list/controlは新turnを作らないため通す。
926
+ result = { ok: false, error: "restart_pending" }
786
927
  } else if (isExplicit) {
787
928
  // 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
788
929
  // sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
789
930
  // request_id を渡すのは、bridge 側が配送 ID を **決定的に** 導出して同一依頼の再処理を
790
931
  // 重複ガードで止められるようにするため (claude bridge は余分な引数を無視する)。
791
932
  const r = bridge?.sendToStream
792
- ? await bridge.sendToStream(cwd, target_session_id, message, injectSource, request_id)
933
+ ? await this._sendNewTurn(
934
+ () => bridge.sendToStream(cwd, target_session_id, message, injectSource, request_id),
935
+ { request_id, cwd, target_cli_kind, op: "inject" },
936
+ )
793
937
  : { status: "not_found" }
794
- if (r.status === "delivered") {
938
+ if (r.status === "internal_error") {
939
+ result = { ok: false, error: "internal_error" }
940
+ } else if (r.status === "delivered") {
795
941
  result = {
796
942
  ok: true,
797
943
  delivered_to: r.session,
@@ -857,9 +1003,14 @@ export class SiblingInjectBridge {
857
1003
  // あった。sendToCwd を status 返却にして解決と配送を 1 呼び出しに統合し、delivered_to は
858
1004
  // **実配送先 (r.session)** を返す。
859
1005
  const r = bridge?.sendToCwd
860
- ? await bridge.sendToCwd(cwd, text, injectSource, request_id)
1006
+ ? await this._sendNewTurn(
1007
+ () => bridge.sendToCwd(cwd, text, injectSource, request_id),
1008
+ { request_id, cwd, target_cli_kind, op: "normal" },
1009
+ )
861
1010
  : { status: "not_found" }
862
- if (r.status === "delivered") {
1011
+ if (r.status === "internal_error") {
1012
+ result = { ok: false, error: "internal_error" }
1013
+ } else if (r.status === "delivered") {
863
1014
  result = {
864
1015
  ok: true,
865
1016
  delivered_to: r.session ?? null,
@@ -933,7 +1084,8 @@ export class SiblingInjectBridge {
933
1084
  : "sibling-inject dispatched"
934
1085
  : "sibling-inject failed",
935
1086
  )
936
- if (!isDelegateEvent) await this._writeResult(request_id, result)
1087
+ // startup sweep 由来の restart には待受者がいない。孤児 result を残さない。
1088
+ if (!isDelegateEvent && !(isRestart && startup)) await this._writeResult(request_id, result)
937
1089
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
938
1090
  try {
939
1091
  await unlink(fp)
@@ -989,6 +1141,8 @@ export class SiblingInjectBridge {
989
1141
  /* ignore */
990
1142
  }
991
1143
  this._watcher = null
1144
+ if (this._sweepTimer) clearTimeout(this._sweepTimer)
1145
+ this._sweepTimer = null
992
1146
  }
993
1147
  }
994
1148
 
package/src/usage.mjs CHANGED
@@ -1489,6 +1489,7 @@ const _codexThreadListIndex = new Map() // base → Map<fp, {mtimeMs,size,lastSe
1489
1489
  const _codexThreadListLastWarnAt = new Map() // base → wall-clock ms
1490
1490
  const CODEX_THREAD_LIST_LOG_INTERVAL_MS = 5 * 60 * 1000
1491
1491
  const CODEX_THREAD_LIST_STALE_ENTRY_MS = 24 * 60 * 60 * 1000
1492
+ const CODEX_THREAD_LIST_HEADER_PARSE_CONCURRENCY = 16
1492
1493
  const CODEX_ROLLOUT_PARSE_IO_ERROR = Symbol("codex-rollout-parse-io-error")
1493
1494
 
1494
1495
  function sameCodexThreadListFingerprint(a, b) {
@@ -1736,18 +1737,29 @@ export async function listCodexThreadsFromDisk({
1736
1737
  // 4. 新規・変更分だけ header を解析する。別 cwd は session_meta を得た時点で止め、
1737
1738
  // その cwd が後で要求された時に一度だけ preview まで補完する。一時 I/O 失敗は cache せず、
1738
1739
  // 次 poll で再試行する。解析済み不正 header (meta=null) は fingerprint 不変なら再解析しない。
1739
- let parsedHeaders = 0
1740
1740
  const headerParseStartedAt = Date.now()
1741
- for (const { fp, mtimeMs, size } of candidates) {
1742
- if (snapshot.has(fp)) continue
1743
- const observed = index.get(fp)
1744
- parsedHeaders++
1745
- const meta = await parseCodexRolloutHead(fp, cwd, _testHooks)
1746
- if (meta === CODEX_ROLLOUT_PARSE_IO_ERROR) continue
1747
- const entry = { mtimeMs, size, lastSeenAt: seenAt, meta }
1748
- commitCodexThreadListEntry(index, fp, observed, entry)
1749
- snapshot.set(fp, entry)
1741
+ const uncachedCandidates = candidates.filter(({ fp }) => !snapshot.has(fp))
1742
+ const parsedHeaders = uncachedCandidates.length
1743
+ let nextHeaderIndex = 0
1744
+ const parseNextHeader = async () => {
1745
+ while (nextHeaderIndex < uncachedCandidates.length) {
1746
+ const { fp, mtimeMs, size } = uncachedCandidates[nextHeaderIndex++]
1747
+ const observed = index.get(fp)
1748
+ const meta = await parseCodexRolloutHead(fp, cwd, _testHooks)
1749
+ if (meta === CODEX_ROLLOUT_PARSE_IO_ERROR) continue
1750
+ const entry = { mtimeMs, size, lastSeenAt: seenAt, meta }
1751
+ commitCodexThreadListEntry(index, fp, observed, entry)
1752
+ snapshot.set(fp, entry)
1753
+ }
1750
1754
  }
1755
+ await Promise.all(
1756
+ Array.from(
1757
+ {
1758
+ length: Math.min(CODEX_THREAD_LIST_HEADER_PARSE_CONCURRENCY, uncachedCandidates.length),
1759
+ },
1760
+ parseNextHeader,
1761
+ ),
1762
+ )
1751
1763
 
1752
1764
  // 5. cwd で絞ってから mtime 降順・limit を適用する。グローバル候補数には左右されない。
1753
1765
  const out = []