@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.
- package/bin/hub-agent.mjs +36 -7
- package/package.json +2 -2
- package/src/claude-stream-bridge.mjs +18 -5
- package/src/codex-artifact-template-picker.mjs +198 -0
- package/src/codex-dynamic-tools.mjs +191 -0
- package/src/codex-stream-bridge.mjs +405 -10
- package/src/codex-workspace-dependencies.mjs +331 -0
- package/src/main.mjs +174 -4
- package/src/service-install.mjs +51 -17
- package/src/sibling-inject.mjs +177 -23
|
@@ -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
|
-
|
|
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() {
|