@cocorograph/hub-agent 0.8.48 → 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 +36 -7
- package/package.json +2 -2
- package/src/claude-stream-bridge.mjs +18 -5
- package/src/codex-stream-bridge.mjs +11 -0
- package/src/main.mjs +174 -4
- package/src/service-install.mjs +51 -17
- package/src/sibling-inject.mjs +177 -23
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("
|
|
235
|
-
.
|
|
236
|
+
.description("実行中の作業完了後に daemon を安全に再起動 (--force は即時再起動)")
|
|
237
|
+
.option("--force", "サービスマネージャーで即時再起動する")
|
|
238
|
+
.action(async (opts) => {
|
|
236
239
|
try {
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
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,
|
package/src/service-install.mjs
CHANGED
|
@@ -314,28 +314,19 @@ export async function installService({ bin } = {}) {
|
|
|
314
314
|
throw new Error(`unsupported platform: ${process.platform}`)
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
/**
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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 =
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
610
|
-
if (
|
|
723
|
+
requestInfo = await lstat(fp)
|
|
724
|
+
if (requestInfo.size > SIBLING_REQUEST_MAX_BYTES) {
|
|
611
725
|
this.logger?.warn(
|
|
612
|
-
{ filename, size:
|
|
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:
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
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 === "
|
|
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
|
|
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 === "
|
|
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
|
-
|
|
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
|
|