@huynle/hub 0.8.0 → 0.8.2

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.
Files changed (133) hide show
  1. package/CHANGELOG.md +326 -1
  2. package/README.md +85 -16
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/paths.ts +3 -3
  6. package/packages/cli/dist/hub-cli.js +723 -281
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  9. package/packages/cli/src/artifacts-mcp.ts +8 -7
  10. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  11. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  12. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  13. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  14. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  15. package/packages/cli/src/cli.ts +3 -3
  16. package/packages/cli/src/http.ts +1 -0
  17. package/packages/cli/src/launches.ts +9 -3
  18. package/packages/cli/src/login.ts +2 -2
  19. package/packages/cli/src/mcp.ts +9 -4
  20. package/packages/cli/src/messaging.ts +4 -2
  21. package/packages/cli/src/profiles-mcp.ts +301 -0
  22. package/packages/cli/src/setup/commands.ts +20 -2
  23. package/packages/cli/src/spoke-services.ts +1 -1
  24. package/packages/cli/src/team-intake-mcp.ts +8 -7
  25. package/packages/cli/src/teams-mcp.ts +61 -3
  26. package/packages/cli/src/tool-args.ts +6 -0
  27. package/packages/cli/src/watchlist.ts +5 -4
  28. package/packages/codex/package.json +1 -1
  29. package/packages/codex/src/paths.ts +3 -3
  30. package/packages/node/package.json +1 -1
  31. package/packages/node/src/config.ts +10 -2
  32. package/packages/node/src/opencode-warm.ts +27 -8
  33. package/packages/node/src/run.ts +3 -2
  34. package/packages/preset/package.json +1 -1
  35. package/packages/protocol/package.json +1 -1
  36. package/packages/protocol/src/concierge.ts +4 -1
  37. package/packages/protocol/src/embedded-media.ts +59 -0
  38. package/packages/protocol/src/index.ts +90 -7
  39. package/packages/protocol/src/launch.ts +14 -0
  40. package/packages/protocol/src/wire.ts +1 -0
  41. package/packages/runtime-openshell/package.json +1 -1
  42. package/packages/runtime-sdk/package.json +1 -1
  43. package/packages/sdk/package.json +1 -1
  44. package/packages/sdk/src/client.ts +3 -1
  45. package/packages/sdk/src/errors.ts +1 -0
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/types.ts +22 -2
  48. package/packages/sdk/src/wake.ts +20 -0
  49. package/packages/spoke-sdk/package.json +1 -1
  50. package/packages/spoke-sdk/src/connection.ts +13 -1
  51. package/src/central/agent/acting-routes.ts +3 -2
  52. package/src/central/agent/acting.ts +70 -12
  53. package/src/central/agent/audit.ts +1 -1
  54. package/src/central/agent/decisions.ts +58 -2
  55. package/src/central/agent/delivery.ts +111 -28
  56. package/src/central/agent/errors.ts +2 -0
  57. package/src/central/agent/escalation.ts +39 -10
  58. package/src/central/agent/events.ts +7 -1
  59. package/src/central/agent/launch-engine.ts +173 -30
  60. package/src/central/agent/launch-routes.ts +20 -9
  61. package/src/central/agent/launch-spec.ts +60 -17
  62. package/src/central/agent/launch-store.ts +55 -2
  63. package/src/central/agent/message-limits.ts +89 -11
  64. package/src/central/agent/message-routes.ts +8 -3
  65. package/src/central/agent/messages.ts +3 -2
  66. package/src/central/agent/placement.ts +93 -18
  67. package/src/central/agent/read-routes.ts +3 -0
  68. package/src/central/agent/routes.ts +11 -2
  69. package/src/central/agent/webhooks.ts +17 -2
  70. package/src/central/artifacts/jobs-routes.ts +4 -1
  71. package/src/central/concierge/board-routes.ts +13 -2
  72. package/src/central/concierge/context.ts +3 -1
  73. package/src/central/concierge/inbound.ts +4 -7
  74. package/src/central/concierge/prompt.ts +10 -6
  75. package/src/central/concierge/proposals.ts +8 -2
  76. package/src/central/concierge/routes.ts +62 -2
  77. package/src/central/concierge/session-routes.ts +62 -6
  78. package/src/central/concierge/supervisor.ts +34 -7
  79. package/src/central/concierge/watchlist-routes.ts +4 -2
  80. package/src/central/config.ts +21 -5
  81. package/src/central/http.ts +87 -35
  82. package/src/central/main.ts +1 -1
  83. package/src/central/registry.ts +49 -4
  84. package/src/central/revert-windows.ts +66 -0
  85. package/src/central/spoke-ws.ts +6 -3
  86. package/src/central/teams/intake.ts +20 -6
  87. package/src/central/teams/staffing.ts +24 -2
  88. package/src/central/workspaces/sidechats-routes.ts +1 -1
  89. package/src/index.ts +63 -30
  90. package/src/migrate.ts +109 -3
  91. package/src/spoke/deliver.ts +6 -0
  92. package/src/spoke/env-inject.ts +8 -1
  93. package/src/spoke/fork-revert.ts +94 -9
  94. package/src/spoke/hub-tool.ts +26 -12
  95. package/src/spoke/launchers.ts +6 -4
  96. package/src/spoke/listing.ts +18 -6
  97. package/src/spoke/localhub.ts +14 -4
  98. package/src/spoke/locations.ts +120 -0
  99. package/src/spoke/profile-apply.ts +24 -14
  100. package/src/spoke/session-context.ts +183 -33
  101. package/src/spoke/spoke.ts +129 -41
  102. package/src/spoke/timeline.ts +30 -3
  103. package/src/spoke/tracker.ts +17 -1
  104. package/src/status.ts +2 -1
  105. package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
  106. package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
  107. package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
  108. package/web/dist/assets/Library-BXbJvTUS.js +2 -0
  109. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
  110. package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
  111. package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
  112. package/web/dist/assets/index-BabrNDB3.css +1 -0
  113. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  114. package/web/dist/assets/live-OgQWPrSY.js +5 -0
  115. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  116. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
  117. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
  118. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
  119. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  120. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  121. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  122. package/web/dist/index.html +4 -4
  123. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  124. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  125. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  126. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  127. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  128. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  129. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  130. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  131. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  132. package/web/dist/assets/live-YyTjE47g.js +0 -5
  133. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -0,0 +1,301 @@
