@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 CHANGED
@@ -37,8 +37,10 @@ import { resolveSyncTargets, syncBundleTargets } from "../src/hub-bundle.mjs"
37
37
  import { loadPlugins } from "../src/plugin-loader.mjs"
38
38
  import { uninstallPlugin } from "../src/plugin-uninstall.mjs"
39
39
  import {
40
+ canSuggestForcedRestart,
40
41
  installService,
41
42
  restartService,
43
+ restartWithPolicy,
42
44
  uninstallService,
43
45
  } from "../src/service-install.mjs"
44
46
 
@@ -231,18 +233,45 @@ program
231
233
 
232
234
  program
233
235
  .command("restart")
234
- .description("install-service 済みの daemon を再起動 (macOS=launchctl kickstart / Linux=systemctl --user restart)")
235
- .action(async () => {
236
+ .description("実行中の作業完了後に daemon を安全に再起動 (--force は即時再起動)")
237
+ .option("--force", "サービスマネージャーで即時再起動する")
238
+ .action(async (opts) => {
236
239
  try {
237
- const r = await restartService()
238
- console.log(`restarted: ${r.platform} ${r.path}`)
239
- if (r.platform === "darwin") {
240
- console.log(`label: ${r.label}`)
240
+ const restart = await restartWithPolicy({
241
+ force: !!opts.force,
242
+ deferredRestart: async () => {
243
+ const { requestDeferredRestart } = await import("../src/sibling-inject.mjs")
244
+ return requestDeferredRestart()
245
+ },
246
+ immediateRestart: restartService,
247
+ })
248
+ if (restart.mode === "immediate") {
249
+ const r = restart.result
250
+ console.log(`restarted immediately: ${r.platform} ${r.path}`)
251
+ if (r.platform === "darwin") {
252
+ console.log(`label: ${r.label}`)
253
+ } else {
254
+ console.log(`unit: ${r.unit}`)
255
+ }
241
256
  } else {
242
- console.log(`unit: ${r.unit}`)
257
+ console.log("restart accepted: daemon will restart after active work completes")
243
258
  }
244
259
  } catch (err) {
245
260
  console.error(`restart failed: ${err.message}`)
261
+ if (err.code === "service_not_installed") {
262
+ console.error("Run `hub-agent install-service` before restarting.")
263
+ } else if (!opts.force && canSuggestForcedRestart(err)) {
264
+ if (err.code === "restart_rejected" &&
265
+ (err.daemonError === "invalid_request" || err.daemonError === "restart_unavailable")) {
266
+ console.error(
267
+ "The daemon may be an older version or the request contract may be unsupported. " +
268
+ "No immediate fallback was attempted.",
269
+ )
270
+ }
271
+ console.error(
272
+ "Check ~/.hub/agent.log first. Use `hub-agent restart --force` only if interrupting active work is acceptable.",
273
+ )
274
+ }
246
275
  process.exit(1)
247
276
  }
248
277
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.48",
3
+ "version": "0.8.50",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "start": "node bin/hub-agent.mjs start",
21
- "test": "node --test test/*.test.mjs",
21
+ "test": "node --test --test-concurrency=1 test/*.test.mjs",
22
22
  "check:publish-on-main": "node scripts/check-publish-on-main.mjs",
23
23
  "prepublishOnly": "node scripts/check-publish-on-main.mjs && npm test"
24
24
  },
@@ -3703,13 +3703,26 @@ export class ClaudeStreamBridge extends EventEmitter {
3703
3703
  * 観測は **通知の台帳**で行い、権威照会 `thread/backgroundTerminals/list` は使わない
3704
3704
  * (実験 API の有効化が要るうえ、threadId ごとの照会なので子スレッドの背景コマンドを取りこぼす)。 */
3705
3705
  _isSessionProtected(session, now = Date.now()) {
3706
- if (!session) return false
3706
+ return this._hasSessionWork(session, now) || this._liveTerminalCount(session) >= 2
3707
+ }
3708
+
3709
+ /** warm保護から購読数だけを除いた、実作業の共通判定。 */
3710
+ _hasSessionWork(session, now = Date.now()) {
3711
+ if (!session || session._closed) return false
3707
3712
  if (session._busy) return true
3708
- if (Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0) {
3709
- return true
3713
+ if (Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0) return true
3714
+ return !!session._hasLiveTask?.(now)
3715
+ }
3716
+
3717
+ /**
3718
+ * daemon の deferred restart が待つべき実作業があるか。
3719
+ * 多端末 subscribe は warm 保持には必要だが作業ではないため、ここでは数えない。
3720
+ */
3721
+ hasActiveWork(now = Date.now()) {
3722
+ for (const { session } of this._liveCandidatesForCwd()) {
3723
+ if (this._hasSessionWork(session, now)) return true
3710
3724
  }
3711
- if (session._hasLiveTask?.(now)) return true
3712
- return this._liveTerminalCount(session) >= 2
3725
+ return false
3713
3726
  }
3714
3727
 
3715
3728
  /** 子⑤: warm 境界超過セッションを reap する(detach の softDetach onTimeout と同じ撤去処理)。 */
@@ -0,0 +1,198 @@
1
+ import path from "node:path"
2
+ import { lstat } from "node:fs/promises"
3
+
4
+ import { resolveCodexWorkspaceDependencies } from "./codex-workspace-dependencies.mjs"
5
+
6
+ export const ARTIFACT_TEMPLATE_CATALOG_MAX_ITEMS = 100
7
+ export const ARTIFACT_TEMPLATE_CATALOG_MAX_BYTES = 24_000
8
+ export const ARTIFACT_TEMPLATE_DESCRIPTION_MAX_CODEPOINTS = 600
9
+
10
+ const TEMPLATE_NAME_RE = /^(?:[^:]+:)?artifact-template-[a-z0-9][a-z0-9._-]*$/
11
+ const PICKER_CONFIG_KEY = "mcp_servers.openai_artifact_template_picker"
12
+ const DARWIN_RESOURCES_PATH = "/Applications/ChatGPT.app/Contents/Resources"
13
+
14
+ export class CodexArtifactTemplatePickerError extends Error {
15
+ constructor(code, message, options = {}) {
16
+ super(message, options)
17
+ this.name = "CodexArtifactTemplatePickerError"
18
+ this.code = code
19
+ }
20
+ }
21
+
22
+ function fail(code, message, cause) {
23
+ throw new CodexArtifactTemplatePickerError(code, message, cause ? { cause } : undefined)
24
+ }
25
+
26
+ function diagnostic(code, detail = {}) {
27
+ return { code, ...detail }
28
+ }
29
+
30
+ function priorityOf(skillName) {
31
+ if (skillName.startsWith("openai-templates:")) return 2
32
+ return skillName.includes(":") ? 1 : 0
33
+ }
34
+
35
+ function normalizedPathKey(skillPath, platform) {
36
+ // Desktop does not establish case-folding as part of its contract. Normalize separators and
37
+ // dot segments only, preserving case and the original path carried in the catalog.
38
+ return platform === "win32" ? path.win32.normalize(skillPath) : path.posix.normalize(skillPath)
39
+ }
40
+
41
+ function codepointPrefix(value, limit) {
42
+ return Array.from(value).slice(0, limit).join("")
43
+ }
44
+
45
+ function optionalTrimmed(value) {
46
+ return typeof value === "string" ? value.trim() : ""
47
+ }
48
+
49
+ function catalogCandidate(record) {
50
+ if (
51
+ !record ||
52
+ typeof record !== "object" ||
53
+ Array.isArray(record) ||
54
+ typeof record.name !== "string"
55
+ ) {
56
+ return { error: diagnostic("ARTIFACT_TEMPLATE_SKILL_METADATA_INVALID") }
57
+ }
58
+ if (!TEMPLATE_NAME_RE.test(record.name)) return { unrelated: true }
59
+ if (typeof record.path !== "string" || typeof record.enabled !== "boolean") {
60
+ return { error: diagnostic("ARTIFACT_TEMPLATE_SKILL_METADATA_INVALID") }
61
+ }
62
+ if (record.enabled !== true) return { disabled: true }
63
+ // skills/list is authoritative. Do not reinterpret, normalize, or derive this SKILL.md path.
64
+ if (!path.isAbsolute(record.path)) {
65
+ return { error: diagnostic("ARTIFACT_TEMPLATE_SKILL_PATH_INVALID", { skillName: record.name }) }
66
+ }
67
+ const description = optionalTrimmed(record.description)
68
+ || optionalTrimmed(record.interface?.shortDescription)
69
+ || optionalTrimmed(record.interface?.displayName)
70
+ || record.name
71
+ return {
72
+ candidate: {
73
+ skillName: record.name,
74
+ skillPath: record.path,
75
+ description: codepointPrefix(description, ARTIFACT_TEMPLATE_DESCRIPTION_MAX_CODEPOINTS),
76
+ priority: priorityOf(record.name),
77
+ },
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Convert app-server skills/list SkillMetadata records to the Desktop-compatible picker catalog.
83
+ * The caller supplies the exact response records; this module deliberately does not scan plugin
84
+ * caches, personal skill directories, or config.toml to second-guess Codex enablement.
85
+ */
86
+ export function collectCodexArtifactTemplateCatalog({ skills = [], platform = process.platform } = {}) {
87
+ const diagnostics = []
88
+ if (!Array.isArray(skills)) {
89
+ return {
90
+ catalog: [],
91
+ diagnostics: [diagnostic("ARTIFACT_TEMPLATE_SKILLS_LIST_INVALID")],
92
+ }
93
+ }
94
+
95
+ const rawCandidates = []
96
+ for (const record of skills) {
97
+ const result = catalogCandidate(record)
98
+ if (result.error) diagnostics.push(result.error)
99
+ else if (result.candidate) rawCandidates.push(result.candidate)
100
+ }
101
+ rawCandidates.sort((a, b) => b.priority - a.priority)
102
+
103
+ const catalog = []
104
+ let catalogBytes = 2 // JSON array brackets: []
105
+ const names = new Set()
106
+ const paths = new Set()
107
+ for (const { priority: _priority, ...candidate } of rawCandidates) {
108
+ const pathKey = normalizedPathKey(candidate.skillPath, platform)
109
+ if (names.has(candidate.skillName) || paths.has(pathKey)) {
110
+ diagnostics.push(diagnostic("ARTIFACT_TEMPLATE_SKILL_DUPLICATE", {
111
+ skillName: candidate.skillName,
112
+ skillPath: candidate.skillPath,
113
+ }))
114
+ continue
115
+ }
116
+ if (catalog.length >= ARTIFACT_TEMPLATE_CATALOG_MAX_ITEMS) {
117
+ diagnostics.push(diagnostic("ARTIFACT_TEMPLATE_CATALOG_ITEM_LIMIT", { skillName: candidate.skillName }))
118
+ continue
119
+ }
120
+ const candidateBytes = Buffer.byteLength(JSON.stringify(candidate), "utf8")
121
+ const nextBytes = catalogBytes + (catalog.length > 0 ? 1 : 0) + candidateBytes
122
+ if (nextBytes > ARTIFACT_TEMPLATE_CATALOG_MAX_BYTES) {
123
+ diagnostics.push(diagnostic("ARTIFACT_TEMPLATE_CATALOG_BYTE_LIMIT", { skillName: candidate.skillName }))
124
+ continue
125
+ }
126
+ catalog.push(candidate)
127
+ catalogBytes = nextBytes
128
+ names.add(candidate.skillName)
129
+ paths.add(pathKey)
130
+ }
131
+ return { catalog, diagnostics }
132
+ }
133
+
134
+ async function isFile(candidate, lstatImpl) {
135
+ try {
136
+ const value = await lstatImpl(candidate)
137
+ return value.isFile() && !value.isSymbolicLink()
138
+ } catch {
139
+ return false
140
+ }
141
+ }
142
+
143
+ async function resolveResourcesPath({ env, platform, lstatImpl }) {
144
+ const explicit = env.CODEX_ELECTRON_RESOURCES_PATH
145
+ if (explicit) {
146
+ if (!path.isAbsolute(explicit)) {
147
+ fail("CODEX_ARTIFACT_RESOURCES_PATH_INVALID", "CODEX_ELECTRON_RESOURCES_PATH must be absolute")
148
+ }
149
+ return path.normalize(explicit)
150
+ }
151
+ if (platform === "darwin") {
152
+ const server = path.join(DARWIN_RESOURCES_PATH, "artifact-template-picker", "server.mjs")
153
+ if (await isFile(server, lstatImpl)) return DARWIN_RESOURCES_PATH
154
+ }
155
+ fail("CODEX_ARTIFACT_RESOURCES_PATH_UNAVAILABLE", "Codex Electron resources path is unavailable")
156
+ }
157
+
158
+ export async function preflightCodexArtifactTemplatePicker({
159
+ env = process.env,
160
+ platform = process.platform,
161
+ lstatImpl = lstat,
162
+ } = {}) {
163
+ const resourcesPath = await resolveResourcesPath({ env, platform, lstatImpl })
164
+ const serverPath = path.join(resourcesPath, "artifact-template-picker", "server.mjs")
165
+ if (!await isFile(serverPath, lstatImpl)) {
166
+ fail("CODEX_ARTIFACT_TEMPLATE_PICKER_MISSING", "Codex artifact template picker server is missing")
167
+ }
168
+ return { resourcesPath, serverPath }
169
+ }
170
+
171
+ /** Return the dotted thread/start config shape consumed by Codex app-server. */
172
+ export async function buildCodexArtifactTemplatePickerConfig({
173
+ env = process.env,
174
+ platform = process.platform,
175
+ skills = [],
176
+ resolveWorkspaceDependencies = resolveCodexWorkspaceDependencies,
177
+ lstatImpl = lstat,
178
+ preflight,
179
+ onDiagnostic,
180
+ } = {}) {
181
+ const verified = preflight || await preflightCodexArtifactTemplatePicker({ env, platform, lstatImpl })
182
+ const { serverPath } = verified
183
+ const runtime = await resolveWorkspaceDependencies()
184
+ if (typeof runtime?.nodePath !== "string" || !path.isAbsolute(runtime.nodePath)) {
185
+ fail("CODEX_ARTIFACT_RUNTIME_NODE_INVALID", "Codex primary runtime did not return an absolute nodePath")
186
+ }
187
+ const { catalog, diagnostics } = collectCodexArtifactTemplateCatalog({ skills, platform })
188
+ for (const item of diagnostics) onDiagnostic?.(item)
189
+ return {
190
+ [PICKER_CONFIG_KEY]: {
191
+ command: runtime.nodePath,
192
+ args: [serverPath],
193
+ env: {
194
+ CODEX_ARTIFACT_TEMPLATE_SKILLS: JSON.stringify(catalog),
195
+ },
196
+ },
197
+ }
198
+ }
@@ -0,0 +1,191 @@
1
+ import path from "node:path"
2
+
3
+ import { resolveCodexWorkspaceDependencies } from "./codex-workspace-dependencies.mjs"
4
+
5
+ const NAMESPACE = "codex_app"
6
+ const LOAD_WORKSPACE_DEPENDENCIES = "load_workspace_dependencies"
7
+ const COMPLETED_CALL_LEDGER_MAX = 256
8
+
9
+ const EMPTY_OBJECT_SCHEMA = Object.freeze({
10
+ type: "object",
11
+ properties: {},
12
+ additionalProperties: false,
13
+ })
14
+
15
+ export const CODEX_DYNAMIC_TOOLS = Object.freeze([
16
+ Object.freeze({
17
+ type: "namespace",
18
+ name: NAMESPACE,
19
+ description: "Codex standard runtime tools provided by the Cockpit host.",
20
+ tools: Object.freeze([
21
+ Object.freeze({
22
+ type: "function",
23
+ name: LOAD_WORKSPACE_DEPENDENCIES,
24
+ description: "Resolve the verified Codex primary runtime dependencies for artifact skills.",
25
+ inputSchema: EMPTY_OBJECT_SCHEMA,
26
+ }),
27
+ ]),
28
+ }),
29
+ ])
30
+
31
+ function inputText(text) {
32
+ return { contentItems: [{ type: "inputText", text }], success: true }
33
+ }
34
+
35
+ const REQUIRED_INVENTORY_FIELDS = Object.freeze([
36
+ ["bundleVersion", "Bundle version", false],
37
+ ["gitPath", "Git executable", true],
38
+ ["nodePath", "Node.js executable", false],
39
+ ["nodeModulesPath", "Node.js packages", false],
40
+ ["pnpmPath", "pnpm executable", true],
41
+ ["pythonPath", "Python executable", false],
42
+ ["pythonLibrariesPath", "Python packages", false],
43
+ ["overrideBinPath", "Override binaries", false],
44
+ ["fallbackBinPath", "Fallback binaries", false],
45
+ ])
46
+
47
+ function invalidInventory() {
48
+ const error = new Error("Codex workspace dependency inventory is invalid")
49
+ error.code = "CODEX_DYNAMIC_TOOL_INVENTORY_INVALID"
50
+ throw error
51
+ }
52
+
53
+ function inventoryValue(inventory, field, optional) {
54
+ const value = inventory[field]
55
+ if (optional && (value === null || value === undefined)) return null
56
+ if (!isNonEmptyString(value) || /[\r\n`]/.test(value)) invalidInventory()
57
+ if (field !== "bundleVersion" && !path.isAbsolute(value)) invalidInventory()
58
+ return value
59
+ }
60
+
61
+ function inlineCode(value) {
62
+ return `\`${value}\``
63
+ }
64
+
65
+ /** Format only the canonical resolver inventory; compatibility aliases are never substituted. */
66
+ export function formatCodexWorkspaceDependencies(inventory) {
67
+ if (!inventory || typeof inventory !== "object" || Array.isArray(inventory)) invalidInventory()
68
+ const lines = [
69
+ "Workspace dependencies are available for this local desktop thread.",
70
+ "",
71
+ "### Workspace Dependencies",
72
+ "",
73
+ "Use these bundled paths for sheets, slides, documents, PDFs, images, or browser automation:",
74
+ "",
75
+ ]
76
+ for (const [field, label, optional] of REQUIRED_INVENTORY_FIELDS) {
77
+ const value = inventoryValue(inventory, field, optional)
78
+ if (value !== null) lines.push(`- ${label}: ${inlineCode(value)}`)
79
+ }
80
+ return lines.join("\n")
81
+ }
82
+
83
+ function failure(text) {
84
+ return { contentItems: [{ type: "inputText", text }], success: false }
85
+ }
86
+
87
+ export function codexDynamicToolFailure(code) {
88
+ const safeCode = sanitizeCodexDynamicErrorCode(code)
89
+ return failure(`Dynamic tool call failed (${safeCode})`)
90
+ }
91
+
92
+ export function sanitizeCodexDynamicErrorCode(value, fallback = "CODEX_DYNAMIC_TOOL_ERROR") {
93
+ return typeof value === "string" && value.length <= 128 && /^[A-Z0-9_]+$/.test(value)
94
+ ? value
95
+ : fallback
96
+ }
97
+
98
+ export function sanitizeCodexDynamicToolName(value) {
99
+ return typeof value === "string"
100
+ && value.length >= 1
101
+ && value.length <= 128
102
+ && /^[A-Za-z0-9_.:-]+$/.test(value)
103
+ ? value
104
+ : "unknown"
105
+ }
106
+
107
+ function isNonEmptyString(value) {
108
+ return typeof value === "string" && value.trim().length > 0
109
+ }
110
+
111
+ function validateCall(params) {
112
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
113
+ return { ok: false, code: "INVALID_CALL", tool: "unknown" }
114
+ }
115
+ const tool = sanitizeCodexDynamicToolName(params.tool)
116
+ if (
117
+ !isNonEmptyString(params.threadId) ||
118
+ !isNonEmptyString(params.callId) ||
119
+ !isNonEmptyString(params.turnId)
120
+ ) {
121
+ return { ok: false, code: "INVALID_CALL", tool }
122
+ }
123
+ if (params.namespace !== NAMESPACE) {
124
+ return { ok: false, code: "UNKNOWN_NAMESPACE", tool }
125
+ }
126
+ if (params.tool !== LOAD_WORKSPACE_DEPENDENCIES) {
127
+ return { ok: false, code: "UNKNOWN_TOOL", tool }
128
+ }
129
+ if (
130
+ !params.arguments ||
131
+ typeof params.arguments !== "object" ||
132
+ Array.isArray(params.arguments) ||
133
+ Object.keys(params.arguments).length !== 0
134
+ ) {
135
+ return { ok: false, code: "INVALID_ARGUMENTS", tool }
136
+ }
137
+ return { ok: true, tool }
138
+ }
139
+
140
+ /**
141
+ * A per-session executor for host-provided Codex dynamic tools.
142
+ * Recent results are retained in a bounded replay window so transport retries cannot execute a
143
+ * resolver twice without allowing an unbounded session-lifetime ledger.
144
+ */
145
+ export function createCodexDynamicToolExecutor({
146
+ resolveWorkspaceDependencies = resolveCodexWorkspaceDependencies,
147
+ logger,
148
+ } = {}) {
149
+ const calls = new Map()
150
+
151
+ const executeOnce = async (params) => {
152
+ const validation = validateCall(params)
153
+ if (!validation.ok) {
154
+ logger?.warn?.(
155
+ { tool: validation.tool, error_code: validation.code },
156
+ "codex dynamic tool call rejected",
157
+ )
158
+ return failure(`Dynamic tool call rejected (${validation.code})`)
159
+ }
160
+
161
+ try {
162
+ const dependencies = await resolveWorkspaceDependencies()
163
+ return inputText(formatCodexWorkspaceDependencies(dependencies))
164
+ } catch (error) {
165
+ const code = sanitizeCodexDynamicErrorCode(error?.code)
166
+ logger?.warn?.(
167
+ { tool: validation.tool, error_code: code },
168
+ "codex dynamic tool call failed",
169
+ )
170
+ return failure(`Workspace dependency resolution failed (${code})`)
171
+ }
172
+ }
173
+
174
+ return {
175
+ definitions: CODEX_DYNAMIC_TOOLS,
176
+ execute(params) {
177
+ const threadId = isNonEmptyString(params?.threadId) ? params.threadId : null
178
+ const callId = isNonEmptyString(params?.callId) ? params.callId : null
179
+ if (!threadId || !callId) return executeOnce(params)
180
+ const key = JSON.stringify([threadId, callId])
181
+ const existing = calls.get(key)
182
+ if (existing) return existing
183
+ const pending = executeOnce(params)
184
+ calls.set(key, pending)
185
+ if (calls.size > COMPLETED_CALL_LEDGER_MAX) {
186
+ calls.delete(calls.keys().next().value)
187
+ }
188
+ return pending
189
+ },
190
+ }
191
+ }