@huynle/hub 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +326 -1
- package/README.md +85 -16
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +723 -281
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- 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 +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +9 -4
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +61 -3
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +90 -7
- package/packages/protocol/src/launch.ts +14 -0
- package/packages/protocol/src/wire.ts +1 -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/client.ts +3 -1
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +70 -12
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +111 -28
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +39 -10
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +60 -17
- 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 +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +11 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +10 -6
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +87 -35
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +49 -4
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -3
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +24 -2
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +63 -30
- package/src/migrate.ts +109 -3
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +26 -12
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +120 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +183 -33
- package/src/spoke/spoke.ts +129 -41
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +17 -1
- package/src/status.ts +2 -1
- package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
- package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
- package/web/dist/assets/Library-BXbJvTUS.js +2 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
- package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
- package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/live-OgQWPrSY.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- package/web/dist/assets/ui-C9Igxzjm.js +0 -1
package/src/spoke/locations.ts
CHANGED
|
@@ -10,6 +10,8 @@ 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> }
|
|
14
|
+
names?(): Promise<LocalNames>
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -33,3 +35,121 @@ export async function replyPermission(
|
|
|
33
35
|
}
|
|
34
36
|
return "gone"
|
|
35
37
|
}
|
|
38
|
+
|
|
39
|
+
/** A directory's branch and working-copy changes (undefined: nothing could read them). */
|
|
40
|
+
export interface VcsReader {
|
|
41
|
+
diff(directory: string): Promise<ReadonlyArray<unknown> | undefined>
|
|
42
|
+
branch(directory: string): Promise<string | undefined>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reads a directory's VCS state through the plugin instance whose location is that directory, else the own
|
|
47
|
+
* server's API (which honours the location), else not at all. OpenCode's plugin vcs API answers for the
|
|
48
|
+
* instance's own location whatever location is passed, so the first instance (the background service's is
|
|
49
|
+
* its $HOME) would report another directory's branch and changes. A read that takes longer than `timeoutMs` (a hung
|
|
50
|
+
* server) answers undefined, so the session summary goes on without branch and changes.
|
|
51
|
+
*/
|
|
52
|
+
/** How long a branch or changes read may take before the summary goes on without it (a hung server). */
|
|
53
|
+
export const VCS_READ_TIMEOUT_MS = 5_000
|
|
54
|
+
|
|
55
|
+
/** The value, or undefined once `ms` passed (the read is left to finish on its own). */
|
|
56
|
+
function within<T>(read: Promise<T | undefined>, ms: number): Promise<T | undefined> {
|
|
57
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
58
|
+
const late = new Promise<undefined>((resolve) => {
|
|
59
|
+
timer = setTimeout(() => resolve(undefined), ms)
|
|
60
|
+
})
|
|
61
|
+
return Promise.race([read.catch(() => undefined), late]).finally(() => clearTimeout(timer))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function vcsReader(locations: () => readonly PermissionLocation[], server: () => Promise<VcsReader | undefined>, timeoutMs: number = VCS_READ_TIMEOUT_MS): VcsReader {
|
|
65
|
+
const local = (directory: string) => locations().find((l) => l.directory === directory && l.vcs)?.vcs
|
|
66
|
+
return {
|
|
67
|
+
diff: async (directory) => {
|
|
68
|
+
const vcs = local(directory)
|
|
69
|
+
return within(vcs ? vcs.diff() : server().then((reader) => reader?.diff(directory)), timeoutMs)
|
|
70
|
+
},
|
|
71
|
+
branch: async (directory) => {
|
|
72
|
+
const vcs = local(directory)
|
|
73
|
+
return within(vcs ? vcs.branch() : server().then((reader) => reader?.branch(directory)), timeoutMs)
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/** A directory's local skill and command names (a hub profile's of the same name give way to them). */
|
|
80
|
+
export interface LocalNames {
|
|
81
|
+
readonly skills: readonly string[]
|
|
82
|
+
readonly commands: readonly string[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A directory's local names; undefined: nothing could read that directory's. */
|
|
86
|
+
export type NamesSource = (directory: string) => Promise<LocalNames | undefined>
|
|
87
|
+
|
|
88
|
+
/** How long two reads of a directory's names must agree before they count (OpenCode loads them one source at a time). */
|
|
89
|
+
export const NAMES_SETTLE_MS = 500
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reads a directory's local skill and command names like vcsReader reads its VCS state: through the plugin
|
|
93
|
+
* instance whose location is that directory, else the own server's API (which honours the location), else not
|
|
94
|
+
* at all (undefined). OpenCode's plugin skill.list / command.list answer for the instance's own location whatever
|
|
95
|
+
* location is passed, so the first instance would report another directory's names. A location OpenCode is still
|
|
96
|
+
* loading lists nothing, then its built-ins, then its own commands and skills one source at a time: the names
|
|
97
|
+
* count once they list a command (OpenCode always has built-in ones) and two reads `settleMs` apart agree. A read
|
|
98
|
+
* that fails, or does not settle within `timeoutMs`, is unknown.
|
|
99
|
+
*/
|
|
100
|
+
export function namesReader(
|
|
101
|
+
locations: () => readonly PermissionLocation[],
|
|
102
|
+
server: () => Promise<NamesSource | undefined>,
|
|
103
|
+
timeoutMs: number = VCS_READ_TIMEOUT_MS,
|
|
104
|
+
settleMs: number = NAMES_SETTLE_MS,
|
|
105
|
+
): NamesSource {
|
|
106
|
+
return (directory) => {
|
|
107
|
+
const local = locations().find((l) => l.directory === directory && l.names)
|
|
108
|
+
let done = false
|
|
109
|
+
const settled = async (read: NamesSource): Promise<LocalNames | undefined> => {
|
|
110
|
+
let previous = await read(directory)
|
|
111
|
+
while (!done) {
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs))
|
|
113
|
+
const next = await read(directory)
|
|
114
|
+
if (next === undefined) return undefined
|
|
115
|
+
if (next.commands.length && previous && sameNames(previous, next)) return next
|
|
116
|
+
previous = next
|
|
117
|
+
}
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
const reading = local?.names ? settled(() => local.names!()) : server().then((read) => (read ? settled(read) : undefined))
|
|
121
|
+
return within(reading, timeoutMs).finally(() => (done = true))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sameNames(a: LocalNames, b: LocalNames): boolean {
|
|
126
|
+
const same = (x: readonly string[], y: readonly string[]) => x.length === y.length && x.every((name, i) => name === y[i])
|
|
127
|
+
return same(a.skills, b.skills) && same(a.commands, b.commands)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The `name` of each item of a plugin or HTTP API list (bare, or wrapped as { location, data }). */
|
|
131
|
+
export function listedNames(value: unknown): string[] {
|
|
132
|
+
const v = value as any
|
|
133
|
+
const list = Array.isArray(v) ? v : Array.isArray(v?.data) ? v.data : []
|
|
134
|
+
return list.flatMap((item: any) => (typeof item?.name === "string" ? [item.name] : []))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ListApi {
|
|
138
|
+
list(input: { location: { directory: string } }): Promise<unknown>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A directory's names over the own server's API (skill.list / command.list honour the location there); an answer for another directory is unknown. */
|
|
142
|
+
export function serverNames(client: { skill: ListApi; command: ListApi }): NamesSource {
|
|
143
|
+
return async (directory) => {
|
|
144
|
+
const location = { directory }
|
|
145
|
+
const [skills, commands] = await Promise.all([client.skill.list({ location }), client.command.list({ location })])
|
|
146
|
+
if ([skills, commands].some((answer) => answeredFor(answer) !== undefined && answeredFor(answer) !== directory)) return undefined
|
|
147
|
+
return { skills: listedNames(skills), commands: listedNames(commands) }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The directory a wrapped { location, data } answer is for (undefined: a bare answer). */
|
|
152
|
+
function answeredFor(answer: unknown): string | undefined {
|
|
153
|
+
const directory = (answer as any)?.location?.directory
|
|
154
|
+
return typeof directory === "string" ? directory : undefined
|
|
155
|
+
}
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// - Skills and commands: listed in that system part and served by the `hub_profile` tool (registered once per
|
|
8
8
|
// process like the `hub` tool; the context hook hides it from sessions without a profile, and it answers only
|
|
9
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)
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Same-named local skills/commands win (partial); their names are read in the session's own folder, and when they
|
|
11
|
+
// cannot be read the profile's skills and commands are not offered (partial with a note; the instructions apply).
|
|
12
|
+
// Commands that reference `{{secret:NAME}}` are skipped: a rendered command becomes a persisted prompt or tool
|
|
13
|
+
// result in OpenCode, and resolved secrets must never be written (renderAtInvocation false). The model preference
|
|
14
|
+
// is ignored (the user's model is never switched).
|
|
13
15
|
// - Next-turn boundary: an idle session's layer is replaced at once (its next turn reads it). For a busy session
|
|
14
16
|
// the new layer is staged, the running turn keeps the previous one, and the answer waits for the turn to end
|
|
15
17
|
// (Spoke.observe calls settle; a poll backs it up). The answer echoes exactly the version that became
|
|
@@ -41,11 +43,19 @@ export const PROFILE_TOOL_INPUT = {
|
|
|
41
43
|
additionalProperties: false,
|
|
42
44
|
} as const
|
|
43
45
|
|
|
46
|
+
/** The note of an apply whose session's local skill and command names could not be read. */
|
|
47
|
+
const UNKNOWN_NAMES_NOTE = "skills and commands not offered: the session's local skills and commands could not be read"
|
|
48
|
+
/** The planner's notes for skills/commands this adapter did not offer (replaced by UNKNOWN_NAMES_NOTE). */
|
|
49
|
+
const ADAPTER_UNSUPPORTED = /^(skills|commands) not supported by this harness adapter$/
|
|
50
|
+
|
|
44
51
|
export interface OpenCodeProfilesOptions {
|
|
45
52
|
/** The session has a turn running (OpenCode busy, or a hub turn in progress). */
|
|
46
53
|
readonly isBusy: (sessionID: string) => boolean
|
|
47
|
-
/**
|
|
48
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The session's local skill and command names, read in its own folder; absent: skills and commands are not
|
|
56
|
+
* applied. Undefined or a failure: unknown, so the instructions apply and the skills and commands are not offered.
|
|
57
|
+
*/
|
|
58
|
+
readonly localNames?: (sessionID: string) => Promise<ProfileLocalNames | undefined>
|
|
49
59
|
/** The hub_profile tool is available in this OpenCode. */
|
|
50
60
|
readonly tool: () => boolean
|
|
51
61
|
/** How long a busy session's answer waits for the boundary (default 120 s). */
|
|
@@ -177,20 +187,20 @@ export class OpenCodeProfiles {
|
|
|
177
187
|
this.supersede(sessionID, req)
|
|
178
188
|
return { result: current.result }
|
|
179
189
|
}
|
|
190
|
+
// Local names are read only when there is something they could shadow. Unknown names (unreadable in the
|
|
191
|
+
// session's own folder) never reject the apply: the skills and commands are just not offered.
|
|
192
|
+
const tool = this.options.tool()
|
|
193
|
+
const wantsBindings = tool && (req.content.skills.length > 0 || req.content.commands.length > 0)
|
|
180
194
|
let local: ProfileLocalNames | undefined
|
|
181
|
-
if (this.options.localNames)
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
195
|
+
if (wantsBindings && this.options.localNames) local = await this.options.localNames(sessionID).catch(() => undefined)
|
|
196
|
+
const unknown = wantsBindings && this.options.localNames !== undefined && local === undefined
|
|
197
|
+
const bindings = tool && local !== undefined
|
|
189
198
|
const plan = planProfileApplication(req, {
|
|
190
199
|
capabilities: { instructions: true, skills: bindings, commands: bindings, model: false, live: true, renderAtInvocation: false },
|
|
191
200
|
...(local ? { local } : {}),
|
|
192
201
|
})
|
|
193
|
-
const
|
|
202
|
+
const notes = unknown ? [UNKNOWN_NAMES_NOTE, ...plan.notes.filter((note) => !ADAPTER_UNSUPPORTED.test(note))] : plan.notes
|
|
203
|
+
const result = profileApplyResult(req, plan.status, notes)
|
|
194
204
|
if (plan.status === "unsupported") return { result }
|
|
195
205
|
const layer: Layer = { result, ...(plan.instructions !== undefined ? { instructions: plan.instructions } : {}), skills: plan.skills, commands: plan.commands }
|
|
196
206
|
this.supersede(sessionID, req)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// CON-04 session contexts (spoke feature "sessionContext"; contract packages/protocol/src/concierge.ts). The hub pushes
|
|
2
2
|
// a session's per-turn context; the spoke keeps it in this process's memory (like profile layers, profile-apply.ts):
|
|
3
3
|
//
|
|
4
|
-
// - parts: appended by the plugin's context hook after the profile parts
|
|
5
|
-
//
|
|
4
|
+
// - parts: instructions then notes are appended by the plugin's context hook after the profile parts; the board goes
|
|
5
|
+
// after the conversation as a system message (V-01: it changes between turns, and OpenCode sends tools, system,
|
|
6
|
+
// then messages, so a board in the system prompt made every push re-write the cached conversation; see
|
|
7
|
+
// applySessionContext). The whole set replaces the previous one.
|
|
8
|
+
// - The kept tools are put in name order (the request lists them in the hook's record order).
|
|
6
9
|
// - tools.allow: the exact tool names the session keeps; the hook deletes every other tool on every turn (unknown
|
|
7
10
|
// future tools included: fail closed). tools.deny: globs of tool names deleted as well.
|
|
8
11
|
// - Revisions: an older revision than the effective one is refused ("stale"); the same one is answered again.
|
|
@@ -16,17 +19,78 @@
|
|
|
16
19
|
// the plugin's state dir (lockFile), so a restarted OpenCode locks the session before the hub reconnects. The agent
|
|
17
20
|
// a locked session was last seen on is reported to the hub (SessionInfo.agent), which suspends a Concierge found on
|
|
18
21
|
// another agent.
|
|
19
|
-
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
|
|
22
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
20
23
|
import path from "node:path"
|
|
21
|
-
import { CONCIERGE_TOOLS,
|
|
24
|
+
import { CONCIERGE_TOOLS, type SessionContextKey, type SessionContextRequest, type SessionContextResult, toolDenied } from "../../packages/protocol/src/concierge.ts"
|
|
22
25
|
|
|
23
26
|
interface Context {
|
|
24
27
|
readonly revision: number
|
|
28
|
+
/** The stable parts (instructions, notes): the end of the system prompt. */
|
|
25
29
|
readonly parts: readonly string[]
|
|
30
|
+
/** The changing parts (board): after the conversation. */
|
|
31
|
+
readonly tail: readonly string[]
|
|
26
32
|
readonly deny: readonly string[]
|
|
27
33
|
readonly allow?: ReadonlySet<string>
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/** Parts at the end of the system prompt (stable between turns), in this order. */
|
|
37
|
+
const SYSTEM_KEYS: readonly SessionContextKey[] = ["instructions", "notes"]
|
|
38
|
+
/** Parts after the conversation (they change between turns). */
|
|
39
|
+
const TAIL_KEYS: readonly SessionContextKey[] = ["board"]
|
|
40
|
+
const EPHEMERAL = { type: "ephemeral" } as const
|
|
41
|
+
|
|
42
|
+
interface HookPart {
|
|
43
|
+
type: string
|
|
44
|
+
text?: string
|
|
45
|
+
cache?: unknown
|
|
46
|
+
}
|
|
47
|
+
interface HookMessage {
|
|
48
|
+
role: string
|
|
49
|
+
content: HookPart[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The tools in name order (OpenCode sends them in the record's order: the request's prefix must not move). */
|
|
53
|
+
export function sortTools(tools: Record<string, unknown>): void {
|
|
54
|
+
const entries = Object.entries(tools).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
55
|
+
for (const [name] of entries) delete tools[name]
|
|
56
|
+
for (const [name, tool] of entries) tools[name] = tool
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** An OpenCode effort message (a system message carrying only the reasoning effort). */
|
|
60
|
+
const isEffort = (m: HookMessage) => m.role === "system" && m.content.length === 1 && m.content[0]?.type === "effort"
|
|
61
|
+
|
|
62
|
+
/** The index of the last item matching (-1: none). */
|
|
63
|
+
const lastIndex = <T>(items: readonly T[], match: (item: T) => boolean) => {
|
|
64
|
+
for (let i = items.length - 1; i >= 0; i--) if (match(items[i]!)) return i
|
|
65
|
+
return -1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A cache hint on the message's last text part (else its last part that is not compaction or effort). */
|
|
69
|
+
function hint(message: HookMessage): void {
|
|
70
|
+
const text = lastIndex(message.content, (p) => p.type === "text")
|
|
71
|
+
const index = text >= 0 ? text : lastIndex(message.content, (p) => p.type !== "compaction" && p.type !== "effort")
|
|
72
|
+
const part = message.content[index]
|
|
73
|
+
if (part && part.cache === undefined) message.content[index] = { ...part, cache: EPHEMERAL }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* (V-01) Adds a session context to a context hook's request so the provider's prompt cache survives a board change.
|
|
78
|
+
* OpenCode sends tools, the system parts, then the messages; it puts cache breakpoints on the last tool, the first and
|
|
79
|
+
* last system part and the last message (4 at most). The stable parts end the system prompt (the last one carries a
|
|
80
|
+
* hint); the board follows the conversation as one system message (with a hint), and the conversation's last message
|
|
81
|
+
* gets a hint, so the next turn reads everything up to it from the cache whatever the board says. Without a board
|
|
82
|
+
* nothing is added after the conversation; a request without messages gets the board in its system prompt.
|
|
83
|
+
*/
|
|
84
|
+
export function applySessionContext(event: { system: HookPart[]; messages?: HookMessage[] }, system: readonly string[], tail: readonly string[]): void {
|
|
85
|
+
const messages = Array.isArray(event.messages) ? event.messages : undefined
|
|
86
|
+
const ending = messages ? system : [...system, ...tail]
|
|
87
|
+
ending.forEach((text, i) => event.system.push(i === ending.length - 1 ? { type: "text", text, cache: EPHEMERAL } : { type: "text", text }))
|
|
88
|
+
if (!messages || !tail.length) return
|
|
89
|
+
const last = messages[lastIndex(messages, (m) => !isEffort(m))]
|
|
90
|
+
if (last) hint(last)
|
|
91
|
+
messages.push({ role: "system", content: [{ type: "text", text: tail.join("\n\n"), cache: EPHEMERAL }] })
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
/** Locked sessions kept at most (oldest dropped; the hub keeps 20 Concierge sessions per user). */
|
|
31
95
|
const LOCKS_MAX = 1000
|
|
32
96
|
const LOCKED_TOOLS: ReadonlySet<string> = new Set(CONCIERGE_TOOLS)
|
|
@@ -43,6 +107,62 @@ export interface SessionContextsOptions {
|
|
|
43
107
|
readonly maxSessions?: number
|
|
44
108
|
/** Where locked session IDs persist (the plugin's state dir); in memory only without it. */
|
|
45
109
|
readonly lockFile?: string
|
|
110
|
+
/** (FX-07) How long a write waits for the other processes' exclusive lock before it writes anyway. */
|
|
111
|
+
readonly lockWaitMs?: number
|
|
112
|
+
/**
|
|
113
|
+
* Other lock files read (never written): the other default state dir places (FX-08: a lock written before an
|
|
114
|
+
* upgrade moved the state dir still locks the session; fail closed).
|
|
115
|
+
*/
|
|
116
|
+
readonly readLockFiles?: readonly string[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** (FX-07) A `<lockFile>.lock` older than this belongs to a process that died while writing: it is broken. */
|
|
120
|
+
const WRITE_LOCK_STALE_MS = 10_000
|
|
121
|
+
const WRITE_LOCK_WAIT_MS = 2_000
|
|
122
|
+
const pause = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* (FX-07) Runs `write` holding `<file>.lock` (created exclusively), so the read-merge-write of every OpenCode process
|
|
126
|
+
* on the machine is serialized and none drops another's locks. A stale lock (its holder died) is broken; after
|
|
127
|
+
* `waitMs` the write runs anyway (the lock still holds in this process, and the write merges what it reads).
|
|
128
|
+
*/
|
|
129
|
+
function underWriteLock(file: string, waitMs: number, write: () => void): void {
|
|
130
|
+
const lock = `${file}.lock`
|
|
131
|
+
const deadline = Date.now() + waitMs
|
|
132
|
+
let held = false
|
|
133
|
+
for (;;) {
|
|
134
|
+
try {
|
|
135
|
+
closeSync(openSync(lock, "wx", 0o600))
|
|
136
|
+
held = true
|
|
137
|
+
break
|
|
138
|
+
} catch (cause) {
|
|
139
|
+
if ((cause as NodeJS.ErrnoException).code !== "EEXIST") break
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
if (Date.now() - statSync(lock).mtimeMs > WRITE_LOCK_STALE_MS) {
|
|
143
|
+
unlinkSync(lock)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
continue // released meanwhile
|
|
148
|
+
}
|
|
149
|
+
if (Date.now() >= deadline) {
|
|
150
|
+
console.error("[hub] the Concierge session lock file stayed busy: writing without it")
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
pause(2 + Math.floor(Math.random() * 5))
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
write()
|
|
157
|
+
} finally {
|
|
158
|
+
if (held) {
|
|
159
|
+
try {
|
|
160
|
+
unlinkSync(lock)
|
|
161
|
+
} catch {
|
|
162
|
+
// already gone
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
46
166
|
}
|
|
47
167
|
|
|
48
168
|
export class SessionContexts {
|
|
@@ -60,46 +180,62 @@ export class SessionContexts {
|
|
|
60
180
|
this.locks = new Set(this.load())
|
|
61
181
|
}
|
|
62
182
|
|
|
183
|
+
/** Every lock file read: the written one first, then the read-only ones. */
|
|
184
|
+
private files(): string[] {
|
|
185
|
+
return [...(this.options.lockFile ? [this.options.lockFile] : []), ...(this.options.readLockFiles ?? [])]
|
|
186
|
+
}
|
|
187
|
+
|
|
63
188
|
/** Adds the locks another process wrote since the last read (locks are never removed). */
|
|
64
189
|
private refresh(): void {
|
|
65
|
-
const
|
|
66
|
-
if (!
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
190
|
+
const files = this.files()
|
|
191
|
+
if (!files.length) return
|
|
192
|
+
const stamp = files
|
|
193
|
+
.map((file) => {
|
|
194
|
+
try {
|
|
195
|
+
return stampOf(file)
|
|
196
|
+
} catch {
|
|
197
|
+
return "-"
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
.join("|")
|
|
73
201
|
if (stamp === this.lockStamp) return
|
|
74
202
|
this.lockStamp = stamp
|
|
75
203
|
for (const id of this.load()) this.locks.add(id)
|
|
76
204
|
}
|
|
77
205
|
|
|
206
|
+
/** The union of the lock files' sessions (a missing or corrupt file adds none). */
|
|
78
207
|
private load(): string[] {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
208
|
+
return this.files().flatMap((file) => {
|
|
209
|
+
try {
|
|
210
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as { v?: unknown; sessions?: unknown }
|
|
211
|
+
return raw.v === 1 && Array.isArray(raw.sessions) ? raw.sessions.filter((id): id is string => typeof id === "string" && id.length > 0 && id.length <= 200) : []
|
|
212
|
+
} catch {
|
|
213
|
+
return []
|
|
214
|
+
}
|
|
215
|
+
})
|
|
86
216
|
}
|
|
87
217
|
|
|
88
218
|
private lock(sessionID: string): void {
|
|
89
219
|
this.refresh()
|
|
90
220
|
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
221
|
this.locks.add(sessionID)
|
|
94
|
-
while (this.locks.size > LOCKS_MAX) this.locks.delete(this.locks.values().next().value as string)
|
|
95
222
|
const file = this.options.lockFile
|
|
96
|
-
if (!file) return
|
|
223
|
+
if (!file) return this.boundLocks()
|
|
97
224
|
try {
|
|
98
225
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
226
|
+
underWriteLock(file, this.options.lockWaitMs ?? WRITE_LOCK_WAIT_MS, () => {
|
|
227
|
+
// Merge with what other processes wrote (read under the write lock): never drop their locks.
|
|
228
|
+
for (const id of this.load()) this.locks.add(id)
|
|
229
|
+
// The newest lock last, so the bound never drops it.
|
|
230
|
+
this.locks.delete(sessionID)
|
|
231
|
+
this.locks.add(sessionID)
|
|
232
|
+
this.boundLocks()
|
|
233
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
234
|
+
writeFileSync(tmp, `${JSON.stringify({ v: 1, sessions: [...this.locks] })}\n`, { mode: 0o600 })
|
|
235
|
+
renameSync(tmp, file)
|
|
236
|
+
})
|
|
237
|
+
this.lockStamp = ""
|
|
238
|
+
this.refresh()
|
|
103
239
|
} catch (cause) {
|
|
104
240
|
// The lock still holds in this process; the hub re-sends the context after a restart.
|
|
105
241
|
console.error(`[hub] could not persist the Concierge session lock: ${(cause as Error).message}`)
|
|
@@ -119,22 +255,32 @@ export class SessionContexts {
|
|
|
119
255
|
this.cleared.delete(req.sessionID)
|
|
120
256
|
if (req.lockAgent) this.lock(req.sessionID)
|
|
121
257
|
const byKey = new Map(req.parts.map((p) => [p.key, p.text]))
|
|
122
|
-
const
|
|
258
|
+
const pick = (keys: readonly SessionContextKey[]) => keys.flatMap((key) => (byKey.has(key) ? [byKey.get(key)!] : []))
|
|
123
259
|
const allow = req.tools?.allow
|
|
124
|
-
this.contexts.set(req.sessionID, { revision: req.revision, parts, deny: [...(req.tools?.deny ?? [])], ...(allow ? { allow: new Set(allow) } : {}) })
|
|
260
|
+
this.contexts.set(req.sessionID, { revision: req.revision, parts: pick(SYSTEM_KEYS), tail: pick(TAIL_KEYS), deny: [...(req.tools?.deny ?? [])], ...(allow ? { allow: new Set(allow) } : {}) })
|
|
125
261
|
this.bound(this.contexts)
|
|
126
262
|
return { revision: req.revision }
|
|
127
263
|
}
|
|
128
264
|
|
|
265
|
+
/** At most LOCKS_MAX locks (the oldest dropped). */
|
|
266
|
+
private boundLocks(): void {
|
|
267
|
+
while (this.locks.size > LOCKS_MAX) this.locks.delete(this.locks.values().next().value as string)
|
|
268
|
+
}
|
|
269
|
+
|
|
129
270
|
private bound(map: Map<string, unknown>): void {
|
|
130
271
|
while (map.size > this.maxSessions) map.delete(map.keys().next().value as string)
|
|
131
272
|
}
|
|
132
273
|
|
|
133
|
-
/** The session's parts for its system prompt (none without a context). */
|
|
274
|
+
/** The session's stable parts for the end of its system prompt: instructions, notes (none without a context). */
|
|
134
275
|
systemParts(sessionID: string): string[] {
|
|
135
276
|
return [...(this.contexts.get(sessionID)?.parts ?? [])]
|
|
136
277
|
}
|
|
137
278
|
|
|
279
|
+
/** The session's changing parts, sent after its conversation: the board (none without a context). */
|
|
280
|
+
tailParts(sessionID: string): string[] {
|
|
281
|
+
return [...(this.contexts.get(sessionID)?.tail ?? [])]
|
|
282
|
+
}
|
|
283
|
+
|
|
138
284
|
/** A Concierge session: only CONCIERGE_TOOLS, its agent and model kept, no conversation mode. */
|
|
139
285
|
locked(sessionID: string): boolean {
|
|
140
286
|
if (!this.locks.has(sessionID)) this.refresh()
|
|
@@ -157,6 +303,7 @@ export class SessionContexts {
|
|
|
157
303
|
filterTools(sessionID: string, tools: Record<string, unknown>): void {
|
|
158
304
|
if (!this.contexts.has(sessionID) && !this.locked(sessionID)) return
|
|
159
305
|
for (const name of Object.keys(tools)) if (this.denied(sessionID, name)) delete tools[name]
|
|
306
|
+
sortTools(tools)
|
|
160
307
|
}
|
|
161
308
|
|
|
162
309
|
/** Records the agent a locked session runs on; true when it changed (a new report is due). */
|
|
@@ -166,11 +313,14 @@ export class SessionContexts {
|
|
|
166
313
|
return true
|
|
167
314
|
}
|
|
168
315
|
|
|
169
|
-
/**
|
|
170
|
-
|
|
316
|
+
/**
|
|
317
|
+
* What the sessions frame says about the session (locked ones only): locked (FX-07: from the very first frame, even
|
|
318
|
+
* after a restart, before its agent is read or the hub re-sends a context), its agent and context revision.
|
|
319
|
+
*/
|
|
320
|
+
report(sessionID: string): { locked?: true; agent?: string; contextRevision?: number } {
|
|
171
321
|
if (!this.locks.has(sessionID)) return {}
|
|
172
322
|
const agent = this.agents.get(sessionID)
|
|
173
323
|
const revision = this.contexts.get(sessionID)?.revision
|
|
174
|
-
return { ...(agent ? { agent } : {}), ...(revision !== undefined ? { contextRevision: revision } : {}) }
|
|
324
|
+
return { locked: true, ...(agent ? { agent } : {}), ...(revision !== undefined ? { contextRevision: revision } : {}) }
|
|
175
325
|
}
|
|
176
326
|
}
|