1
+ // MCP tools for the profile lifecycle (FX-03, m8sthu85): show a profile and its versions, create one, edit its
2
+ // metadata, write new versions (whole content, a rollback, or one skill or command attached/detached), archive and
3
+ // restore it, and pin a version to a session. The read tools a session uses on its own pin (profile, profile_skill,
4
+ // profile_command) and the listing (profiles, profile_usage) stay in mcp.ts.
5
+ //
6
+ // Profiles are immutable versioned bundles (HWA-14, RP-08): nothing here edits a version. Every content change is
7
+ // POST /profiles/:id/versions with the profile's revision; attaching or detaching a skill or command reads the latest
8
+ // version and writes its content with that one change as the next version (a stale revision answers conflict, so a
9
+ // concurrent change is never overwritten). No server behaviour is added: each tool is one existing route of
10
+ // src/central/profiles/routes.ts (plus the reads it needs).
11
+ //
12
+ // Annotations for gateway review (packages/cli/src/mcp.ts maps them): reads are readOnlyHint; create, metadata edits
13
+ // and new versions only reorganize (destructiveHint false: pinned sessions keep their explicit version, and an old
14
+ // version can be restored with fromVersion); archive/restore (no new versions or pins) and pins (applied to a running
15
+ // session) keep the default destructiveHint. Changes send expectedRevision, so repeating one is idempotent.
16
+ import { sanitizeText } from "../../sdk/src/envelope.ts"
17
+ import { ToolArgError } from "./tool-args"
18
+ import type { Profiles } from "../../sdk/src/client.ts"
19
+ import type { ProfileCommand, ProfileContent, ProfileSkill, ProfileVersionSummary, ProfileView } from "../../sdk/src/types.ts"
20
+
21
+ type Args = Record<string, unknown>
22
+
23
+ /** The shape of an MCP tool (as packages/cli/src/mcp.ts registers them). */
24
+ export interface ProfileTool {
25
+ readonly name: string
26
+ readonly description: string
27
+ readonly properties: Record<string, unknown>
28
+ readonly required?: readonly string[]
29
+ readonly readOnly: boolean
30
+ readonly organizes?: boolean
31
+ readonly idempotent?: boolean
32
+ run(hub: { readonly profiles: Profiles }, args: Args, signal: AbortSignal): Promise<string>
33
+ }
34
+
35
+ const STRING = { type: "string" } as const
36
+ const STRINGS = { type: "array", items: STRING } as const
37
+ const ID = { type: "string", description: "The profile's ID (prof_…; see profiles)." } as const
38
+ const REVISION = { type: "integer", minimum: 0, description: "The profile's revision you read (profile_show); a stale one answers conflict with the current revision." } as const
39
+ const IDEMPOTENCY = { type: "string", description: "Idempotency-Key: the same key and arguments within 24 h answer the first result again." } as const
40
+ const SKILL = { type: "object", properties: { name: { ...STRING, description: "A slug." }, description: STRING, body: { ...STRING, description: "The skill's full text (loaded on demand)." } }, required: ["name", "description", "body"] } as const
41
+ const COMMAND = { type: "object", properties: { name: { ...STRING, description: "A slug." }, description: STRING, template: { ...STRING, description: "Text with {{name}} parameters and {{secret:NAME}} references (never resolved by the hub)." } }, required: ["name", "description", "template"] } as const
42
+ const CONTENT_FIELDS = {
43
+ instructions: { type: ["string", "null"], description: "The profile's instructions (AGENTS.md-like text)." },
44
+ model: { type: ["string", "null"], description: "provider/model the profile suggests." },
45
+ skills: { type: "array", items: SKILL, description: "Skills, wholesale." },
46
+ commands: { type: "array", items: COMMAND, description: "Commands, wholesale." },
47
+ } as const
48
+ const META_FIELDS = {
49
+ title: { ...STRING, description: "1–120 characters." },
50
+ description: STRING,
51
+ tags: { ...STRINGS, description: "Slugs." },
52
+ metadata: { type: "object", additionalProperties: STRING, description: "String key/value pairs (≤ 32 slug keys)." },
53
+ projects: { ...STRINGS, description: "Project names the profile is meant for (≤ 16)." },
54
+ } as const
55
+ const SCOPES = "App tokens need scope profiles:read (reads) or profiles:manage (changes; profile_version_create with content fields and the skill/command set/remove tools read the latest version first, so they also need profiles:read); pins need profiles:assign and sessions:read (an app also needs a prompt grant on the session). Session tokens never change profiles. A profile confers no authority."
56
+
57
+ /** A bad tool argument: mcp.ts answers it as `invalid: …` (an isError result; tool-args.ts). */
58
+ const ArgError = ToolArgError
59
+
60
+ const need = (args: Args, key: string): string => {
61
+ const value = args[key]
62
+ if (typeof value !== "string" || !value.trim()) throw new ArgError(`"${key}" is required (a non-empty string)`)
63
+ return value
64
+ }
65
+ const optional = (args: Args, key: string): string | undefined => {
66
+ const value = args[key]
67
+ if (value === undefined || value === null) return undefined
68
+ if (typeof value !== "string") throw new ArgError(`"${key}" must be a string`)
69
+ return value.trim() ? value : undefined
70
+ }
71
+ const whole = (args: Args, key: string, min: number, what: string): number => {
72
+ const value = args[key]
73
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) throw new ArgError(`"${key}" is required: ${what}`)
74
+ return value
75
+ }
76
+ const revision = (args: Args): number => whole(args, "expectedRevision", 0, "the revision you read (profile_show; 0 for a session with no pin)")
77
+ const idempotency = (args: Args) => {
78
+ const key = optional(args, "idempotencyKey")
79
+ return key ? { idempotencyKey: key } : {}
80
+ }
81
+ /** The given fields of `keys` (undefined ones dropped). */
82
+ const picked = (args: Args, keys: readonly string[]): Record<string, unknown> => Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]))
83
+
84
+ const one = (text: string) => sanitizeText(text).replace(/\s+/g, " ")
85
+
86
+ /** `prof_… name vN rN state title` */
87
+ export const profileLine = (p: ProfileView): string => `${p.id} ${p.name} v${p.latestVersion} r${p.revision} ${p.state} ${one(p.title)}`
88
+
89
+ /** A version's contents by name (never bodies). */
90
+ function summaryLines(v: ProfileVersionSummary | undefined): string[] {
91
+ if (!v) return []
92
+ return [
93
+ `version ${v.version} (${v.digest.slice(0, 12)}, ${v.bytes} bytes): instructions ${v.instructions ? "yes" : "no"}${v.model ? `, model ${one(v.model)}` : ""}`,
94
+ `skills: ${v.skills.length ? v.skills.map((s) => s.name).join(", ") : "none"}`,
95
+ `commands: ${v.commands.length ? v.commands.map((c) => `${c.name}(${c.params.join(", ")})`).join(", ") : "none"}`,
96
+ ...(v.secretRefs.length ? [`secret references: ${v.secretRefs.join(", ")}`] : []),
97
+ ]
98
+ }
99
+
100
+ const versionText = (r: { profile: ProfileView; version: ProfileVersionSummary }) => [`new version ${r.version.version}: ${profileLine(r.profile)}`, ...summaryLines(r.version)].join("\n")
101
+
102
+ /** The content of a profile's latest version (what an attach/detach starts from). */
103
+ async function latestContent(profiles: Profiles, id: string, signal: AbortSignal): Promise<{ latest: number; content: ProfileContent }> {
104
+ const { profile } = await profiles.get(id, { signal })
105
+ const { version } = await profiles.version(profile.id, profile.latestVersion, { signal })
106
+ return { latest: version.version, content: version.content }
107
+ }
108
+
109
+ /** A tool writing the latest version's content with one change as the next version. */
110
+ const contentTool = (
111
+ name: string,
112
+ description: string,
113
+ properties: Record<string, unknown>,
114
+ required: readonly string[],
115
+ change: (content: ProfileContent, args: Args, latest: number) => ProfileContent,
116
+ ): ProfileTool => ({
117
+ name,
118
+ description: `${description} Writes the latest version's content with this change as the next version (versions are immutable; pinned sessions keep theirs). ${SCOPES}`,
119
+ properties: { id: ID, ...properties, expectedRevision: REVISION },
120
+ required: ["id", ...required, "expectedRevision"],
121
+ readOnly: false,
122
+ organizes: true,
123
+ idempotent: true,
124
+ run: async (hub, args, signal) => {
125
+ const expectedRevision = revision(args)
126
+ const id = need(args, "id")
127
+ const { latest, content } = await latestContent(hub.profiles, id, signal)
128
+ return versionText(await hub.profiles.createVersion(id, { expectedRevision, content: change(content, args, latest) }, { signal }))
129
+ },
130
+ })
131
+
132
+ /** `items` with `item` replacing the one of its name (in place), else appended. */
133
+ const setNamed = <T extends { readonly name: string }>(items: readonly T[], item: T): T[] =>
134
+ items.some((i) => i.name === item.name) ? items.map((i) => (i.name === item.name ? item : i)) : [...items, item]
135
+ const removeNamed = <T extends { readonly name: string }>(items: readonly T[], name: string, what: string, latest: number): T[] => {
136
+ if (!items.some((i) => i.name === name)) throw new ArgError(`version ${latest} has no ${what} ${name}`)
137
+ return items.filter((i) => i.name !== name)
138
+ }
139
+
140
+ const stateTool = (verb: "archive" | "restore", description: string): ProfileTool => ({
141
+ name: `profile_${verb}`,
142
+ description: `${description} ${SCOPES}`,
143
+ properties: { id: ID, expectedRevision: REVISION },
144
+ required: ["id", "expectedRevision"],
145
+ readOnly: false,
146
+ idempotent: true,
147
+ run: async (hub, args, signal) => {
148
+ const expectedRevision = revision(args)
149
+ const { profile } = await hub.profiles.update(need(args, "id"), { expectedRevision, state: verb === "archive" ? "archived" : "active" }, { signal })
150
+ return `${verb === "archive" ? "archived" : "restored"}: ${profileLine(profile)}`
151
+ },
152
+ })
153
+
154
+ export const profileLifecycleTools: readonly ProfileTool[] = [
155
+ // ---------- reads ----------
156
+ {
157
+ name: "profile_show",
158
+ description: `One profile: name, title, state, revision (send it as expectedRevision), tags, projects, metadata, and its latest version's instructions, model, skill and command names (never bodies). ${SCOPES}`,
159
+ properties: { id: ID },
160
+ required: ["id"],
161
+ readOnly: true,
162
+ run: async (hub, args, signal) => {
163
+ const { profile, latest } = await hub.profiles.get(need(args, "id"), { signal })
164
+ return [
165
+ profileLine(profile),
166
+ ...(profile.description ? [`description: ${one(profile.description)}`] : []),
167
+ ...(profile.tags?.length ? [`tags: ${profile.tags.join(", ")}`] : []),
168
+ ...(profile.projects?.length ? [`projects: ${profile.projects.map(one).join(", ")}`] : []),
169
+ ...(profile.metadata && Object.keys(profile.metadata).length ? [`metadata: ${Object.entries(profile.metadata).map(([k, v]) => `${k}=${one(v)}`).join(", ")}`] : []),
170
+ ...summaryLines(latest),
171
+ ].join("\n")
172
+ },
173
+ },
174
+ {
175
+ name: "profile_versions",
176
+ description: `A profile's retained versions, newest first (version, digest, bytes, when, skill and command counts). ${SCOPES}`,
177
+ properties: { id: ID, limit: { type: "integer", minimum: 1, maximum: 200 }, cursor: STRING },
178
+ required: ["id"],
179
+ readOnly: true,
180
+ run: async (hub, args, signal) => {
181
+ const limit = args.limit === undefined ? undefined : whole(args, "limit", 1, "a whole number from 1 to 200")
182
+ const cursor = optional(args, "cursor")
183
+ const page = await hub.profiles.versions(need(args, "id"), { signal, ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}) })
184
+ const counts = (v: (typeof page.items)[number]) => (v.skills !== undefined ? ` ${v.skills} skills, ${v.commands ?? 0} commands${v.instructions ? ", instructions" : ""}` : "")
185
+ return [...page.items.map((v) => `v${v.version} ${v.digest.slice(0, 12)} ${v.bytes} bytes ${new Date(v.createdAt).toISOString()}${counts(v)}`), ...(page.cursor ? [`more: cursor ${page.cursor}`] : [])].join("\n") || "no versions"
186
+ },
187
+ },
188
+
189
+ // ---------- definitions ----------
190
+ {
191
+ name: "profile_create",
192
+ description: `Create a profile: a versioned bundle of instructions, skills and commands, published as version 1 at once. The name is unique per user. Attach it to a team role with team_role_set (profile), pin it with profile_assign. ${SCOPES}`,
193
+ properties: { name: { ...STRING, description: "A slug, unique per user." }, ...META_FIELDS, ...CONTENT_FIELDS, idempotencyKey: IDEMPOTENCY },
194
+ required: ["name", "title"],
195
+ readOnly: false,
196
+ organizes: true,
197
+ idempotent: true,
198
+ run: async (hub, args, signal) => {
199
+ need(args, "name")
200
+ need(args, "title")
201
+ const meta = picked(args, ["name", "title", "description", "tags", "metadata", "projects"]) as { name: string; title: string }
202
+ const text = Object.entries(picked(args, ["instructions", "model"])).filter(([, v]) => v !== null)
203
+ const content = { ...Object.fromEntries(text), skills: (args.skills ?? []) as ProfileSkill[], commands: (args.commands ?? []) as ProfileCommand[] } as ProfileContent
204
+ const r = await hub.profiles.create({ ...meta, content }, { signal, ...idempotency(args) })
205
+ // A replay of the same idempotencyKey answers the first result again (the API does not mark replays).
206
+ return [`created: ${profileLine(r.profile)}`, ...summaryLines(r.version)].join("\n")
207
+ },
208
+ },
209
+ {
210
+ name: "profile_update",
211
+ description: `Edit a profile's metadata: title, description, tags, metadata, projects (null clears description, metadata or projects). Content changes are new versions (profile_version_create, profile_skill_set, …). ${SCOPES}`,
212
+ properties: { id: ID, ...META_FIELDS, description: { type: ["string", "null"] }, metadata: { ...META_FIELDS.metadata, type: ["object", "null"] }, projects: { ...META_FIELDS.projects, type: ["array", "null"] }, expectedRevision: REVISION },
213
+ required: ["id", "expectedRevision"],
214
+ readOnly: false,
215
+ organizes: true,
216
+ idempotent: true,
217
+ run: async (hub, args, signal) => {
218
+ const expectedRevision = revision(args)
219
+ const patch = picked(args, ["title", "description", "tags", "metadata", "projects"])
220
+ if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
221
+ const { profile } = await hub.profiles.update(need(args, "id"), { expectedRevision, ...patch }, { signal })
222
+ return `updated: ${profileLine(profile)}`
223
+ },
224
+ },
225
+ {
226
+ name: "profile_version_create",
227
+ description: `Write a profile's next version: either content fields (instructions, model, skills, commands; the ones you omit are kept from the latest version, null clears instructions or model) or fromVersion (an older version's content again: an explicit rollback). Versions are immutable; pinned sessions keep theirs until re-pinned. ${SCOPES}`,
228
+ properties: { id: ID, ...CONTENT_FIELDS, fromVersion: { type: "integer", minimum: 1, description: "A retained version to restore as the next one." }, expectedRevision: REVISION, idempotencyKey: IDEMPOTENCY },
229
+ required: ["id", "expectedRevision"],
230
+ readOnly: false,
231
+ organizes: true,
232
+ idempotent: true,
233
+ run: async (hub, args, signal) => {
234
+ const expectedRevision = revision(args)
235
+ const id = need(args, "id")
236
+ const given = Object.keys(picked(args, Object.keys(CONTENT_FIELDS)))
237
+ const options = { signal, ...idempotency(args) }
238
+ if (args.fromVersion !== undefined) {
239
+ if (given.length) throw new ArgError(`give either fromVersion or content fields (${given.join(", ")}), not both`)
240
+ return versionText(await hub.profiles.createVersion(id, { expectedRevision, fromVersion: whole(args, "fromVersion", 1, "a version number") }, options))
241
+ }
242
+ if (!given.length) throw new ArgError("give content fields (instructions, model, skills, commands) or fromVersion")
243
+ const { content } = await latestContent(hub.profiles, id, signal)
244
+ const next: Record<string, unknown> = { ...content }
245
+ for (const key of given) {
246
+ if (args[key] === null) delete next[key]
247
+ else next[key] = args[key]
248
+ }
249
+ return versionText(await hub.profiles.createVersion(id, { expectedRevision, content: next as unknown as ProfileContent }, options))
250
+ },
251
+ },
252
+ contentTool("profile_skill_set", "Attach a skill to a profile, or replace the skill of that name.", { name: { ...STRING, description: "The skill's name (a slug)." }, description: STRING, body: { ...STRING, description: "The skill's full text." } }, ["name", "description", "body"], (content, args) => ({
253
+ ...content,
254
+ skills: setNamed(content.skills, { name: need(args, "name"), description: need(args, "description"), body: need(args, "body") }),
255
+ })),
256
+ contentTool("profile_skill_remove", "Detach a skill from a profile.", { name: { ...STRING, description: "The skill's name." } }, ["name"], (content, args, latest) => ({
257
+ ...content,
258
+ skills: removeNamed(content.skills, need(args, "name"), "skill", latest),
259
+ })),
260
+ contentTool(
261
+ "profile_command_set",
262
+ "Attach a command to a profile, or replace the command of that name.",
263
+ { name: { ...STRING, description: "The command's name (a slug)." }, description: STRING, template: COMMAND.properties.template },
264
+ ["name", "description", "template"],
265
+ (content, args) => ({ ...content, commands: setNamed(content.commands, { name: need(args, "name"), description: need(args, "description"), template: need(args, "template") }) }),
266
+ ),
267
+ contentTool("profile_command_remove", "Detach a command from a profile.", { name: { ...STRING, description: "The command's name." } }, ["name"], (content, args, latest) => ({
268
+ ...content,
269
+ commands: removeNamed(content.commands, need(args, "name"), "command", latest),
270
+ })),
271
+ stateTool("archive", "Archive a profile: it takes no new versions or pins; pinned sessions keep theirs; reversible with profile_restore."),
272
+ stateTool("restore", "Restore an archived profile (it takes versions and pins again)."),
273
+
274
+ // ---------- pins ----------
275
+ {
276
+ name: "profile_assign",
277
+ description: `Pin a profile version to a session (applied through its machine; the ack tells whether it took). Send the session's pin revision (0 when it has none; see profile). Pinning confers no authority. ${SCOPES}`,
278
+ properties: { addr: { ...STRING, description: "The session: @name, ses_… ID or other address." }, profileID: ID, version: { type: "integer", minimum: 1 }, expectedRevision: { ...REVISION, description: "The session's pin revision (0 when it has none)." } },
279
+ required: ["addr", "profileID", "version", "expectedRevision"],
280
+ readOnly: false,
281
+ idempotent: true,
282
+ run: async (hub, args, signal) => {
283
+ const expectedRevision = revision(args)
284
+ const { assignment: a } = await hub.profiles.assign(need(args, "addr"), { profileID: need(args, "profileID"), version: whole(args, "version", 1, "a version number"), expectedRevision }, { signal })
285
+ return `${a.sessionID} pins ${a.profileID} v${a.version} r${a.revision}${a.ack ? `, ack ${a.ack.status}${a.ack.offline ? " (offline)" : ""}` : ""}`
286
+ },
287
+ },
288
+ {
289
+ name: "profile_unassign",
290
+ description: `Remove a session's pin (what it already applied stays until it restarts). Send the pin's revision (profile_assign's answer). ${SCOPES}`,
291
+ properties: { addr: { ...STRING, description: "The session." }, expectedRevision: { ...REVISION, description: "The session's pin revision." } },
292
+ required: ["addr", "expectedRevision"],
293
+ readOnly: false,
294
+ idempotent: true,
295
+ run: async (hub, args, signal) => {
296
+ const addr = need(args, "addr")
297
+ await hub.profiles.unassign(addr, revision(args), { signal })
298
+ return `unpinned ${addr}`
299
+ },
300
+ },
301
+ ]
@@ -56,7 +56,8 @@ import { TxStore } from "../bootstrap/transaction/store.ts"
56
56
  import type { TxOutcome } from "../bootstrap/transaction/engine.ts"
