@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,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
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// CON-04 session contexts (spoke feature "sessionContext"; contract packages/protocol/src/concierge.ts). The hub pushes
|
|
2
|
+
// a session's per-turn context; the spoke keeps it in this process's memory (like profile layers, profile-apply.ts):
|
|
3
|
+
//
|
|
4
|
+
// - parts: appended by the plugin's context hook after the profile parts, in key order (instructions, board,
|
|
5
|
+
// notes). The whole set replaces the previous one.
|
|
6
|
+
// - tools.allow: the exact tool names the session keeps; the hook deletes every other tool on every turn (unknown
|
|
7
|
+
// future tools included: fail closed). tools.deny: globs of tool names deleted as well.
|
|
8
|
+
// - Revisions: an older revision than the effective one is refused ("stale"); the same one is answered again.
|
|
9
|
+
// `clear` drops the parts. At most maxSessions contexts are kept (the least recently set dropped); lost on a
|
|
10
|
+
// process restart, after which the hub sends them again (the session reports no contextRevision).
|
|
11
|
+
//
|
|
12
|
+
// LOCKED sessions (the Concierge's, R1 of the CON-04 verify): a context with lockAgent locks its session for good.
|
|
13
|
+
// A locked session, WHATEVER agent it runs on and with or without a context (cleared, or not re-sent yet after a
|
|
14
|
+
// restart), keeps only CONCIERGE_TOOLS (intersected with its context's allow-list), never gets conversation mode,
|
|
15
|
+
// voice style or orchestrator parts. Locks survive `clear`, the context bound and a restart: they are persisted in
|
|
16
|
+
// the plugin's state dir (lockFile), so a restarted OpenCode locks the session before the hub reconnects. The agent
|
|
17
|
+
// a locked session was last seen on is reported to the hub (SessionInfo.agent), which suspends a Concierge found on
|
|
18
|
+
// another agent.
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
|
|
20
|
+
import path from "node:path"
|
|
21
|
+
import { CONCIERGE_TOOLS, SESSION_CONTEXT_KEYS, type SessionContextRequest, type SessionContextResult, toolDenied } from "../../packages/protocol/src/concierge.ts"
|
|
22
|
+
|
|
23
|
+
interface Context {
|
|
24
|
+
readonly revision: number
|
|
25
|
+
readonly parts: readonly string[]
|
|
26
|
+
readonly deny: readonly string[]
|
|
27
|
+
readonly allow?: ReadonlySet<string>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Locked sessions kept at most (oldest dropped; the hub keeps 20 Concierge sessions per user). */
|
|
31
|
+
const LOCKS_MAX = 1000
|
|
32
|
+
const LOCKED_TOOLS: ReadonlySet<string> = new Set(CONCIERGE_TOOLS)
|
|
33
|
+
/** OpenCode's internal agents (title generation, compaction, summaries): never the session's own agent. */
|
|
34
|
+
/** A file version: atomic writes rename a new inode in, so inode + mtime + size changes with every write. */
|
|
35
|
+
const stampOf = (file: string) => {
|
|
36
|
+
const st = statSync(file)
|
|
37
|
+
return `${st.ino}:${st.mtimeMs}:${st.size}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const INTERNAL_AGENTS: ReadonlySet<string> = new Set(["title", "compaction", "summary"])
|
|
41
|
+
|
|
42
|
+
export interface SessionContextsOptions {
|
|
43
|
+
readonly maxSessions?: number
|
|
44
|
+
/** Where locked session IDs persist (the plugin's state dir); in memory only without it. */
|
|
45
|
+
readonly lockFile?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class SessionContexts {
|
|
49
|
+
private readonly contexts = new Map<string, Context>()
|
|
50
|
+
/** The last cleared revision per session, so a stale set cannot resurrect a cleared context. */
|
|
51
|
+
private readonly cleared = new Map<string, number>()
|
|
52
|
+
private readonly locks: Set<string>
|
|
53
|
+
/** The lock file's mtime when last read (another process may add locks: re-read on change). */
|
|
54
|
+
private lockStamp = ""
|
|
55
|
+
private readonly agents = new Map<string, string>()
|
|
56
|
+
private readonly maxSessions: number
|
|
57
|
+
|
|
58
|
+
constructor(private readonly options: SessionContextsOptions = {}) {
|
|
59
|
+
this.maxSessions = options.maxSessions ?? 200
|
|
60
|
+
this.locks = new Set(this.load())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Adds the locks another process wrote since the last read (locks are never removed). */
|
|
64
|
+
private refresh(): void {
|
|
65
|
+
const file = this.options.lockFile
|
|
66
|
+
if (!file) return
|
|
67
|
+
let stamp: string
|
|
68
|
+
try {
|
|
69
|
+
stamp = stampOf(file)
|
|
70
|
+
} catch {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (stamp === this.lockStamp) return
|
|
74
|
+
this.lockStamp = stamp
|
|
75
|
+
for (const id of this.load()) this.locks.add(id)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private load(): string[] {
|
|
79
|
+
if (!this.options.lockFile) return []
|
|
80
|
+
try {
|
|
81
|
+
const raw = JSON.parse(readFileSync(this.options.lockFile, "utf8")) as { v?: unknown; sessions?: unknown }
|
|
82
|
+
return raw.v === 1 && Array.isArray(raw.sessions) ? raw.sessions.filter((id): id is string => typeof id === "string" && id.length > 0 && id.length <= 200) : []
|
|
83
|
+
} catch {
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private lock(sessionID: string): void {
|
|
89
|
+
this.refresh()
|
|
90
|
+
if (this.locks.has(sessionID)) return
|
|
91
|
+
// Merge with what other processes wrote: never drop their locks.
|
|
92
|
+
for (const id of this.load()) this.locks.add(id)
|
|
93
|
+
this.locks.add(sessionID)
|
|
94
|
+
while (this.locks.size > LOCKS_MAX) this.locks.delete(this.locks.values().next().value as string)
|
|
95
|
+
const file = this.options.lockFile
|
|
96
|
+
if (!file) return
|
|
97
|
+
try {
|
|
98
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
99
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
100
|
+
writeFileSync(tmp, `${JSON.stringify({ v: 1, sessions: [...this.locks] })}\n`, { mode: 0o600 })
|
|
101
|
+
renameSync(tmp, file)
|
|
102
|
+
this.lockStamp = stampOf(file)
|
|
103
|
+
} catch (cause) {
|
|
104
|
+
// The lock still holds in this process; the hub re-sends the context after a restart.
|
|
105
|
+
console.error(`[hub] could not persist the Concierge session lock: ${(cause as Error).message}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Applies a hub request (validated by the protocol parser); throws "stale: …" for an older revision. */
|
|
110
|
+
handle(req: SessionContextRequest): SessionContextResult {
|
|
111
|
+
const effective = this.contexts.get(req.sessionID)?.revision ?? this.cleared.get(req.sessionID)
|
|
112
|
+
if (effective !== undefined && req.revision < effective) throw new Error(`stale: revision ${req.revision} is older than the effective ${effective}`)
|
|
113
|
+
this.contexts.delete(req.sessionID)
|
|
114
|
+
if (req.clear) {
|
|
115
|
+
this.cleared.set(req.sessionID, req.revision)
|
|
116
|
+
this.bound(this.cleared)
|
|
117
|
+
return { revision: req.revision }
|
|
118
|
+
}
|
|
119
|
+
this.cleared.delete(req.sessionID)
|
|
120
|
+
if (req.lockAgent) this.lock(req.sessionID)
|
|
121
|
+
const byKey = new Map(req.parts.map((p) => [p.key, p.text]))
|
|
122
|
+
const parts = SESSION_CONTEXT_KEYS.flatMap((key) => (byKey.has(key) ? [byKey.get(key)!] : []))
|
|
123
|
+
const allow = req.tools?.allow
|
|
124
|
+
this.contexts.set(req.sessionID, { revision: req.revision, parts, deny: [...(req.tools?.deny ?? [])], ...(allow ? { allow: new Set(allow) } : {}) })
|
|
125
|
+
this.bound(this.contexts)
|
|
126
|
+
return { revision: req.revision }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private bound(map: Map<string, unknown>): void {
|
|
130
|
+
while (map.size > this.maxSessions) map.delete(map.keys().next().value as string)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The session's parts for its system prompt (none without a context). */
|
|
134
|
+
systemParts(sessionID: string): string[] {
|
|
135
|
+
return [...(this.contexts.get(sessionID)?.parts ?? [])]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A Concierge session: only CONCIERGE_TOOLS, its agent and model kept, no conversation mode. */
|
|
139
|
+
locked(sessionID: string): boolean {
|
|
140
|
+
if (!this.locks.has(sessionID)) this.refresh()
|
|
141
|
+
return this.locks.has(sessionID)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lockedSessions(): string[] {
|
|
145
|
+
return [...this.locks]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Whether the hub took this tool from the session (a locked session's non-Concierge tool, off its allow-list, or denied). */
|
|
149
|
+
denied(sessionID: string, tool: string): boolean {
|
|
150
|
+
if (this.locks.has(sessionID) && !LOCKED_TOOLS.has(tool)) return true
|
|
151
|
+
const context = this.contexts.get(sessionID)
|
|
152
|
+
if (!context) return false
|
|
153
|
+
return (context.allow !== undefined && !context.allow.has(tool)) || toolDenied(tool, context.deny)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Deletes the tools the hub took from the session from a context event's tools. */
|
|
157
|
+
filterTools(sessionID: string, tools: Record<string, unknown>): void {
|
|
158
|
+
if (!this.contexts.has(sessionID) && !this.locked(sessionID)) return
|
|
159
|
+
for (const name of Object.keys(tools)) if (this.denied(sessionID, name)) delete tools[name]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Records the agent a locked session runs on; true when it changed (a new report is due). */
|
|
163
|
+
seenAgent(sessionID: string, agent: string | undefined): boolean {
|
|
164
|
+
if (!this.locks.has(sessionID) || !agent || INTERNAL_AGENTS.has(agent) || this.agents.get(sessionID) === agent) return false
|
|
165
|
+
this.agents.set(sessionID, agent)
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** What the sessions frame says about the session (locked ones only): its agent and context revision. */
|
|
170
|
+
report(sessionID: string): { agent?: string; contextRevision?: number } {
|
|
171
|
+
if (!this.locks.has(sessionID)) return {}
|
|
172
|
+
const agent = this.agents.get(sessionID)
|
|
173
|
+
const revision = this.contexts.get(sessionID)?.revision
|
|
174
|
+
return { ...(agent ? { agent } : {}), ...(revision !== undefined ? { contextRevision: revision } : {}) }
|
|
175
|
+
}
|
|
176
|
+
}
|