@huynle/hub 0.7.4 → 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.
- package/CHANGELOG.md +217 -0
- package/Dockerfile +7 -2
- package/README.md +148 -19
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +632 -49
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- 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.
|
|
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: {
|
|
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.
|
|
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")
|