57
57
  import { readNodeConfig, nodeConfigPath } from "../../../node/src/config.ts"
58
58
  import { str, UsageError, type FlagKind, type Parsed } from "../args"
59
- import { resolveCredentials, type Env } from "../credentials"
59
+ import { resolveCredentials, type Credentials, type Env } from "../credentials"
60
+ import { isInsecureStoredURL, storedInsecureWarning } from "../bootstrap/enrollment/policy.ts"
60
61
  import { clientFor } from "../http"
61
62
  import { hubLaunchDriver } from "./launch-driver.ts"
62
63
  import { errorCodeOf, issuesText, PlanRefusal } from "./contributors.ts"
@@ -214,11 +215,28 @@ function readState(snapshot: DiscoverySnapshot): BootstrapState {
214
215
  }
215
216
  }
216
217
 
218
+ /**
219
+ * This shell's hub login for the verification launch; a stored plain-http, non-loopback login is warned about once
220
+ * on stderr, as every `hub` command does.
221
+ */
222
+ export function launchCredentials(io: Pick<SetupIo, "env" | "home" | "stderr">): () => Credentials | undefined {
223
+ let warned = false
224
+ return () => {
225
+ const found = resolveCredentials(io.env as Env, io.home)
226
+ if (found?.source === "login" && !warned && isInsecureStoredURL(found.url)) {
227
+ warned = true
228
+ io.stderr(`hubctl: warning: ${storedInsecureWarning(found.url)}\n`)
229
+ }
230
+ return found
231
+ }
232
+ }
233
+
217
234
  /** The verification launch over the hub launch API, with this shell's hub login (resolved when it starts). */
