@cocorograph/hub-agent 0.8.48 → 0.8.50

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.
@@ -0,0 +1,331 @@
1
+ import { constants as fsConstants } from "node:fs"
2
+ import { access, readFile, realpath, stat } from "node:fs/promises"
3
+ import { execFile } from "node:child_process"
4
+ import { promisify } from "node:util"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+
8
+ export const CODEX_PRIMARY_RUNTIME_BUNDLE_FORMAT = 2
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ export class CodexWorkspaceDependenciesError extends Error {
12
+ constructor(code, message, options = {}) {
13
+ super(message, options)
14
+ this.name = "CodexWorkspaceDependenciesError"
15
+ this.code = code
16
+ }
17
+ }
18
+
19
+ function fail(code, message, cause) {
20
+ throw new CodexWorkspaceDependenciesError(code, message, cause ? { cause } : undefined)
21
+ }
22
+
23
+ function resolveRuntimeRoot(runtimeRootOverride, homedirImpl) {
24
+ if (runtimeRootOverride !== undefined) {
25
+ if (typeof runtimeRootOverride !== "string" || !path.isAbsolute(runtimeRootOverride)) {
26
+ fail("CODEX_RUNTIME_ROOT_INVALID", "runtimeRootOverride must be an absolute path")
27
+ }
28
+ return runtimeRootOverride
29
+ }
30
+
31
+ const home = homedirImpl()
32
+ if (typeof home !== "string" || !path.isAbsolute(home)) {
33
+ fail("CODEX_RUNTIME_ROOT_INVALID", "OS home directory must be an absolute path")
34
+ }
35
+ return path.join(home, ".cache", "codex-runtimes", "codex-primary-runtime")
36
+ }
37
+
38
+ function requireNonemptyString(manifest, field, { optional = false } = {}) {
39
+ const value = manifest[field]
40
+ if (optional && value === undefined) return null
41
+ if (typeof value !== "string" || value.trim() === "") {
42
+ fail("CODEX_RUNTIME_MANIFEST_INVALID", `Codex primary runtime manifest field ${field} must be a nonempty string`)
43
+ }
44
+ return value
45
+ }
46
+
47
+ function requireNativeDependencies(manifest) {
48
+ if (manifest.nativeDependencies === undefined) return []
49
+ if (
50
+ !Array.isArray(manifest.nativeDependencies)
51
+ || manifest.nativeDependencies.some((item) => typeof item !== "string" || item.trim() === "")
52
+ ) {
53
+ fail(
54
+ "CODEX_RUNTIME_MANIFEST_INVALID",
55
+ "Codex primary runtime manifest field nativeDependencies must be an array of nonempty strings",
56
+ )
57
+ }
58
+ return manifest.nativeDependencies
59
+ }
60
+
61
+ async function readManifest(manifestPath, readFileImpl) {
62
+ let raw
63
+ try {
64
+ raw = await readFileImpl(manifestPath, "utf8")
65
+ } catch (error) {
66
+ const code = error?.code === "ENOENT"
67
+ ? "CODEX_RUNTIME_MANIFEST_MISSING"
68
+ : "CODEX_RUNTIME_MANIFEST_UNREADABLE"
69
+ fail(code, "Codex primary runtime manifest is not readable", error)
70
+ }
71
+
72
+ try {
73
+ const manifest = JSON.parse(raw)
74
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
75
+ fail("CODEX_RUNTIME_MANIFEST_INVALID", "Codex primary runtime manifest must be a JSON object")
76
+ }
77
+ return manifest
78
+ } catch (error) {
79
+ if (error instanceof CodexWorkspaceDependenciesError) throw error
80
+ fail("CODEX_RUNTIME_MANIFEST_INVALID_JSON", "Codex primary runtime manifest is not valid JSON", error)
81
+ }
82
+ }
83
+
84
+ async function requireFile(candidate, label, statImpl) {
85
+ try {
86
+ if ((await statImpl(candidate)).isFile()) return
87
+ } catch (error) {
88
+ fail("CODEX_RUNTIME_PATH_MISSING", `${label} is missing or inaccessible`, error)
89
+ }
90
+ fail("CODEX_RUNTIME_PATH_INVALID", `${label} must be a file`)
91
+ }
92
+
93
+ async function requireDirectory(candidate, label, statImpl) {
94
+ try {
95
+ if ((await statImpl(candidate)).isDirectory()) return
96
+ } catch (error) {
97
+ fail("CODEX_RUNTIME_PATH_MISSING", `${label} is missing or inaccessible`, error)
98
+ }
99
+ fail("CODEX_RUNTIME_PATH_INVALID", `${label} must be a directory`)
100
+ }
101
+
102
+ async function requireExecutableFile(candidate, label, executableErrorCode, statImpl, accessImpl) {
103
+ await requireFile(candidate, label, statImpl)
104
+ try {
105
+ await accessImpl(candidate, fsConstants.X_OK)
106
+ } catch (error) {
107
+ fail(executableErrorCode, `${label} is not executable`, error)
108
+ }
109
+ }
110
+
111
+ function isWithinRoot(candidate, root) {
112
+ const relative = path.relative(root, candidate)
113
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
114
+ }
115
+
116
+ async function requireContainedRealPath(candidate, label, realRuntimeRoot, realpathImpl) {
117
+ let resolved
118
+ try {
119
+ resolved = await realpathImpl(candidate)
120
+ } catch (error) {
121
+ fail("CODEX_RUNTIME_PATH_MISSING", `${label} is missing or inaccessible`, error)
122
+ }
123
+ if (typeof resolved !== "string" || !path.isAbsolute(resolved)) {
124
+ fail("CODEX_RUNTIME_PATH_INVALID", `${label} real path must be absolute`)
125
+ }
126
+ if (!isWithinRoot(resolved, realRuntimeRoot)) {
127
+ fail("CODEX_RUNTIME_PATH_OUTSIDE_ROOT", `${label} resolves outside the Codex primary runtime root`)
128
+ }
129
+ return resolved
130
+ }
131
+
132
+ async function requireContainedManifestPath(manifestPath, realRuntimeRoot, realpathImpl) {
133
+ let resolved
134
+ try {
135
+ resolved = await realpathImpl(manifestPath)
136
+ } catch (error) {
137
+ const code = error?.code === "ENOENT"
138
+ ? "CODEX_RUNTIME_MANIFEST_MISSING"
139
+ : "CODEX_RUNTIME_MANIFEST_UNREADABLE"
140
+ fail(code, "Codex primary runtime manifest is not accessible", error)
141
+ }
142
+ if (typeof resolved !== "string" || !path.isAbsolute(resolved)) {
143
+ fail("CODEX_RUNTIME_PATH_INVALID", "Codex primary runtime manifest real path must be absolute")
144
+ }
145
+ if (!isWithinRoot(resolved, realRuntimeRoot)) {
146
+ fail(
147
+ "CODEX_RUNTIME_PATH_OUTSIDE_ROOT",
148
+ "Codex primary runtime manifest resolves outside the Codex primary runtime root",
149
+ )
150
+ }
151
+ return resolved
152
+ }
153
+
154
+ function childEnvWithoutExternalLanguagePaths(env) {
155
+ const childEnv = { ...env }
156
+ delete childEnv.NODE_OPTIONS
157
+ delete childEnv.NODE_PATH
158
+ delete childEnv.PYTHONHOME
159
+ delete childEnv.PYTHONPATH
160
+ return childEnv
161
+ }
162
+
163
+ /**
164
+ * Codex standard primary runtime の絶対パスを検証して返す。
165
+ *
166
+ * runtime.json 以外から版や配置を推測せず、ローカル npm・system Node・PATH へ
167
+ * fallback しない。runtimeRoot 自体のsymlinkは追従し、そのrealpathをcontainment rootとする。
168
+ * host側は成功・失敗をそれぞれ短期cacheするが、cache miss時はここで実体を再検証する。
169
+ */
170
+ export async function resolveCodexWorkspaceDependencies({
171
+ env = process.env,
172
+ platform = process.platform,
173
+ arch = process.arch,
174
+ runtimeRootOverride,
175
+ homedirImpl = os.homedir,
176
+ readFileImpl = readFile,
177
+ realpathImpl = realpath,
178
+ statImpl = stat,
179
+ accessImpl = access,
180
+ execFileImpl = execFileAsync,
181
+ } = {}) {
182
+ const runtimeRoot = resolveRuntimeRoot(runtimeRootOverride, homedirImpl)
183
+ const manifestPath = path.join(runtimeRoot, "runtime.json")
184
+ let realRuntimeRoot
185
+ try {
186
+ realRuntimeRoot = await realpathImpl(runtimeRoot)
187
+ } catch (error) {
188
+ fail("CODEX_RUNTIME_ROOT_UNREADABLE", "Codex primary runtime root is not accessible", error)
189
+ }
190
+ if (!path.isAbsolute(realRuntimeRoot)) {
191
+ fail("CODEX_RUNTIME_ROOT_INVALID", "Codex primary runtime real path must be absolute")
192
+ }
193
+ // manifestの内容を読む前に、symlinkを含む実体がruntime root内にあることを確認する。
194
+ await requireContainedManifestPath(manifestPath, realRuntimeRoot, realpathImpl)
195
+ const manifest = await readManifest(manifestPath, readFileImpl)
196
+
197
+ if (manifest.bundleFormatVersion !== CODEX_PRIMARY_RUNTIME_BUNDLE_FORMAT) {
198
+ fail(
199
+ "CODEX_RUNTIME_BUNDLE_FORMAT_UNSUPPORTED",
200
+ `Unsupported Codex primary runtime bundle format: ${String(manifest.bundleFormatVersion)}`,
201
+ )
202
+ }
203
+ if (manifest.targetPlatform !== platform) {
204
+ fail("CODEX_RUNTIME_PLATFORM_MISMATCH", "Codex primary runtime target platform does not match this process")
205
+ }
206
+ if (manifest.targetArch !== arch) {
207
+ fail("CODEX_RUNTIME_ARCH_MISMATCH", "Codex primary runtime target architecture does not match this process")
208
+ }
209
+ if (platform === "win32") {
210
+ fail(
211
+ "CODEX_RUNTIME_PLATFORM_UNSUPPORTED",
212
+ "Codex primary runtime layout for Windows is not supported by this resolver",
213
+ )
214
+ }
215
+
216
+ const bundleVersion = requireNonemptyString(manifest, "bundleVersion")
217
+ requireNonemptyString(manifest, "nodeVersion")
218
+ requireNonemptyString(manifest, "pythonVersion")
219
+ requireNonemptyString(manifest, "artifactToolVersion")
220
+ const pnpmVersion = requireNonemptyString(manifest, "pnpmVersion", { optional: true })
221
+ const nativeDependencies = requireNativeDependencies(manifest)
222
+
223
+ const runtimeNode = path.join(runtimeRoot, "dependencies", "node", "bin", "node")
224
+ const runtimeNodeModules = path.join(runtimeRoot, "dependencies", "node", "node_modules")
225
+ const runtimeBinDir = path.join(runtimeRoot, "dependencies", "bin", "override")
226
+ const runtimePython = path.join(runtimeRoot, "dependencies", "python", "bin", "python3")
227
+ const runtimePythonPackages = path.join(runtimeRoot, "dependencies", "python")
228
+ const runtimeFallbackBinDir = path.join(runtimeRoot, "dependencies", "bin", "fallback")
229
+ const runtimePnpm = pnpmVersion ? path.join(runtimeFallbackBinDir, "pnpm") : null
230
+ const runtimeGit = nativeDependencies.includes("git") ? path.join(runtimeFallbackBinDir, "git") : null
231
+
232
+ for (const [candidate, label] of [
233
+ [runtimeNode, "Bundled runtime Node"],
234
+ [runtimeNodeModules, "Bundled runtime node_modules"],
235
+ [runtimeBinDir, "Bundled runtime binary directory"],
236
+ [runtimePython, "Bundled runtime Python"],
237
+ [runtimePythonPackages, "Bundled runtime Python packages"],
238
+ [runtimeFallbackBinDir, "Bundled runtime fallback binary directory"],
239
+ [runtimePnpm, "Bundled runtime pnpm"],
240
+ [runtimeGit, "Bundled runtime git"],
241
+ ]) {
242
+ if (candidate) await requireContainedRealPath(candidate, label, realRuntimeRoot, realpathImpl)
243
+ }
244
+
245
+ await requireExecutableFile(
246
+ runtimeNode,
247
+ "Bundled runtime Node",
248
+ "CODEX_RUNTIME_NODE_NOT_EXECUTABLE",
249
+ statImpl,
250
+ accessImpl,
251
+ )
252
+ await requireDirectory(runtimeNodeModules, "Bundled runtime node_modules", statImpl)
253
+ await requireDirectory(runtimeBinDir, "Bundled runtime binary directory", statImpl)
254
+ await requireExecutableFile(
255
+ runtimePython,
256
+ "Bundled runtime Python",
257
+ "CODEX_RUNTIME_PYTHON_NOT_EXECUTABLE",
258
+ statImpl,
259
+ accessImpl,
260
+ )
261
+ await requireDirectory(runtimePythonPackages, "Bundled runtime Python packages", statImpl)
262
+ await requireDirectory(runtimeFallbackBinDir, "Bundled runtime fallback binary directory", statImpl)
263
+ if (runtimePnpm) {
264
+ await requireExecutableFile(
265
+ runtimePnpm,
266
+ "Bundled runtime pnpm",
267
+ "CODEX_RUNTIME_PNPM_NOT_EXECUTABLE",
268
+ statImpl,
269
+ accessImpl,
270
+ )
271
+ }
272
+ if (runtimeGit) {
273
+ await requireExecutableFile(
274
+ runtimeGit,
275
+ "Bundled runtime git",
276
+ "CODEX_RUNTIME_GIT_NOT_EXECUTABLE",
277
+ statImpl,
278
+ accessImpl,
279
+ )
280
+ }
281
+
282
+ const runtimeChildEnv = childEnvWithoutExternalLanguagePaths(env)
283
+
284
+ try {
285
+ await execFileImpl(
286
+ runtimeNode,
287
+ ["--input-type=module", "--eval", "await import('@oai/artifact-tool')"],
288
+ {
289
+ cwd: path.dirname(runtimeNodeModules),
290
+ env: runtimeChildEnv,
291
+ encoding: "utf8",
292
+ timeout: 15_000,
293
+ maxBuffer: 64 * 1024,
294
+ killSignal: "SIGKILL",
295
+ windowsHide: true,
296
+ },
297
+ )
298
+ } catch (error) {
299
+ fail("CODEX_RUNTIME_ARTIFACT_TOOL_UNAVAILABLE", "Bundled @oai/artifact-tool could not be imported", error)
300
+ }
301
+
302
+ try {
303
+ await execFileImpl(
304
+ runtimePython,
305
+ ["-I", "-c", "import docx"],
306
+ {
307
+ cwd: runtimePythonPackages,
308
+ env: runtimeChildEnv,
309
+ encoding: "utf8",
310
+ timeout: 15_000,
311
+ maxBuffer: 64 * 1024,
312
+ killSignal: "SIGKILL",
313
+ windowsHide: true,
314
+ },
315
+ )
316
+ } catch (error) {
317
+ fail("CODEX_RUNTIME_DOCUMENTS_PYTHON_UNAVAILABLE", "Bundled Python could not import docx", error)
318
+ }
319
+
320
+ return {
321
+ bundleVersion,
322
+ gitPath: runtimeGit,
323
+ nodePath: runtimeNode,
324
+ nodeModulesPath: runtimeNodeModules,
325
+ pnpmPath: runtimePnpm,
326
+ pythonPath: runtimePython,
327
+ pythonLibrariesPath: runtimePythonPackages,
328
+ overrideBinPath: runtimeBinDir,
329
+ fallbackBinPath: runtimeFallbackBinDir,
330
+ }
331
+ }
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() {