@huynle/hub 0.7.3 → 0.8.0

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 (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,135 @@
1
+ // `hub watchlist [show|watch|unwatch|mute|unmute] <addr>` and the MCP tools watchlist_get, watchlist_update,
2
+ // watchlist_watch and watchlist_mute (CON-01): the Concierge's watchlist over /api/agent/concierge/watchlist. Users and
3
+ // app tokens (sessions:read to show, sessions:manage to change); the user's current Concierge session through its
4
+ // act token (show and the verbs). Other session tokens get not found (exit 3).
5
+ import { sanitizeLabel } from "../../sdk/src/envelope.ts"
6
+ import type { Concierge, WatchlistChanged, WatchlistConfig, WatchlistUpdate, WatchlistView } from "../../sdk/src/index.ts"
7
+
8
+ type Args = Record<string, unknown>
9
+
10
+ /** The shape of an MCP tool (as packages/cli/src/mcp.ts registers them). */
11
+ export interface WatchlistTool {
12
+ readonly name: string
13
+ readonly description: string
14
+ readonly properties: Record<string, unknown>
15
+ readonly required?: readonly string[]
16
+ readonly readOnly: boolean
17
+ readonly organizes?: boolean
18
+ readonly idempotent?: boolean
19
+ run(hub: { readonly concierge: Concierge }, args: Args, signal: AbortSignal): Promise<string>
20
+ }
21
+
22
+ /** One value from the hub, safe to print: escapes, control, bidi and zero-width characters removed, one line. */
23
+ const safe = (value: unknown) => sanitizeLabel(String(value), 1024)
24
+ const list = (items: readonly string[]) => (items.length ? items.map(safe).join(", ") : "none")
25
+
26
+ export function watchlistConfigText(c: WatchlistConfig): string {
27
+ return [
28
+ `revision ${safe(c.revision)}`,
29
+ `auto-active: ${c.autoActive ? `on (within ${safe(c.activeWithinHours)} h)` : "off"} pins: ${c.usePins ? "on" : "off"}`,
30
+ `sessions: ${list(c.sessions)}`,
31
+ `projects: ${list(c.projects)}`,
32
+ `labels: ${c.labels.length ? c.labels.map((l) => Object.entries(l).map(([k, v]) => `${safe(k)}=${safe(v)}`).join(",")).join("; ") : "none"}`,
33
+ `muted: ${list(c.muted)}`,
34
+ `muted projects: ${list(c.mutedProjects)}`,
35
+ ].join("\n")
36
+ }
37
+
38
+ export function watchlistText(view: WatchlistView): string {
39
+ const rows = view.entries.map((e) => `${safe(e.sessionID)} ${e.reasons.map(safe).join(",")}${e.lastActiveAt !== undefined ? ` active ${new Date(e.lastActiveAt).toISOString()}` : ""}`)
40
+ return [watchlistConfigText(view.config), "", view.entries.length ? `watching ${view.entries.length}${view.truncated ? " (truncated)" : ""}:` : "watching nothing", ...rows].join("\n")
41
+ }
42
+
43
+ export function watchlistChangedText(verb: string, r: WatchlistChanged): string {
44
+ return `${r.changed ? `${verb}` : `already ${verb}`}: ${safe(r.sessionID)} (revision ${safe(r.config.revision)})`
45
+ }
46
+
47
+ const PAST: Record<string, string> = { watch: "watching", unwatch: "not watching", mute: "muted", unmute: "unmuted" }
48
+ export const watchlistVerbText = (verb: "watch" | "unwatch" | "mute" | "unmute", r: WatchlistChanged) => watchlistChangedText(PAST[verb]!, r)
49
+
50
+ const ADDR = { type: "string", description: "Session address: ses_… (or a unique prefix), @name, @@role, @name.project." } as const
51
+ const STRINGS = { type: "array", items: { type: "string" } } as const
52
+
53
+ const need = (args: Args, key: string): string => {
54
+ const value = args[key]
55
+ if (typeof value !== "string" || !value.trim()) throw new Error(`"${key}" is required (a non-empty string)`)
56
+ return value
57
+ }
58
+ const flag = (args: Args, key: string, fallback: boolean): boolean => {
59
+ const value = args[key]
60
+ if (value === undefined || value === null) return fallback
61
+ if (typeof value !== "boolean") throw new Error(`"${key}" must be true or false`)
62
+ return value
63
+ }
64
+
65
+ const UPDATE_FIELDS = ["autoActive", "activeWithinHours", "usePins", "sessions", "projects", "labels", "muted", "mutedProjects"] as const
66
+
67
+ export const watchlistTools: readonly WatchlistTool[] = [
68
+ {
69
+ name: "watchlist_get",
70
+ description: "The Concierge's watchlist: its config (revision) and the sessions it watches now, with why (active, pinned, pinned_project, explicit, project, label). Needs sessions:read for an app token.",
71
+ properties: {},
72
+ readOnly: true,
73
+ run: async (hub, _args, signal) => watchlistText(await hub.concierge.watchlist.get({ signal })),
74
+ },
75
+ {
76
+ name: "watchlist_update",
77
+ description:
78
+ "Change the watchlist config under the revision you read (expectedRevision; a stale one is a conflict). Give only the fields to change: autoActive, activeWithinHours (1–24), usePins, sessions (ses_… IDs), projects (absolute dirs), labels ([{key: value}]), muted, mutedProjects. Users and app tokens with sessions:manage.",
79
+ properties: {
80
+ expectedRevision: { type: "integer", minimum: 0 },
81
+ autoActive: { type: "boolean" },
82
+ activeWithinHours: { type: "integer", minimum: 1, maximum: 24 },
83
+ usePins: { type: "boolean" },
84
+ sessions: STRINGS,
85
+ projects: STRINGS,
86
+ labels: { type: "array", items: { type: "object", additionalProperties: { type: "string" } } },
87
+ muted: STRINGS,
88
+ mutedProjects: STRINGS,
89
+ },
90
+ required: ["expectedRevision"],
91
+ readOnly: false,
92
+ organizes: true,
93
+ run: async (hub, args, signal) => {
94
+ 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
+ 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
+ const patch: Record<string, unknown> = {}
99
+ for (const field of UPDATE_FIELDS) if (args[field] !== undefined && args[field] !== null) patch[field] = args[field]
100
+ const r = await hub.concierge.watchlist.update(patch as WatchlistUpdate, expected, { signal })
101
+ const head = r.config.revision === expected ? `No change (revision ${safe(expected)})` : `Updated the watchlist (revision ${safe(expected)} → ${safe(r.config.revision)})`
102
+ return `${head}\n${watchlistConfigText(r.config)}`
103
+ },
104
+ },
105
+ {
106
+ name: "watchlist_watch",
107
+ description: "Watch a session (watch: false stops watching it). A subagent watches its root. Idempotent; grants nothing.",
108
+ properties: { addr: ADDR, watch: { type: "boolean", description: "Default true; false unwatches." } },
109
+ required: ["addr"],
110
+ readOnly: false,
111
+ organizes: true,
112
+ idempotent: true,
113
+ run: async (hub, args, signal) => {
114
+ const on = flag(args, "watch", true)
115
+ const addr = need(args, "addr")
116
+ const r = on ? await hub.concierge.watchlist.watch(addr, { signal }) : await hub.concierge.watchlist.unwatch(addr, { signal })
117
+ return watchlistVerbText(on ? "watch" : "unwatch", r)
118
+ },
119
+ },
120
+ {
121
+ name: "watchlist_mute",
122
+ description: "Mute a session in the watchlist (mute: false unmutes it): a muted session is never watched, whatever includes it. Not a notification mute. Idempotent.",
123
+ properties: { addr: ADDR, mute: { type: "boolean", description: "Default true; false unmutes." } },
124
+ required: ["addr"],
125
+ readOnly: false,
126
+ organizes: true,
127
+ idempotent: true,
128
+ run: async (hub, args, signal) => {
129
+ const on = flag(args, "mute", true)
130
+ const addr = need(args, "addr")
131
+ const r = on ? await hub.concierge.watchlist.mute(addr, { signal }) : await hub.concierge.watchlist.unmute(addr, { signal })
132
+ return watchlistVerbText(on ? "mute" : "unmute", r)
133
+ },
134
+ },
135
+ ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
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",
@@ -0,0 +1,163 @@
1
+ // Profile application for Codex threads (HWA-18; protocol packages/protocol/src/profiles.ts, SDK ProfileApplyHandler).
2
+ // What a hub profile can and cannot become in a running Codex thread, without touching Codex's own files:
3
+ //
4
+ // - instructions: applied live. The framed block (profileInstructionsBlock: after local instructions, which win)
5
+ // goes into the thread's context as one developer message through thread/inject_items (no turn is started).
6
+ // - skills: partial. Codex loads native skills only from its configuration (CODEX_HOME/skills, the project's
7
+ // .codex/skills or .agents/skills), which the spoke never writes. The profile's skills become session-scoped files
8
+ // in the spoke's private state directory (<dir>/<sessionID>/v<version>-<digest>/skills/<name>/SKILL.md, 0700),
9
+ // listed by name, description and path in the same context message; a local skill of the same name wins.
10
+ // - commands: not applied (Codex custom prompts live in its global configuration, and the spoke cannot render a
11
+ // command when the user invokes it there), so `{{secret:NAME}}` references are never resolved here.
12
+ // - model: ignored with a note (a thread's model is chosen at launch; the spoke does not switch it).
13
+ // - launch agent: Codex has no agents; a create with `agent` stays refused (sessions.ts), profiles do not change that.
14
+ //
15
+ // Applies to one session run one at a time; a repeated digest answers the same result (ProfileApplyHandler). When
16
+ // the session is deleted its files go; when the spoke only stops reporting it, they stay (the thread's context still
17
+ // names them) and a later apply rewrites them.
18
+ import { randomBytes } from "node:crypto"
19
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"
20
+ import path from "node:path"
21
+ import type { ProfileApplyRequest, ProfileApplyResult, ProfileSkill } from "../../protocol/src/index.ts"
22
+ import { ProfileApplyHandler, type ProfileApplyOutcome, type ProfileCapabilities, type ProfilePlan } from "../../spoke-sdk/src/index.ts"
23
+ import { ensurePrivateDir } from "./paths.ts"
24
+
25
+ /** What the Codex spoke can apply of a profile to a running thread (its effective capabilities). */
26
+ export const CODEX_PROFILE_CAPABILITIES: ProfileCapabilities = Object.freeze({
27
+ instructions: true,
28
+ skills: true,
29
+ commands: false,
30
+ model: false,
31
+ live: true,
32
+ renderAtInvocation: false,
33
+ })
34
+
35
+ export const SKILLS_NOTE =
36
+ "skills offered as session files listed in the thread context, not native Codex skills (Codex loads those only from its own configuration, which the spoke never writes)"
37
+
38
+ /** What CodexProfiles needs of the sessions (CodexSessions). */
39
+ export interface CodexProfilePort {
40
+ /** The thread's working directory, or undefined when the spoke does not report the session. */
41
+ directory(sessionID: string): string | undefined
42
+ /** Adds `text` to the thread's context (a developer message; no turn). */
43
+ inject(sessionID: string, text: string): Promise<void>
44
+ }
45
+
46
+ export interface CodexProfilesOptions {
47
+ readonly port: CodexProfilePort
48
+ /** The spoke's private directory for session-scoped profile files (e.g. <hub state dir>/codex/profiles). */
49
+ readonly dir: string
50
+ /** CODEX_HOME, read only: its skills win over the profile's. */
51
+ readonly codexHome: string
52
+ readonly log?: (line: string) => void
53
+ }
54
+
55
+ const LOCAL_NAMES_MAX = 1000
56
+ const SAFE_NAME = /^[A-Za-z0-9_-]{1,100}$/
57
+
58
+ /** The names of the local Codex skills a thread in `cwd` sees (directory names; read only, bounded). */
59
+ export function localCodexSkillNames(codexHome: string, cwd: string): string[] {
60
+ const names = new Set<string>()
61
+ for (const dir of [path.join(codexHome, "skills"), path.join(cwd, ".codex", "skills"), path.join(cwd, ".agents", "skills")]) {
62
+ let entries
63
+ try {
64
+ entries = readdirSync(dir, { withFileTypes: true })
65
+ } catch {
66
+ continue
67
+ }
68
+ for (const entry of entries.slice(0, LOCAL_NAMES_MAX)) if (entry.isDirectory() || entry.isSymbolicLink()) names.add(entry.name)
69
+ }
70
+ return [...names]
71
+ }
72
+
73
+ const skillFile = (skill: ProfileSkill) => `---\nname: ${skill.name}\ndescription: ${JSON.stringify(skill.description)}\n---\n\n${skill.body}\n`
74
+
75
+ export class CodexProfiles {
76
+ readonly #port: CodexProfilePort
77
+ readonly #dir: string
78
+ readonly #codexHome: string
79
+ readonly #handler: ProfileApplyHandler
80
+ /** The running apply per session (one at a time). */
81
+ readonly #chains = new Map<string, Promise<unknown>>()
82
+
83
+ constructor(options: CodexProfilesOptions) {
84
+ this.#port = options.port
85
+ this.#dir = options.dir
86
+ this.#codexHome = options.codexHome
87
+ this.#handler = new ProfileApplyHandler({
88
+ capabilities: CODEX_PROFILE_CAPABILITIES,
89
+ local: (sessionID) => {
90
+ const cwd = this.#port.directory(sessionID)
91
+ return cwd === undefined ? undefined : { skills: localCodexSkillNames(this.#codexHome, cwd) }
92
+ },
93
+ apply: (plan, req) => this.#apply(plan, req),
94
+ })
95
+ }
96
+
97
+ /** The `profileApply` op handler. */
98
+ readonly handle = (req: ProfileApplyRequest): Promise<ProfileApplyResult> => {
99
+ const previous = this.#chains.get(req.sessionID) ?? Promise.resolve()
100
+ const run = previous.then(() => this.#handler.handle(req))
101
+ const settled = run.catch(() => {})
102
+ this.#chains.set(req.sessionID, settled)
103
+ void settled.then(() => {
104
+ if (this.#chains.get(req.sessionID) === settled) this.#chains.delete(req.sessionID)
105
+ })
106
+ return run
107
+ }
108
+
109
+ /** The spoke no longer reports the session: a later apply applies again (its files stay). */
110
+ ended(sessionID: string): void {
111
+ this.#handler.forget(sessionID)
112
+ }
113
+
114
+ /** The session was deleted: forget it and remove its files. */
115
+ deleted(sessionID: string): void {
116
+ this.#handler.forget(sessionID)
117
+ if (SAFE_NAME.test(sessionID)) rmSync(path.join(this.#dir, sessionID), { recursive: true, force: true })
118
+ }
119
+
120
+ async #apply(plan: ProfilePlan, req: ProfileApplyRequest): Promise<ProfileApplyOutcome> {
121
+ if (this.#port.directory(req.sessionID) === undefined || !SAFE_NAME.test(req.sessionID)) throw new Error(`Unknown Codex session ${req.sessionID}`)
122
+ const sessionDir = path.join(this.#dir, req.sessionID)
123
+ const versionName = `v${req.version}-${req.digest.slice(0, 16)}`
124
+ const versionDir = path.join(sessionDir, versionName)
125
+ let wrote = false
126
+ const parts: string[] = []
127
+ if (plan.instructions !== undefined) parts.push(plan.instructions)
128
+ if (plan.skills.length) {
129
+ if (!existsSync(versionDir)) {
130
+ ensurePrivateDir(this.#dir)
131
+ mkdirSync(sessionDir, { recursive: true, mode: 0o700 })
132
+ const temp = path.join(sessionDir, `.tmp-${randomBytes(6).toString("hex")}`)
133
+ try {
134
+ for (const skill of plan.skills) {
135
+ mkdirSync(path.join(temp, "skills", skill.name), { recursive: true, mode: 0o700 })
136
+ writeFileSync(path.join(temp, "skills", skill.name, "SKILL.md"), skillFile(skill), { mode: 0o600 })
137
+ }
138
+ renameSync(temp, versionDir)
139
+ } catch (cause) {
140
+ rmSync(temp, { recursive: true, force: true })
141
+ throw cause
142
+ }
143
+ wrote = true
144
+ }
145
+ parts.push(
146
+ [
147
+ `Skills from hub profile ${req.profileID} version ${req.version} (session-scoped files, not native Codex skills; a local skill of the same name takes precedence). Read a skill's file before you use it:`,
148
+ ...plan.skills.map((s) => `- ${s.name}: ${s.description} (${path.join(versionDir, "skills", s.name, "SKILL.md")})`),
149
+ ].join("\n"),
150
+ )
151
+ }
152
+ if (parts.length) {
153
+ try {
154
+ await this.#port.inject(req.sessionID, parts.join("\n\n"))
155
+ } catch (cause) {
156
+ if (wrote) rmSync(versionDir, { recursive: true, force: true })
157
+ throw cause
158
+ }
159
+ }
160
+ if (existsSync(sessionDir)) for (const name of readdirSync(sessionDir)) if (name !== versionName || !plan.skills.length) rmSync(path.join(sessionDir, name), { recursive: true, force: true })
161
+ return plan.skills.length ? { status: "partial", notes: [SKILLS_NOTE] } : {}
162
+ }
163
+ }
@@ -222,6 +222,18 @@ export class CodexSessions {
222
222
  return id !== undefined && this.#threads.has(id)
223
223
  }
224
224
 
225
+ /** The working directory of a session the spoke reports, else undefined. */
226
+ directory(sessionID: string): string | undefined {
227
+ const id = codexThreadID(sessionID)
228
+ return id === undefined ? undefined : this.#threads.get(id)?.cwd
229
+ }
230
+
231
+ /** Adds `text` to a session's context as a developer message (thread/inject_items: no turn starts). */
232
+ async injectContext(sessionID: string, text: string): Promise<void> {
233
+ const thread = this.#thread(sessionID)
234
+ await thread.conn.request("thread/inject_items", { threadId: thread.id, items: [{ type: "message", role: "developer", content: [{ type: "input_text", text }] }] })
235
+ }
236
+
225
237
  /** Whether the spoke reports this thread (on any connection). */
226
238
  hasThread(threadID: string): boolean {
227
239
  return this.#threads.has(threadID.toLowerCase())
@@ -10,9 +10,12 @@
10
10
  // never starts the daemon: it connects when the socket answers and tries again every pollMs when it does not.
11
11
  //
12
12
  // Nothing here writes Codex's files (CODEX_HOME): configuration goes in as -c overrides (codexArgs) and environment.
13
+ import path from "node:path"
13
14
  import { HarnessSpoke, type HarnessSpokeOptions } from "../../spoke-sdk/src/index.ts"
14
15
  import { openAppServer, type AppServerConnection } from "./app-server.ts"
15
16
  import type { RpcID } from "./jsonrpc.ts"
17
+ import { codexHome, codexStateDir } from "./paths.ts"
18
+ import { CodexProfiles } from "./profile.ts"
16
19
  import { CodexSessions, type ThreadDescription } from "./sessions.ts"
17
20
  import { spawnStdioChannel } from "./stdio.ts"
18
21
  import { connectUnixWebSocket } from "./websocket.ts"
@@ -45,6 +48,9 @@ export interface CodexSpokeOptions {
45
48
  readonly pollMs?: number
46
49
  readonly threadDefaults?: Readonly<Record<string, unknown>>
47
50
  readonly turnTimeoutMs?: number
51
+ /** Session-scoped profile files (default <hub state dir>/codex/profiles) and CODEX_HOME (read only), from env. */
52
+ readonly profileDir?: string
53
+ readonly codexHome?: string
48
54
  readonly log?: (line: string) => void
49
55
  readonly socket?: HarnessSpokeOptions["socket"]
50
56
  readonly backoff?: HarnessSpokeOptions["backoff"]
@@ -71,13 +77,24 @@ export function startCodexSpoke(options: CodexSpokeOptions): CodexSpoke {
71
77
  const resuming = new Set<string>()
72
78
 
73
79
  const sessions: CodexSessions = new CodexSessions({
74
- spoke: { upsertSession: (info) => spoke.upsertSession(info), endSession: (id) => spoke.endSession(id), emit: (event) => spoke.emit(event) },
80
+ spoke: {
81
+ upsertSession: (info) => spoke.upsertSession(info),
82
+ endSession: (id) => (profiles.ended(id), spoke.endSession(id)),
83
+ emit: (event) => (event.type === "session.deleted" && profiles.deleted(event.data.sessionID), spoke.emit(event)),
84
+ },
75
85
  createServer: () => ownServer(),
76
86
  ...(options.threadDefaults ? { threadDefaults: options.threadDefaults } : {}),
77
87
  ...(options.turnTimeoutMs !== undefined ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
78
88
  log,
79
89
  })
80
90
 
91
+ const profiles = new CodexProfiles({
92
+ port: { directory: (id) => sessions.directory(id), inject: (id, text) => sessions.injectContext(id, text) },
93
+ dir: options.profileDir ?? path.join(codexStateDir(options.env), "profiles"),
94
+ codexHome: options.codexHome ?? codexHome(options.env),
95
+ log,
96
+ })
97
+
81
98
  const spoke = new HarnessSpoke({
82
99
  server: options.server,
83
100
  token: options.token,
@@ -95,6 +112,7 @@ export function startCodexSpoke(options: CodexSpokeOptions): CodexSpoke {
95
112
  stop: (req) => sessions.stop(req),
96
113
  permission: (req) => sessions.permission(req),
97
114
  create: (req) => sessions.create(req),
115
+ profileApply: profiles.handle,
98
116
  },
99
117
  deliver: (req) => sessions.deliver(req),
100
118
  features: ["hubPrompt", "launch", "turnEvents"],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-node",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
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",
@@ -6,18 +6,27 @@
6
6
  // node.json: { "workspaceRoots": ["/Users/huy/projects"], "labels": {"gpu": "no"}, "slots": 6, "memoryMB": 49152,
7
7
  // "openshell": { "enabled": true, "gateway": "openshell", "image": "ghcr.io/huynle/hub-sandbox:0.1",
8
8
  // "profiles": { "untrusted-code": { "providers": ["anthropic"] } } },
9
- // "harnesses": { "claude-code": false } }
9
+ // "harnesses": { "claude-code": false },
10
+ // "opencode": { "directories": ["/Users/huy/projects/app"],
11
+ // "servers": [{ "url": "http://127.0.0.1:4297", "passwordFile": "/Users/huy/.config/opencode-4297.pw" }] } }
10
12
  // `harnesses` overrides what the host runtime detects (harnesses.ts): true offers a harness, false withholds it.
13
+ // `opencode` (RF-04, opencode-warm.ts): foreground `opencode serve` processes to keep warm besides the background
14
+ // service (loopback URLs only, the password from a private file), and the project directories to keep loaded on the
15
+ // service (`directories`) or on a server (its own `directories`); absent: the projects of recent sessions under
16
+ // workspaceRoots.
11
17
  import { readFileSync } from "node:fs"
12
18
  import { homedir, hostname } from "node:os"
13
19
  import path from "node:path"
14
20
  import { isAbsolutePath, labelsProblem, NODE_ROOTS_MAX, NODE_SLOTS_MAX } from "../../protocol/src/index.ts"
15
21
  import { normalizeOpenShellProfiles, type OpenShellProfiles } from "../../runtime-openshell/src/profiles.ts"
16
22
  import { configPath, readConfig } from "../../../src/config.ts"
23
+ import { WARM_DIRECTORIES_MAX } from "./opencode-warm.ts"
17
24
 
18
25
  type Env = Readonly<Record<string, string | undefined>>
19
26
 
20
27
  export const NODE_CONFIG_FILE = "node.json"
28
+ /** Foreground OpenCode servers node.json may list. */
29
+ export const OPENCODE_SERVERS_MAX = 8
21
30
  /** Concurrent launches when node.json sets no `slots`. */
22
31
  export const DEFAULT_SLOTS = 4
23
32
  const MEMORY_MB_MAX = 2 ** 31
@@ -37,6 +46,25 @@ export interface NodeConfig {
37
46
  readonly openshell?: OpenShellNodeConfig
38
47
  /** Per-harness overrides of detection (true: offer it, false: withhold it); absent: detection alone. */
39
48
  readonly harnesses?: Readonly<Record<string, boolean>>
49
+ /** OpenCode servers to keep warm besides the background service, and project directories to keep loaded. */
50
+ readonly opencode?: OpenCodeNodeConfig
51
+ }
52
+
53
+ /** node.json `opencode`. */
54
+ export interface OpenCodeNodeConfig {
55
+ /** Project directories the background service keeps loaded; absent: recent sessions' projects under workspaceRoots. */
56
+ readonly directories?: readonly string[]
57
+ readonly servers: readonly OpenCodeServerConfig[]
58
+ }
59
+
60
+ /** A foreground `opencode serve` the node keeps warm. */
61
+ export interface OpenCodeServerConfig {
62
+ /** Its origin: http(s) on 127.0.0.1, [::1] or localhost, without user info, path or query. */
63
+ readonly url: string
64
+ /** Absolute path of a private file (mode 600) holding its password (OPENCODE_PASSWORD), read on every tick. */
65
+ readonly passwordFile: string
66
+ /** Project directories it keeps loaded; absent: recent sessions' projects under workspaceRoots. */
67
+ readonly directories?: readonly string[]
40
68
  }
41
69
 
42
70
  /** node.json `openshell`: whether the node offers sandboxes, through which gateway, with which image and profiles. */
@@ -107,13 +135,79 @@ export function normalizeNodeConfig(raw: unknown): NodeConfigResult {
107
135
 
108
136
  const openshell = r.openshell === undefined ? undefined : normalizeOpenShell(r.openshell, problems)
109
137
  const harnesses = r.harnesses === undefined ? undefined : normalizeHarnesses(r.harnesses, problems)
138
+ const opencode = r.opencode === undefined ? undefined : normalizeOpenCode(r.opencode, problems)
110
139
 
111
140
  return {
112
- config: { workspaceRoots, labels, slots, ...(memoryMB !== undefined ? { memoryMB } : {}), ...(openshell ? { openshell } : {}), ...(harnesses ? { harnesses } : {}) },
141
+ config: { workspaceRoots, labels, slots, ...(memoryMB !== undefined ? { memoryMB } : {}), ...(openshell ? { openshell } : {}), ...(harnesses ? { harnesses } : {}), ...(opencode ? { opencode } : {}) },
113
142
  problems,
114
143
  }
115
144
  }
116
145
 
146
+ /** Absolute directories (not /), trailing slashes dropped, at most WARM_DIRECTORIES_MAX; undefined for a non-list. */
147
+ function normalizeDirectories(raw: unknown, field: string, problems: string[]): string[] | undefined {
148
+ if (!Array.isArray(raw)) {
149
+ problems.push(`${field} must be a list of absolute paths`)
150
+ return undefined
151
+ }
152
+ const out: string[] = []
153
+ for (const dir of raw) {
154
+ if (!isAbsolutePath(dir) || dir === "/") {
155
+ problems.push(`${field}: ${show(dir)} is not an absolute path (without . or .. segments, and not /)`)
156
+ continue
157
+ }
158
+ const clean = dir.replace(/\/+$/, "")
159
+ if (!out.includes(clean)) out.push(clean)
160
+ }
161
+ if (out.length > WARM_DIRECTORIES_MAX) {
162
+ problems.push(`${field}: at most ${WARM_DIRECTORIES_MAX} directories; the rest are ignored`)
163
+ out.length = WARM_DIRECTORIES_MAX
164
+ }
165
+ return out
166
+ }
167
+
168
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"])
169
+
170
+ /** A server URL's origin, or undefined; never echoed in problems (it could carry a password). */
171
+ function loopbackOrigin(raw: unknown): string | undefined {
172
+ if (typeof raw !== "string" || raw.length > 2048) return undefined
173
+ let url: URL
174
+ try {
175
+ url = new URL(raw)
176
+ } catch {
177
+ return undefined
178
+ }
179
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
180
+ if (url.username || url.password || !LOOPBACK_HOSTS.has(url.hostname)) return undefined
181
+ if ((url.pathname !== "/" && url.pathname !== "") || url.search || url.hash) return undefined
182
+ return url.origin
183
+ }
184
+
185
+ function normalizeOpenCode(raw: unknown, problems: string[]): OpenCodeNodeConfig | undefined {
186
+ if (!isObject(raw)) {
187
+ problems.push("opencode must be an object")
188
+ return undefined
189
+ }
190
+ const directories = raw.directories === undefined ? undefined : normalizeDirectories(raw.directories, "opencode.directories", problems)
191
+ const servers: OpenCodeServerConfig[] = []
192
+ if (raw.servers !== undefined && !Array.isArray(raw.servers)) problems.push("opencode.servers must be a list of {url, passwordFile, directories?}")
193
+ const list = Array.isArray(raw.servers) ? raw.servers : []
194
+ list.forEach((entry, i) => {
195
+ const field = `opencode.servers[${i}]`
196
+ if (!isObject(entry)) return void problems.push(`${field} must be an object {url, passwordFile, directories?}`)
197
+ const url = loopbackOrigin(entry.url)
198
+ if (!url) return void problems.push(`${field}.url must be an http(s) URL on 127.0.0.1, [::1] or localhost, without user info, path or query`)
199
+ if (!isAbsolutePath(entry.passwordFile) || entry.passwordFile === "/") return void problems.push(`${field}.passwordFile must be the absolute path of a private (chmod 600) file holding the server's password`)
200
+ if (servers.some((s) => s.url === url)) return void problems.push(`${field}: ${url} is listed twice; the first one is used`)
201
+ const dirs = entry.directories === undefined ? undefined : normalizeDirectories(entry.directories, `${field}.directories`, problems)
202
+ servers.push({ url, passwordFile: entry.passwordFile, ...(dirs ? { directories: dirs } : {}) })
203
+ })
204
+ if (servers.length > OPENCODE_SERVERS_MAX) {
205
+ problems.push(`opencode.servers: at most ${OPENCODE_SERVERS_MAX} servers; the rest are ignored`)
206
+ servers.length = OPENCODE_SERVERS_MAX
207
+ }
208
+ return { ...(directories ? { directories } : {}), servers }
209
+ }
210
+
117
211
  function normalizeHarnesses(raw: unknown, problems: string[]): Record<string, boolean> | undefined {
118
212
  if (!isObject(raw)) {
119
213
  problems.push("harnesses must be an object of harness names to true or false")