218
235
  function launchDriverFor(io: SetupIo) {
219
236
  const store = credentialStoreFor(io.env, io.home)
237
+ const login = launchCredentials(io)
220
238
  const client = () => {
221
- const credentials = resolveCredentials(io.env as Env, io.home)
239
+ const credentials = login()
222
240
  if (!credentials) throw new Error("not logged in to the hub (run `hub login --scope sessions:launch`; an app token launches under a grant with verb launch)")
223
241
  return clientFor(credentials)
224
242
  }
@@ -18,7 +18,7 @@ export type ServiceVerb = (typeof SERVICE_VERBS)[number]
18
18
  export const isServiceVerb = (verb: string | undefined): verb is ServiceVerb => (SERVICE_VERBS as readonly string[]).includes(verb ?? "")
19
19
 
20
20
  /** Where config.json and the hub state dir are (shared by every hub spoke). */
21
- const HUB_PLACES = ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR"] as const
21
+ const HUB_PLACES = ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR", "XDG_STATE_HOME"] as const
22
22
 
23
23
  export const CLAUDE_CODE_SERVICE: ServiceSpec = {
24
24
  service: "claude-code",
@@ -5,6 +5,7 @@
5
5
  // sessions submit under team policy and, in the team's entry role, report status.
6
6
  import { sanitizeText } from "../../sdk/src/envelope.ts"
7
7
  import { reportIntakeStatus, TEAM_INTAKE_STATUS_INPUTS, type TeamIntake, type TeamIntakeApi, type TeamIntakePriority, type TeamIntakeStatus, type TeamIntakeStatusInput } from "../../sdk/src/team-intake.ts"
8
+ import { ToolArgError } from "./tool-args"
8
9
 
9
10
  type Args = Record<string, unknown>
10
11
 
@@ -29,24 +30,24 @@ const UPDATES: readonly TeamIntakeStatusInput[] = TEAM_INTAKE_STATUS_INPUTS
29
30
 
30
31
  const need = (args: Args, key: string): string => {
31
32
  const value = args[key]
32
- if (typeof value !== "string" || !value.trim()) throw new Error(`"${key}" is required (a non-empty string)`)
33
+ if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
33
34
  return value
34
35
  }
35
36
  const optional = (args: Args, key: string): string | undefined => {
36
37
  const value = args[key]
37
38
  if (value === undefined || value === null) return undefined
38
- if (typeof value !== "string") throw new Error(`"${key}" must be a string`)
39
+ if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
39
40
  return value.trim() ? value : undefined
40
41
  }
41
42
  const strings = (args: Args, key: string): string[] | undefined => {
42
43
  const value = args[key]
43
44
  if (value === undefined || value === null) return undefined
44
- if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new Error(`"${key}" must be a list of strings`)
45
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new ToolArgError(`"${key}" must be a list of strings`)
45
46
  return value
46
47
  }
47
48
  const oneOf = <T extends string>(args: Args, key: string, allowed: readonly T[]): T | undefined => {
48
49
  const value = optional(args, key)
49
- if (value !== undefined && !allowed.includes(value as T)) throw new Error(`"${key}" must be one of ${allowed.join(", ")}`)
50
+ if (value !== undefined && !allowed.includes(value as T)) throw new ToolArgError(`"${key}" must be one of ${allowed.join(", ")}`)
50
51
  return value as T | undefined
51
52
  }
52
53
 
@@ -55,7 +56,7 @@ function dueOf(args: Args): number | undefined {
55
56
  const value = args.due
56
57
  if (value === undefined || value === null || value === "") return undefined
57
58
  const ms = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN
58
- if (!Number.isSafeInteger(ms) || ms <= 0) throw new Error(`"due" must be an ISO date/time or epoch milliseconds`)
59
+ if (!Number.isSafeInteger(ms) || ms <= 0) throw new ToolArgError(`"due" must be an ISO date/time or epoch milliseconds`)
59
60
  return ms
60
61
  }
61
62
 
@@ -146,10 +147,10 @@ export const teamIntakeTools: readonly TeamIntakeTool[] = [
146
147
  idempotent: true,
147
148
  run: async (hub, args, signal) => {
148
149
  const status = oneOf(args, "status", UPDATES)
149
- if (!status) throw new Error(`"status" is required: one of ${UPDATES.join(", ")}`)
150
+ if (!status) throw new ToolArgError(`"status" is required: one of ${UPDATES.join(", ")}`)
150
151
  const expectedRevision = args.expectedRevision
151
152
  if (expectedRevision !== undefined && expectedRevision !== null && (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0))
152
- throw new Error(`"expectedRevision" must be the revision you read (or left out)`)
153
+ throw new ToolArgError(`"expectedRevision" must be the revision you read (or left out)`)
153
154
  const reason = optional(args, "reason")
154
155
  const { intake, assignment } = await reportIntakeStatus(hub.teams, need(args, "team"), need(args, "intake"), { status, ...(typeof expectedRevision === "number" ? { expectedRevision } : {}), ...(reason ? { reason } : {}) }, { signal })
155
156
  return `${intake.id} is ${intake.status}${intake.progress ? ` (${intake.progress})` : ""}, revision ${intake.revision}${assignment ? `; assignment ${assignment.id} ${assignment.status}` : ""}`
@@ -10,6 +10,7 @@
10
10
  // the default destructiveHint. Changes of existing records send expectedRevision, so repeating one is idempotent.
11
11
  // Decision answers and grant administration stay console-only: there is no tool for them here.
12
12
  import { sanitizeText } from "../../sdk/src/envelope.ts"
13
+ import { ToolArgError } from "./tool-args"
13
14
  import type { LaunchSpec } from "../../sdk/src/types.ts"
14
15
  import type { Team, TeamDraft, TeamEdge, TeamPatch, TeamRole, TeamRoster, Teams, TeamSpec } from "../../sdk/src/teams.ts"
15
16
 
@@ -57,9 +58,66 @@ const SPEC_FIELDS = {
57
58
  metadata: { type: "object", additionalProperties: STRING, description: "String key/value pairs (keys are slugs)." },
58
59
  projects: { ...STRINGS, description: "Project names the team works on." },
59
60
  } as const
61
+ // (FX-03, l3vynemo) The launch spec as validateLaunchSpec (src/central/agent/launch-spec.ts) takes it: NESTED sections,
62
+ // unlike the plain launch tool's flat fields. Keep the two in step. No additionalProperties: false, so a flat field
63
+ // reaches the hub, whose answer names the nested place it belongs (a client-side schema refusal would not).
64
+ const LAUNCH = {
65
+ type: "object",
66
+ description:
67
+ "A launch spec (nested, as POST /api/agent/launches takes; unlike the launch tool's flat fields): workspace{repo, mode, branch, base, remote} is required; placement{machine, labels, mode}, runtime{kind, profile}, end{when, timeout, cleanup} and the top-level fields are optional; key is set by staffing (send idempotencyKey instead). The role's prefs fill harness, model and placement.machine, and its profile fills agentProfile, when not given. A flat field (repo, machine, cleanup at the top) is refused with the nested place it belongs.",
68
+ properties: {
69
+ workspace: {
70
+ type: "object",
71
+ description: "Where the member works on its machine.",
72
+ properties: {
73
+ repo: { ...STRING, description: "The repository's absolute path on the machine." },
74
+ mode: { type: "string", enum: ["worktree", "checkout", "clone"], description: "worktree (default; needs branch), checkout, or clone (needs remote)." },
75
+ branch: { ...STRING, description: "The branch to create or check out (a safe git branch name; required for worktree)." },
76
+ base: { ...STRING, description: "What a new branch starts from." },
77
+ remote: { ...STRING, description: "An https or ssh git remote, without credentials (required for clone)." },
78
+ },
79
+ required: ["repo"],
80
+ },
81
+ placement: {
82
+ type: "object",
83
+ description: "Which machine runs it.",
84
+ properties: {
85
+ machine: { ...STRING, description: "A machine ID or name." },
86
+ labels: { type: "object", additionalProperties: STRING, description: "Node labels to match." },
87
+ mode: { type: "string", enum: ["require", "prefer"], description: "require (default) or prefer the machine/labels." },
88
+ },
89
+ },
90
+ runtime: {
91
+ type: "object",
92
+ properties: {
93
+ kind: { type: "string", enum: ["host", "openshell"], description: "host (default) or openshell (clone workspaces only)." },
94
+ profile: { ...STRING, description: "An openshell sandbox profile (openshell only)." },
95
+ },
96
+ },
97
+ end: {
98
+ type: "object",
99
+ description: "When the launch ends.",
100
+ properties: {
101
+ when: { type: "string", enum: ["idle", "asked", "never"], description: "idle (default), asked or never." },
102
+ timeout: { type: ["string", "number"], description: "1 minute to 30 days: seconds, or 30m, 4h, 2d." },
103
+ cleanup: { type: "string", enum: ["keep", "remove"], description: "keep (default) or remove the workspace at the end." },
104
+ },
105
+ },
106
+ name: { ...STRING, description: "The session's @name, without the @." },
107
+ roles: { ...STRINGS, description: "Roles the session claims (up to 10)." },
108
+ harness: { ...STRING, description: "The harness (default opencode)." },
109
+ agent: { ...STRING, description: "The harness agent." },
110
+ model: { ...STRING, description: "provider/model." },
111
+ prompt: { ...STRING, description: "The first prompt (up to 10000 characters)." },
112
+ intent: { ...STRING, description: "What the session is for (one line)." },
113
+ labels: { type: "object", additionalProperties: STRING, description: "Free-form app labels." },
114
+ agentProfile: { type: "object", properties: { profileID: STRING, version: { type: "integer", minimum: 1 } }, required: ["profileID", "version"], description: "The profile version to pin (needs profiles:assign for an app)." },
115
+ },
116
+ required: ["workspace"],
117
+ } as const
60
118
  const SCOPES = "App tokens need scope teams:read (reads) or teams:manage (changes); seats, status and staffing also need sessions:read. Session tokens read only teams they are seated in and never change definitions. Team roles confer no authority."
61
119
 
62
- class ArgError extends Error {}
120
+ const ArgError = ToolArgError
63
121
 
64
122
  const need = (args: Args, key: string): string => {
65
123
  const value = args[key]
@@ -352,7 +410,7 @@ export const teamTools: readonly TeamTool[] = [
352
410
  {
353
411
  name: "team_staff",
354
412
  description: `Staff a role: attach an existing session (sessionID, any machine) or launch a new member (launch: a launch spec as the launch tool takes). An agent needs the user's grants (roles for the session's project; launch for a repo) or the hub answers needs_user; staffing never issues grants. ${SCOPES}`,
355
- properties: { team: TEAM, role: STRING, sessionID: { ...STRING, description: "An existing session (ses_…)." }, launch: { type: "object", description: "A launch spec (repo, harness, model, prompt, …)." }, idempotencyKey: IDEMPOTENCY },
413
+ properties: { team: TEAM, role: STRING, sessionID: { ...STRING, description: "An existing session (ses_…)." }, launch: LAUNCH, idempotencyKey: IDEMPOTENCY },
356
414
  required: ["team", "role"],
357
415
  readOnly: false,
358
416
  run: async (hub, args, signal) => {
@@ -386,7 +444,7 @@ export const teamTools: readonly TeamTool[] = [
386
444
  {
387
445
  name: "team_replace",
388
446
  description: `Replace a seat by an existing session (sessionID) or a new launch (launch): the old seat ends at once and the new member gets the slot's next generation. Send the seat's revision (team_status). ${SCOPES}`,
389
- properties: { team: TEAM, staffingID: STRING, sessionID: STRING, launch: { type: "object" }, expectedRevision: { ...REVISION, description: "The seat's revision you read (team_status)." } },
447
+ properties: { team: TEAM, staffingID: STRING, sessionID: STRING, launch: LAUNCH, expectedRevision: { ...REVISION, description: "The seat's revision you read (team_status)." } },
390
448
  required: ["team", "staffingID", "expectedRevision"],
391
449
  readOnly: false,
392
450
  run: async (hub, args, signal) => {
@@ -0,0 +1,6 @@
1
+ // (R-04) The one error class for a bad MCP tool argument. Every tool family (mcp.ts, teams-mcp.ts,
2
+ // team-intake-mcp.ts, artifacts-mcp.ts, artifact-jobs-mcp.ts, profiles-mcp.ts, watchlist.ts) throws it, and mcp.ts
3
+ // answers it as `invalid: …` (an isError result), never `<tool> failed: …`.
4
+
5
+ /** A bad tool argument: mcp.ts answers it as `invalid: <message>`. */
6
+ export class ToolArgError extends Error {}
@@ -4,6 +4,7 @@
4
4
  // act token (show and the verbs). Other session tokens get not found (exit 3).
5
5
  import { sanitizeLabel } from "../../sdk/src/envelope.ts"
6
6
  import type { Concierge, WatchlistChanged, WatchlistConfig, WatchlistUpdate, WatchlistView } from "../../sdk/src/index.ts"
7
+ import { ToolArgError } from "./tool-args"
7
8
 
8
9
  type Args = Record<string, unknown>
9
10
 
@@ -52,13 +53,13 @@ const STRINGS = { type: "array", items: { type: "string" } } as const
52
53
 
53
54
  const need = (args: Args, key: string): string => {
54
55
  const value = args[key]
55
- if (typeof value !== "string" || !value.trim()) throw new Error(`"${key}" is required (a non-empty string)`)
56
+ if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
56
57
  return value
57
58
  }
58
59
  const flag = (args: Args, key: string, fallback: boolean): boolean => {
59
60
  const value = args[key]
60
61
  if (value === undefined || value === null) return fallback
61
- if (typeof value !== "boolean") throw new Error(`"${key}" must be true or false`)
62
+ if (typeof value !== "boolean") throw new ToolArgError(`"${key}" must be true or false`)
62
63
  return value
63
64
  }
64
65
 
@@ -92,9 +93,9 @@ export const watchlistTools: readonly WatchlistTool[] = [
92
93
  organizes: true,
93
94
  run: async (hub, args, signal) => {
94
95
  const expected = args.expectedRevision
95
- if (typeof expected !== "number" || !Number.isSafeInteger(expected) || expected < 0) throw new Error(`"expectedRevision" is required (the revision you read)`)
96
+ if (typeof expected !== "number" || !Number.isSafeInteger(expected) || expected < 0) throw new ToolArgError(`"expectedRevision" is required (the revision you read)`)
96
97
  const unknown = Object.keys(args).filter((k) => k !== "expectedRevision" && !(UPDATE_FIELDS as readonly string[]).includes(k))
97
- if (unknown.length) throw new Error(`Unknown argument${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${safe(k).slice(0, 40)}"`).join(", ")}: use ${UPDATE_FIELDS.join(", ")} and expectedRevision`)
98
+ if (unknown.length) throw new ToolArgError(`Unknown argument${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${safe(k).slice(0, 40)}"`).join(", ")}: use ${UPDATE_FIELDS.join(", ")} and expectedRevision`)
98
99
  const patch: Record<string, unknown> = {}
99
100
  for (const field of UPDATE_FIELDS) if (args[field] !== undefined && args[field] !== null) patch[field] = args[field]
100
101
  const r = await hub.concierge.watchlist.update(patch as WatchlistUpdate, expected, { signal })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,12 +1,12 @@
1
1
  // Where the Codex spoke looks and keeps its files. It only reads Codex's home (CODEX_HOME, default ~/.codex) to find
2
2
  // the shared daemon's socket; its own files live in <hub state dir>/codex (the hub state dir is $HUB_STATE_DIR, else
3
- // ~/.local/state/hub), a private (0700) directory holding the service's pid file (spoke.pid) and its log.
3
+ // $XDG_STATE_HOME/hub or ~/.local/state/hub), a private (0700) directory holding the service's pid file (spoke.pid) and its log.
4
4
  // $HUB_CODEX_DAEMON_SOCKET names another daemon socket, or "off" for none.
5
5
  import { chmodSync, lstatSync, mkdirSync, readFileSync } from "node:fs"
6
6
  import { homedir } from "node:os"
7
7
  import path from "node:path"
8
8
  import { hubEnv, type Env } from "../../../src/env.ts"
9
- import { APP_DIR, stateBase } from "../../../src/migrate.ts"
9
+ import { resolveHubStateDir } from "../../../src/migrate.ts"
10
10
 
11
11
  export function codexHome(env: Env = process.env, home: string = homedir()): string {
12
12
  return env.CODEX_HOME || path.join(home, ".codex")
@@ -20,7 +20,7 @@ export function daemonSocketPath(env: Env = process.env, home: string = homedir(
20
20
  }
21
21
 
22
22
  export function codexStateDir(env: Env = process.env, home: string = homedir()): string {
23
- return path.join(hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR), "codex")
23
+ return path.join(resolveHubStateDir(env, home).dir, "codex")
24
24
  }
25
25
 
26
26
  export const pidFile = (env: Env = process.env, home: string = homedir()) => path.join(codexStateDir(env, home), "spoke.pid")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-node",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -13,7 +13,7 @@
13
13
  // `opencode` (RF-04, opencode-warm.ts): foreground `opencode serve` processes to keep warm besides the background
14
14
  // service (loopback URLs only, the password from a private file), and the project directories to keep loaded on the
15
15
  // service (`directories`) or on a server (its own `directories`); absent: the projects of recent sessions under
16
- // workspaceRoots.
16
+ // workspaceRoots. `opencode.timeoutSeconds` (1–300, default 30) bounds each warm request.
17
17
  import { readFileSync } from "node:fs"
18
18
  import { homedir, hostname } from "node:os"
19
19
  import path from "node:path"
@@ -55,6 +55,8 @@ export interface OpenCodeNodeConfig {
55
55
  /** Project directories the background service keeps loaded; absent: recent sessions' projects under workspaceRoots. */
56
56
  readonly directories?: readonly string[]
57
57
  readonly servers: readonly OpenCodeServerConfig[]
58
+ /** The warmer's per-request timeout in seconds (1–300; default opencode-warm.ts WARM_TIMEOUT_MS). */
59
+ readonly timeoutSeconds?: number
58
60
  }
59
61
 
60
62
  /** A foreground `opencode serve` the node keeps warm. */
@@ -205,7 +207,13 @@ function normalizeOpenCode(raw: unknown, problems: string[]): OpenCodeNodeConfig
205
207
  problems.push(`opencode.servers: at most ${OPENCODE_SERVERS_MAX} servers; the rest are ignored`)
206
208
  servers.length = OPENCODE_SERVERS_MAX
207
209
  }
208
- return { ...(directories ? { directories } : {}), servers }
210
+ let timeoutSeconds: number | undefined
211
+ if (raw.timeoutSeconds !== undefined) {
212
+ const t = raw.timeoutSeconds
213
+ if (typeof t === "number" && Number.isInteger(t) && t >= 1 && t <= 300) timeoutSeconds = t
214
+ else problems.push("opencode.timeoutSeconds must be a whole number of seconds from 1 to 300")
215
+ }
216
+ return { ...(directories ? { directories } : {}), servers, ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}) }
209
217
  }
210
218
 
211
219
  function normalizeHarnesses(raw: unknown, problems: string[]): Record<string, boolean> | undefined {