@huynle/hub 0.7.4 → 0.8.1
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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- 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/paths.ts +3 -3
- 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 +6871 -3460
- 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/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- 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 +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -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 +175 -6
- package/packages/protocol/src/launch.ts +36 -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 +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- 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 +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- 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 +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- 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 +776 -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 +350 -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 +86 -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 +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- 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 +69 -7
- 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 +786 -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 +910 -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 +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- 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-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -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
package/src/spoke/listing.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The session listing for the phone's session browser (the background service: every session; a foreground
|
|
2
|
+
// `opencode serve`: those of the locations it loaded, see ListingOptions.scope): the most recent root
|
|
2
3
|
// sessions (no subagent children, nothing archived) within the history window, plus sessions that must
|
|
3
4
|
// always be listed (open in a terminal, running, ...), each with the workspace it belongs to.
|
|
4
5
|
import path from "node:path"
|
|
@@ -21,14 +22,19 @@ export interface RawProject {
|
|
|
21
22
|
readonly name?: string
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** Per-request options (the OpenCode client's RequestOptions): the listing's cancellation. */
|
|
26
|
+
export interface ListingRequest {
|
|
27
|
+
readonly signal?: AbortSignal
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
/** The parts of the OpenCode client the listing uses. */
|
|
25
31
|
export interface ListingClient {
|
|
26
32
|
readonly session: {
|
|
27
|
-
list(input: { limit?: number; parentID?: null; cursor?: string }): Promise<{ data: readonly RawSession[]; cursor: { next?: string | null } }>
|
|
33
|
+
list(input: { limit?: number; parentID?: null; cursor?: string }, request?: ListingRequest): Promise<{ data: readonly RawSession[]; cursor: { next?: string | null } }>
|
|
28
34
|
/** One session by ID (for always-included sessions outside the window). */
|
|
29
|
-
get?(input: { sessionID: string }): Promise<RawSession>
|
|
35
|
+
get?(input: { sessionID: string }, request?: ListingRequest): Promise<RawSession>
|
|
30
36
|
}
|
|
31
|
-
readonly project: { list(): Promise<readonly RawProject[]> }
|
|
37
|
+
readonly project: { list(request?: ListingRequest): Promise<readonly RawProject[]> }
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface ListedSession {
|
|
@@ -51,16 +57,33 @@ export interface ListingOptions {
|
|
|
51
57
|
readonly now?: number
|
|
52
58
|
/** Session IDs listed even outside the window or over `max` (fetched one by one when missing). */
|
|
53
59
|
readonly include?: readonly string[]
|
|
60
|
+
/**
|
|
61
|
+
* Directories of the locations this process has loaded (a foreground `opencode serve`): only sessions of their
|
|
62
|
+
* projects, or in a directory under one of them, are listed. Absent: every session (the background service).
|
|
63
|
+
*/
|
|
64
|
+
readonly scope?: readonly string[]
|
|
65
|
+
/** Cancels the listing: passed to every request, and no request starts once it aborted (the listing rejects). */
|
|
66
|
+
readonly signal?: AbortSignal
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
/** Most always-included sessions fetched one by one per listing. */
|
|
57
70
|
export const INCLUDE_FETCH_MAX = 50
|
|
58
71
|
const DAY_MS = 86_400_000
|
|
59
72
|
|
|
73
|
+
/**
|
|
74
|
+
* OpenCode's global project (directories outside any repository with commits). Its canonical directory is "/" in
|
|
75
|
+
* older versions and, in 2.2.x, a real directory (the first such directory registered): never a workspace root.
|
|
76
|
+
*/
|
|
77
|
+
export const GLOBAL_PROJECT_ID = "global"
|
|
78
|
+
|
|
79
|
+
/** A project that is a real workspace root (not the global project). */
|
|
80
|
+
const isRealProject = (project: RawProject | undefined): project is RawProject =>
|
|
81
|
+
project !== undefined && project.id !== GLOBAL_PROJECT_ID && !!project.canonical && project.canonical !== "/"
|
|
82
|
+
|
|
60
83
|
/** Workspace root and name of a session: its project, else its directory minus its subpath. */
|
|
61
84
|
export function workspaceOf(session: RawSession, projects: ReadonlyMap<string, RawProject>): { project: string; projectName: string } {
|
|
62
85
|
const known = session.projectID ? projects.get(session.projectID) : undefined
|
|
63
|
-
if (known
|
|
86
|
+
if (isRealProject(known)) {
|
|
64
87
|
return { project: known.canonical, projectName: known.name || path.basename(known.canonical) }
|
|
65
88
|
}
|
|
66
89
|
const directory = session.location.directory
|
|
@@ -69,16 +92,46 @@ export function workspaceOf(session: RawSession, projects: ReadonlyMap<string, R
|
|
|
69
92
|
return { project: root, projectName: path.basename(root) || root }
|
|
70
93
|
}
|
|
71
94
|
|
|
95
|
+
const trimSlash = (dir: string) => dir.replace(/\/+$/, "") || "/"
|
|
96
|
+
/** `dir` is `root` or inside it. */
|
|
97
|
+
const within = (dir: string, root: string) => {
|
|
98
|
+
const r = trimSlash(root)
|
|
99
|
+
return dir === r || dir.startsWith(r === "/" ? "/" : `${r}/`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a session belongs to a loaded location: its directory is under one, or its project (a real one, not the
|
|
104
|
+
* global project) is the project of one (the project whose root holds the location, the deepest one).
|
|
105
|
+
*/
|
|
106
|
+
export function inScope(session: RawSession, scope: readonly string[], projects: ReadonlyMap<string, RawProject>): boolean {
|
|
107
|
+
const directory = trimSlash(session.location.directory)
|
|
108
|
+
const dirs = scope.map(trimSlash)
|
|
109
|
+
if (dirs.some((dir) => within(directory, dir))) return true
|
|
110
|
+
const own = session.projectID ? projects.get(session.projectID) : undefined
|
|
111
|
+
if (!isRealProject(own)) return false
|
|
112
|
+
const roots = [...projects.values()].filter(isRealProject).map((p) => p.canonical)
|
|
113
|
+
return dirs.some((dir) => {
|
|
114
|
+
const holder = roots.filter((root) => within(dir, root)).sort((a, b) => b.length - a.length)[0]
|
|
115
|
+
return holder !== undefined && trimSlash(holder) === trimSlash(own.canonical)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
72
119
|
export async function listServiceSessions(client: ListingClient, options: ListingOptions = {}): Promise<ListedSession[]> {
|
|
73
120
|
const max = options.max ?? 500
|
|
121
|
+
if (options.scope?.length === 0) return []
|
|
74
122
|
const pageSize = options.pageSize ?? 100
|
|
75
123
|
const maxPages = options.maxPages ?? 20
|
|
124
|
+
const { signal } = options
|
|
125
|
+
const request: ListingRequest = signal ? { signal } : {}
|
|
126
|
+
signal?.throwIfAborted()
|
|
76
127
|
const projects = new Map<string, RawProject>()
|
|
77
128
|
try {
|
|
78
|
-
for (const project of await client.project.list()) projects.set(project.id, project)
|
|
129
|
+
for (const project of await client.project.list(request)) projects.set(project.id, project)
|
|
79
130
|
} catch {
|
|
80
131
|
// workspaces fall back to directories
|
|
81
132
|
}
|
|
133
|
+
const scope = options.scope
|
|
134
|
+
const wanted = (s: RawSession) => scope === undefined || inScope(s, scope, projects)
|
|
82
135
|
const cutoff = options.days !== undefined ? (options.now ?? Date.now()) - options.days * DAY_MS : -Infinity
|
|
83
136
|
const listed = (s: RawSession): ListedSession => ({ id: s.id, title: s.title ?? "", directory: s.location.directory, updated: s.time.updated, ...workspaceOf(s, projects) })
|
|
84
137
|
const out: ListedSession[] = []
|
|
@@ -86,14 +139,15 @@ export async function listServiceSessions(client: ListingClient, options: Listin
|
|
|
86
139
|
// Sessions come newest first: the first one older than the window ends the listing.
|
|
87
140
|
let ended = false
|
|
88
141
|
for (let page = 0; page < maxPages && out.length < max && !ended; page++) {
|
|
89
|
-
|
|
142
|
+
signal?.throwIfAborted()
|
|
143
|
+
const result = await client.session.list({ limit: pageSize, parentID: null, ...(cursor ? { cursor } : {}) }, request)
|
|
90
144
|
for (const s of result.data) {
|
|
91
145
|
if (s.parentID || s.time.archived || out.length >= max) continue
|
|
92
146
|
if (s.time.updated < cutoff) {
|
|
93
147
|
ended = true
|
|
94
148
|
break
|
|
95
149
|
}
|
|
96
|
-
out.push(listed(s))
|
|
150
|
+
if (wanted(s)) out.push(listed(s))
|
|
97
151
|
}
|
|
98
152
|
cursor = result.cursor.next ?? undefined
|
|
99
153
|
if (!cursor || result.data.length === 0) break
|
|
@@ -101,8 +155,9 @@ export async function listServiceSessions(client: ListingClient, options: Listin
|
|
|
101
155
|
const have = new Set(out.map((s) => s.id))
|
|
102
156
|
const missing = [...new Set(options.include ?? [])].filter((id) => !have.has(id)).slice(0, INCLUDE_FETCH_MAX)
|
|
103
157
|
if (missing.length && client.session.get) {
|
|
104
|
-
|
|
105
|
-
|
|
158
|
+
signal?.throwIfAborted()
|
|
159
|
+
const fetched = await Promise.all(missing.map((sessionID) => client.session.get!({ sessionID }, request).catch(() => undefined)))
|
|
160
|
+
for (const s of fetched) if (s && s.id && !s.parentID && !s.time?.archived && s.location?.directory && wanted(s)) out.push(listed(s))
|
|
106
161
|
}
|
|
107
162
|
return out
|
|
108
163
|
}
|
package/src/spoke/localhub.ts
CHANGED
|
@@ -10,7 +10,9 @@ import path from "node:path"
|
|
|
10
10
|
import { fileURLToPath } from "node:url"
|
|
11
11
|
import { hubEnv } from "../env"
|
|
12
12
|
import { isLoopbackURL } from "../loopback"
|
|
13
|
-
import {
|
|
13
|
+
import { migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
|
|
14
|
+
|
|
15
|
+
const CONCIERGE_LOCK = "concierge-sessions.json"
|
|
14
16
|
|
|
15
17
|
export interface ChildLike {
|
|
16
18
|
readonly pid?: number
|
|
@@ -23,7 +25,7 @@ export type SpawnLike = (command: string, args: readonly string[], options: obje
|
|
|
23
25
|
|
|
24
26
|
export interface LocalHubOptions {
|
|
25
27
|
/**
|
|
26
|
-
* Where hub.pid and hub.log live (default $HUB_STATE_DIR or ~/.local/state/hub, migrated from
|
|
28
|
+
* Where hub.pid and hub.log live (default $HUB_STATE_DIR, else $XDG_STATE_HOME/hub or ~/.local/state/hub, migrated from
|
|
27
29
|
* ~/.local/state/opencode-hub or opencode-voice first so the hub does not find a fresh, empty state directory).
|
|
28
30
|
*/
|
|
29
31
|
readonly stateDir?: string
|
|
@@ -86,7 +88,15 @@ export function hubScript(root: string = repoRoot()): string {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
export function defaultStateDir(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): string {
|
|
89
|
-
return
|
|
91
|
+
return resolveHubStateDir(env, home).dir
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The Concierge lock file (written in the state dir) and the other default places' lock files (read too: fail closed). */
|
|
95
|
+
export function conciergeLockFiles(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): { file: string; read: string[] } {
|
|
96
|
+
const { candidates, source } = resolveHubStateDir(env, home)
|
|
97
|
+
const [dir, ...others] = candidates
|
|
98
|
+
// An explicit HUB_STATE_DIR is an isolated instance (a preview or a test): it never reads the user's default dirs.
|
|
99
|
+
return { file: path.join(dir!, CONCIERGE_LOCK), read: source === "env" ? [] : others.map((d) => path.join(d, CONCIERGE_LOCK)) }
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
/** The runtime for this process: probes the real executable, PATH and home directory. */
|
|
@@ -152,7 +162,7 @@ export async function ensureLocalHub(server: string, options: LocalHubOptions =
|
|
|
152
162
|
if (!(options.webBuilt ?? hasWebBuild)(root)) return { status: "failed", error: WEB_NOT_BUILT }
|
|
153
163
|
|
|
154
164
|
if (options.stateDir === undefined && !hubEnv(env).HUB_STATE_DIR) {
|
|
155
|
-
|
|
165
|
+
migrateStateDir(stateLegacyPaths(env, home), stateDir, { skip: ["hub.pid"], ...(options.log ? { log: options.log } : {}) })
|
|
156
166
|
}
|
|
157
167
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
158
168
|
|
package/src/spoke/locations.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface PermissionLocation {
|
|
|
10
10
|
get(input: { sessionID: string; requestID: string }): Promise<unknown>
|
|
11
11
|
/** `message`: a reject's reason (OpenCode's reply message). */
|
|
12
12
|
reply(input: { sessionID: string; requestID: string; decision: PermissionDecision; message?: string }): Promise<unknown>
|
|
13
|
+
vcs?: { diff(): Promise<ReadonlyArray<unknown>>; branch(): Promise<string | undefined> }
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -33,3 +34,42 @@ export async function replyPermission(
|
|
|
33
34
|
}
|
|
34
35
|
return "gone"
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
/** A directory's branch and working-copy changes (undefined: nothing could read them). */
|
|
39
|
+
export interface VcsReader {
|
|
40
|
+
diff(directory: string): Promise<ReadonlyArray<unknown> | undefined>
|
|
41
|
+
branch(directory: string): Promise<string | undefined>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads a directory's VCS state through the plugin instance whose location is that directory, else the own
|
|
46
|
+
* server's API (which honours the location), else not at all. OpenCode's plugin vcs API answers for the
|
|
47
|
+
* instance's own location whatever location is passed, so the first instance (the background service's is
|
|
48
|
+
* its $HOME) would report another directory's branch and changes. A read that takes longer than `timeoutMs` (a hung
|
|
49
|
+
* server) answers undefined, so the session summary goes on without branch and changes.
|
|
50
|
+
*/
|
|
51
|
+
/** How long a branch or changes read may take before the summary goes on without it (a hung server). */
|
|
52
|
+
export const VCS_READ_TIMEOUT_MS = 5_000
|
|
53
|
+
|
|
54
|
+
/** The value, or undefined once `ms` passed (the read is left to finish on its own). */
|
|
55
|
+
function within<T>(read: Promise<T | undefined>, ms: number): Promise<T | undefined> {
|
|
56
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
57
|
+
const late = new Promise<undefined>((resolve) => {
|
|
58
|
+
timer = setTimeout(() => resolve(undefined), ms)
|
|
59
|
+
})
|
|
60
|
+
return Promise.race([read.catch(() => undefined), late]).finally(() => clearTimeout(timer))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function vcsReader(locations: () => readonly PermissionLocation[], server: () => Promise<VcsReader | undefined>, timeoutMs: number = VCS_READ_TIMEOUT_MS): VcsReader {
|
|
64
|
+
const local = (directory: string) => locations().find((l) => l.directory === directory && l.vcs)?.vcs
|
|
65
|
+
return {
|
|
66
|
+
diff: async (directory) => {
|
|
67
|
+
const vcs = local(directory)
|
|
68
|
+
return within(vcs ? vcs.diff() : server().then((reader) => reader?.diff(directory)), timeoutMs)
|
|
69
|
+
},
|
|
70
|
+
branch: async (directory) => {
|
|
71
|
+
const vcs = local(directory)
|
|
72
|
+
return within(vcs ? vcs.branch() : server().then((reader) => reader?.branch(directory)), timeoutMs)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// The OpenCode profile application adapter (HWA-16; contract packages/protocol/src/profiles.ts, SDK rules in
|
|
2
|
+
// packages/spoke-sdk/src/profile-apply.ts). A hub profile becomes a session-scoped layer held in this process's
|
|
3
|
+
// memory only; nothing is written to disk and no OpenCode configuration (global or project) is touched:
|
|
4
|
+
//
|
|
5
|
+
// - Instructions: the plugin's `context` hook appends them to the session's system prompt after OpenCode's own
|
|
6
|
+
// parts (which carry AGENTS.md and the user's config), framed by profileInstructionsBlock: local instructions win.
|
|
7
|
+
// - Skills and commands: listed in that system part and served by the `hub_profile` tool (registered once per
|
|
8
|
+
// process like the `hub` tool; the context hook hides it from sessions without a profile, and it answers only
|
|
9
|
+
// the calling session's layer). OpenCode's skill/command registries are location-wide, so they are not used.
|
|
10
|
+
// Same-named local skills/commands win (partial). Commands that reference `{{secret:NAME}}` are skipped: a
|
|
11
|
+
// rendered command becomes a persisted prompt or tool result in OpenCode, and resolved secrets must never be
|
|
12
|
+
// written (renderAtInvocation false). The model preference is ignored (the user's model is never switched).
|
|
13
|
+
// - Next-turn boundary: an idle session's layer is replaced at once (its next turn reads it). For a busy session
|
|
14
|
+
// the new layer is staged, the running turn keeps the previous one, and the answer waits for the turn to end
|
|
15
|
+
// (Spoke.observe calls settle; a poll backs it up). The answer echoes exactly the version that became
|
|
16
|
+
// effective. A wait past boundaryWaitMs fails: the hub, which times out first, keeps the pin pending and sends it
|
|
17
|
+
// again on the spoke's next sessions frame, which is answered from the then-effective layer. A newer version
|
|
18
|
+
// staged before the boundary supersedes the older one, which is answered `rejected`.
|
|
19
|
+
// - Rollback: a failed or unsupported apply leaves the effective layer as it was; re-pinning an older version
|
|
20
|
+
// replaces the layer with it. There is no remove op in v1 (an empty version clears the layer).
|
|
21
|
+
// - Bounded: at most maxSessions layers (oldest dropped; the hub re-sends after a reconnect), notes bounded by the
|
|
22
|
+
// SDK. Lost on process restart: the new connection's sessions frame makes the hub send the pin again.
|
|
23
|
+
import { isObj } from "../../packages/protocol/src/wire.ts"
|
|
24
|
+
import { type ProfileApplyRequest, type ProfileApplyResult, type ProfileCommand, type ProfileSkill, profileTemplateParts } from "../../packages/protocol/src/index.ts"
|
|
25
|
+
import { boundedNotes, planProfileApplication, profileApplyResult, profileDigest, type ProfileLocalNames, renderProfileCommand } from "../../packages/spoke-sdk/src/profile-apply.ts"
|
|
26
|
+
|
|
27
|
+
export const PROFILE_TOOL = "hub_profile"
|
|
28
|
+
|
|
29
|
+
export const PROFILE_TOOL_DESCRIPTION =
|
|
30
|
+
"Use this session's hub agent profile: {skill: name} returns a profile skill's instructions; " +
|
|
31
|
+
"{command: name, params: {name: value}} returns a profile command's text with its parameters filled in, to follow as the user's request. " +
|
|
32
|
+
"Only the skills and commands listed in the hub profile section of your instructions exist."
|
|
33
|
+
|
|
34
|
+
export const PROFILE_TOOL_INPUT = {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
skill: { type: "string", description: "A profile skill's name." },
|
|
38
|
+
command: { type: "string", description: "A profile command's name." },
|
|
39
|
+
params: { type: "object", additionalProperties: { type: "string" }, description: "The command's parameters (each {{name}} in it; `arguments` for free text)." },
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
} as const
|
|
43
|
+
|
|
44
|
+
export interface OpenCodeProfilesOptions {
|
|
45
|
+
/** The session has a turn running (OpenCode busy, or a hub turn in progress). */
|
|
46
|
+
readonly isBusy: (sessionID: string) => boolean
|
|
47
|
+
/** The session's local skill and command names; absent: skills and commands are not applied. */
|
|
48
|
+
readonly localNames?: (sessionID: string) => Promise<ProfileLocalNames>
|
|
49
|
+
/** The hub_profile tool is available in this OpenCode. */
|
|
50
|
+
readonly tool: () => boolean
|
|
51
|
+
/** How long a busy session's answer waits for the boundary (default 120 s). */
|
|
52
|
+
readonly boundaryWaitMs?: number
|
|
53
|
+
/** How often a waiting answer checks the session (default 1 s). */
|
|
54
|
+
readonly pollMs?: number
|
|
55
|
+
/** Sessions with a layer kept (default 1000). */
|
|
56
|
+
readonly maxSessions?: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ProfileEffectiveLayer {
|
|
60
|
+
readonly profileID: string
|
|
61
|
+
readonly version: number
|
|
62
|
+
readonly digest: string
|
|
63
|
+
readonly status: ProfileApplyResult["status"]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Layer {
|
|
67
|
+
readonly result: ProfileApplyResult
|
|
68
|
+
readonly instructions?: string
|
|
69
|
+
readonly skills: readonly ProfileSkill[]
|
|
70
|
+
readonly commands: readonly ProfileCommand[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Waiter {
|
|
74
|
+
readonly resolve: (result: ProfileApplyResult) => void
|
|
75
|
+
readonly reject: (cause: Error) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Staged {
|
|
79
|
+
readonly layer: Layer
|
|
80
|
+
readonly waiters: Set<Waiter>
|
|
81
|
+
poll?: ReturnType<typeof setInterval>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A final answer, or one waiting for the boundary (its waiter is registered before staging returns). */
|
|
85
|
+
type Step = { readonly result: ProfileApplyResult } | { readonly pending: Promise<ProfileApplyResult> }
|
|
86
|
+
|
|
87
|
+
const sameVersion = (a: ProfileApplyResult, req: ProfileApplyRequest) => a.profileID === req.profileID && a.version === req.version && a.digest === req.digest
|
|
88
|
+
|
|
89
|
+
export class OpenCodeProfiles {
|
|
90
|
+
private readonly layers = new Map<string, Layer>()
|
|
91
|
+
private readonly staged = new Map<string, Staged>()
|
|
92
|
+
private readonly locks = new Map<string, Promise<void>>()
|
|
93
|
+
|
|
94
|
+
constructor(private readonly options: OpenCodeProfilesOptions) {}
|
|
95
|
+
|
|
96
|
+
/** The profileApply op: answers once the version is effective in the session (see the header). */
|
|
97
|
+
async handle(req: ProfileApplyRequest): Promise<ProfileApplyResult> {
|
|
98
|
+
if (profileDigest(req.content) !== req.digest) return profileApplyResult(req, "rejected", ["digest does not match the content; nothing applied"])
|
|
99
|
+
const step = await this.serialize(req.sessionID, () => this.stage(req))
|
|
100
|
+
return "pending" in step ? step.pending : step.result
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A turn boundary may have passed: makes a staged layer effective when the session is not busy. */
|
|
104
|
+
settle(sessionID: string): void {
|
|
105
|
+
const staged = this.staged.get(sessionID)
|
|
106
|
+
if (!staged || this.options.isBusy(sessionID)) return
|
|
107
|
+
this.dropStaged(sessionID, staged)
|
|
108
|
+
this.setLayer(sessionID, staged.layer)
|
|
109
|
+
for (const waiter of staged.waiters) waiter.resolve(staged.layer.result)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The version the session runs now, if any. */
|
|
113
|
+
effective(sessionID: string): ProfileEffectiveLayer | undefined {
|
|
114
|
+
const layer = this.layers.get(sessionID)
|
|
115
|
+
if (!layer) return undefined
|
|
116
|
+
const { profileID, version, digest, status } = layer.result
|
|
117
|
+
return { profileID, version, digest, status }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** System prompt parts for the session's next model request (after local instructions). */
|
|
121
|
+
systemParts(sessionID: string): string[] {
|
|
122
|
+
const layer = this.layers.get(sessionID)
|
|
123
|
+
if (!layer) return []
|
|
124
|
+
const parts: string[] = []
|
|
125
|
+
if (layer.instructions !== undefined) parts.push(layer.instructions)
|
|
126
|
+
if (layer.skills.length || layer.commands.length) parts.push(bindingsText(layer))
|
|
127
|
+
return parts
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Whether the session's model sees the hub_profile tool. */
|
|
131
|
+
toolVisible(sessionID: string): boolean {
|
|
132
|
+
const layer = this.layers.get(sessionID)
|
|
133
|
+
return layer !== undefined && (layer.skills.length > 0 || layer.commands.length > 0)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The hub_profile tool for the calling session: a skill's body or a command's rendered text, or why not. */
|
|
137
|
+
runTool(sessionID: string, input: unknown): string {
|
|
138
|
+
const value = isObj(input) ? input : {}
|
|
139
|
+
const skill = typeof value.skill === "string" ? value.skill : undefined
|
|
140
|
+
const command = typeof value.command === "string" ? value.command : undefined
|
|
141
|
+
if (skill === undefined && command === undefined) return "Give a skill or command name of this session's hub profile."
|
|
142
|
+
const layer = this.layers.get(sessionID)
|
|
143
|
+
if (!layer) return "This session has no hub profile."
|
|
144
|
+
if (skill !== undefined) {
|
|
145
|
+
const found = layer.skills.find((s) => s.name === skill)
|
|
146
|
+
return found ? `Skill ${found.name} (hub profile ${layer.result.profileID} v${layer.result.version}):\n\n${found.body}` : `This session has no hub profile skill named ${skill}.`
|
|
147
|
+
}
|
|
148
|
+
const found = layer.commands.find((c) => c.name === command)
|
|
149
|
+
if (!found) return `This session has no hub profile command named ${command}.`
|
|
150
|
+
const params = isObj(value.params) ? value.params : {}
|
|
151
|
+
if (!Object.values(params).every((v) => typeof v === "string")) return `Command ${found.name}: every parameter must be text.`
|
|
152
|
+
try {
|
|
153
|
+
// Commands with secret references are never in a layer, so no secret is ever resolved here.
|
|
154
|
+
return renderProfileCommand(found.template, params as Record<string, string>, () => undefined)
|
|
155
|
+
} catch (cause) {
|
|
156
|
+
return `Command ${found.name} could not be run: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The session ended: drops its layer and fails an answer still waiting for its boundary. */
|
|
161
|
+
forget(sessionID: string): void {
|
|
162
|
+
this.layers.delete(sessionID)
|
|
163
|
+
const staged = this.staged.get(sessionID)
|
|
164
|
+
if (!staged) return
|
|
165
|
+
this.dropStaged(sessionID, staged)
|
|
166
|
+
for (const waiter of staged.waiters) waiter.reject(new Error("the session ended before the profile took effect"))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------- internals ----------
|
|
170
|
+
|
|
171
|
+
private async stage(req: ProfileApplyRequest): Promise<Step> {
|
|
172
|
+
const { sessionID } = req
|
|
173
|
+
const staged = this.staged.get(sessionID)
|
|
174
|
+
if (staged && sameVersion(staged.layer.result, req)) return { pending: this.wait(sessionID, staged) }
|
|
175
|
+
const current = this.layers.get(sessionID)
|
|
176
|
+
if (current && sameVersion(current.result, req)) {
|
|
177
|
+
this.supersede(sessionID, req)
|
|
178
|
+
return { result: current.result }
|
|
179
|
+
}
|
|
180
|
+
let local: ProfileLocalNames | undefined
|
|
181
|
+
if (this.options.localNames) {
|
|
182
|
+
try {
|
|
183
|
+
local = await this.options.localNames(sessionID)
|
|
184
|
+
} catch {
|
|
185
|
+
return { result: profileApplyResult(req, "rejected", ["could not read the session's local skills and commands (they take precedence); nothing applied"]) }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const bindings = this.options.tool() && local !== undefined
|
|
189
|
+
const plan = planProfileApplication(req, {
|
|
190
|
+
capabilities: { instructions: true, skills: bindings, commands: bindings, model: false, live: true, renderAtInvocation: false },
|
|
191
|
+
...(local ? { local } : {}),
|
|
192
|
+
})
|
|
193
|
+
const result = profileApplyResult(req, plan.status, plan.notes)
|
|
194
|
+
if (plan.status === "unsupported") return { result }
|
|
195
|
+
const layer: Layer = { result, ...(plan.instructions !== undefined ? { instructions: plan.instructions } : {}), skills: plan.skills, commands: plan.commands }
|
|
196
|
+
this.supersede(sessionID, req)
|
|
197
|
+
if (!this.options.isBusy(sessionID)) {
|
|
198
|
+
this.setLayer(sessionID, layer)
|
|
199
|
+
return { result }
|
|
200
|
+
}
|
|
201
|
+
const next: Staged = { layer, waiters: new Set() }
|
|
202
|
+
this.staged.set(sessionID, next)
|
|
203
|
+
return { pending: this.wait(sessionID, next) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Waits for the staged layer's boundary, at most boundaryWaitMs. */
|
|
207
|
+
private wait(sessionID: string, staged: Staged): Promise<ProfileApplyResult> {
|
|
208
|
+
const pending = new Promise<ProfileApplyResult>((resolve, reject) => {
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
staged.waiters.delete(waiter)
|
|
211
|
+
if (!staged.waiters.size) this.stopPoll(staged)
|
|
212
|
+
const { profileID, version } = staged.layer.result
|
|
213
|
+
reject(new Error(`the session is still busy; profile ${profileID} v${version} is staged and takes effect at its next turn boundary`))
|
|
214
|
+
}, this.options.boundaryWaitMs ?? 120_000)
|
|
215
|
+
unref(timer)
|
|
216
|
+
const waiter: Waiter = {
|
|
217
|
+
resolve: (result) => (clearTimeout(timer), resolve(result)),
|
|
218
|
+
reject: (cause) => (clearTimeout(timer), reject(cause)),
|
|
219
|
+
}
|
|
220
|
+
staged.waiters.add(waiter)
|
|
221
|
+
if (!staged.poll) {
|
|
222
|
+
staged.poll = setInterval(() => this.settle(sessionID), this.options.pollMs ?? 1000)
|
|
223
|
+
unref(staged.poll)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
pending.catch(() => {}) // handled by handle(); the waiter may fail before it is awaited
|
|
227
|
+
return pending
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A staged layer for another version is answered rejected: `req` replaces it before it took effect. */
|
|
231
|
+
private supersede(sessionID: string, req: ProfileApplyRequest): void {
|
|
232
|
+
const staged = this.staged.get(sessionID)
|
|
233
|
+
if (!staged) return
|
|
234
|
+
this.dropStaged(sessionID, staged)
|
|
235
|
+
const { profileID, version, digest } = staged.layer.result
|
|
236
|
+
const note = `superseded by profile ${req.profileID} v${req.version} before it took effect at the next turn boundary`
|
|
237
|
+
const result: ProfileApplyResult = { sessionID, profileID, version, digest, status: "rejected", notes: boundedNotes([note]) }
|
|
238
|
+
for (const waiter of staged.waiters) waiter.resolve(result)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private dropStaged(sessionID: string, staged: Staged): void {
|
|
242
|
+
if (this.staged.get(sessionID) === staged) this.staged.delete(sessionID)
|
|
243
|
+
this.stopPoll(staged)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private stopPoll(staged: Staged): void {
|
|
247
|
+
if (staged.poll) clearInterval(staged.poll)
|
|
248
|
+
staged.poll = undefined
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private setLayer(sessionID: string, layer: Layer): void {
|
|
252
|
+
this.layers.delete(sessionID)
|
|
253
|
+
this.layers.set(sessionID, layer)
|
|
254
|
+
while (this.layers.size > (this.options.maxSessions ?? 1000)) this.layers.delete(this.layers.keys().next().value!)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Runs one session's staging steps one at a time (waiting for a boundary is outside). */
|
|
258
|
+
private serialize<T>(sessionID: string, run: () => Promise<T>): Promise<T> {
|
|
259
|
+
const previous = this.locks.get(sessionID) ?? Promise.resolve()
|
|
260
|
+
const next = previous.then(run)
|
|
261
|
+
const tail = next.then(
|
|
262
|
+
() => undefined,
|
|
263
|
+
() => undefined,
|
|
264
|
+
)
|
|
265
|
+
this.locks.set(sessionID, tail)
|
|
266
|
+
void tail.then(() => {
|
|
267
|
+
if (this.locks.get(sessionID) === tail) this.locks.delete(sessionID)
|
|
268
|
+
})
|
|
269
|
+
return next
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The system part that lists the session's profile skills and commands (names, descriptions, parameters). */
|
|
274
|
+
function bindingsText(layer: Layer): string {
|
|
275
|
+
const { profileID, version } = layer.result
|
|
276
|
+
const lines = [
|
|
277
|
+
`Hub profile ${profileID} v${version} adds skills and commands to this session only, through the ${PROFILE_TOOL} tool. Local skills and commands of the same name take precedence and are not listed.`,
|
|
278
|
+
]
|
|
279
|
+
if (layer.skills.length) {
|
|
280
|
+
lines.push("", `Profile skills (load one with ${PROFILE_TOOL} {"skill": name} before following it):`)
|
|
281
|
+
for (const skill of layer.skills) lines.push(`- ${skill.name}: ${oneLine(skill.description)}`)
|
|
282
|
+
}
|
|
283
|
+
if (layer.commands.length) {
|
|
284
|
+
lines.push("", `Profile commands (when the user asks for /name or the command by name, call ${PROFILE_TOOL} {"command": name, "params": {...}} and follow the text it returns):`)
|
|
285
|
+
for (const command of layer.commands) {
|
|
286
|
+
const parts = profileTemplateParts(command.template)
|
|
287
|
+
const params = typeof parts === "string" ? [] : [...new Set(parts.flatMap((p) => ("param" in p ? [p.param] : [])))]
|
|
288
|
+
lines.push(`- ${command.name}${params.length ? ` (params: ${params.join(", ")})` : ""}: ${oneLine(command.description)}`)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return lines.join("\n")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const oneLine = (text: string) => text.replace(/\s+/g, " ").trim()
|
|
295
|
+
|
|
296
|
+
function unref(timer: ReturnType<typeof setTimeout>): void {
|
|
297
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
298
|
+
}
|