@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.
Files changed (133) hide show
  1. package/CHANGELOG.md +326 -1
  2. package/README.md +85 -16
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/paths.ts +3 -3
  6. package/packages/cli/dist/hub-cli.js +723 -281
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  9. package/packages/cli/src/artifacts-mcp.ts +8 -7
  10. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  11. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  12. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  13. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  14. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  15. package/packages/cli/src/cli.ts +3 -3
  16. package/packages/cli/src/http.ts +1 -0
  17. package/packages/cli/src/launches.ts +9 -3
  18. package/packages/cli/src/login.ts +2 -2
  19. package/packages/cli/src/mcp.ts +9 -4
  20. package/packages/cli/src/messaging.ts +4 -2
  21. package/packages/cli/src/profiles-mcp.ts +301 -0
  22. package/packages/cli/src/setup/commands.ts +20 -2
  23. package/packages/cli/src/spoke-services.ts +1 -1
  24. package/packages/cli/src/team-intake-mcp.ts +8 -7
  25. package/packages/cli/src/teams-mcp.ts +61 -3
  26. package/packages/cli/src/tool-args.ts +6 -0
  27. package/packages/cli/src/watchlist.ts +5 -4
  28. package/packages/codex/package.json +1 -1
  29. package/packages/codex/src/paths.ts +3 -3
  30. package/packages/node/package.json +1 -1
  31. package/packages/node/src/config.ts +10 -2
  32. package/packages/node/src/opencode-warm.ts +27 -8
  33. package/packages/node/src/run.ts +3 -2
  34. package/packages/preset/package.json +1 -1
  35. package/packages/protocol/package.json +1 -1
  36. package/packages/protocol/src/concierge.ts +4 -1
  37. package/packages/protocol/src/embedded-media.ts +59 -0
  38. package/packages/protocol/src/index.ts +90 -7
  39. package/packages/protocol/src/launch.ts +14 -0
  40. package/packages/protocol/src/wire.ts +1 -0
  41. package/packages/runtime-openshell/package.json +1 -1
  42. package/packages/runtime-sdk/package.json +1 -1
  43. package/packages/sdk/package.json +1 -1
  44. package/packages/sdk/src/client.ts +3 -1
  45. package/packages/sdk/src/errors.ts +1 -0
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/types.ts +22 -2
  48. package/packages/sdk/src/wake.ts +20 -0
  49. package/packages/spoke-sdk/package.json +1 -1
  50. package/packages/spoke-sdk/src/connection.ts +13 -1
  51. package/src/central/agent/acting-routes.ts +3 -2
  52. package/src/central/agent/acting.ts +70 -12
  53. package/src/central/agent/audit.ts +1 -1
  54. package/src/central/agent/decisions.ts +58 -2
  55. package/src/central/agent/delivery.ts +111 -28
  56. package/src/central/agent/errors.ts +2 -0
  57. package/src/central/agent/escalation.ts +39 -10
  58. package/src/central/agent/events.ts +7 -1
  59. package/src/central/agent/launch-engine.ts +173 -30
  60. package/src/central/agent/launch-routes.ts +20 -9
  61. package/src/central/agent/launch-spec.ts +60 -17
  62. package/src/central/agent/launch-store.ts +55 -2
  63. package/src/central/agent/message-limits.ts +89 -11
  64. package/src/central/agent/message-routes.ts +8 -3
  65. package/src/central/agent/messages.ts +3 -2
  66. package/src/central/agent/placement.ts +93 -18
  67. package/src/central/agent/read-routes.ts +3 -0
  68. package/src/central/agent/routes.ts +11 -2
  69. package/src/central/agent/webhooks.ts +17 -2
  70. package/src/central/artifacts/jobs-routes.ts +4 -1
  71. package/src/central/concierge/board-routes.ts +13 -2
  72. package/src/central/concierge/context.ts +3 -1
  73. package/src/central/concierge/inbound.ts +4 -7
  74. package/src/central/concierge/prompt.ts +10 -6
  75. package/src/central/concierge/proposals.ts +8 -2
  76. package/src/central/concierge/routes.ts +62 -2
  77. package/src/central/concierge/session-routes.ts +62 -6
  78. package/src/central/concierge/supervisor.ts +34 -7
  79. package/src/central/concierge/watchlist-routes.ts +4 -2
  80. package/src/central/config.ts +21 -5
  81. package/src/central/http.ts +87 -35
  82. package/src/central/main.ts +1 -1
  83. package/src/central/registry.ts +49 -4
  84. package/src/central/revert-windows.ts +66 -0
  85. package/src/central/spoke-ws.ts +6 -3
  86. package/src/central/teams/intake.ts +20 -6
  87. package/src/central/teams/staffing.ts +24 -2
  88. package/src/central/workspaces/sidechats-routes.ts +1 -1
  89. package/src/index.ts +63 -30
  90. package/src/migrate.ts +109 -3
  91. package/src/spoke/deliver.ts +6 -0
  92. package/src/spoke/env-inject.ts +8 -1
  93. package/src/spoke/fork-revert.ts +94 -9
  94. package/src/spoke/hub-tool.ts +26 -12
  95. package/src/spoke/launchers.ts +6 -4
  96. package/src/spoke/listing.ts +18 -6
  97. package/src/spoke/localhub.ts +14 -4
  98. package/src/spoke/locations.ts +120 -0
  99. package/src/spoke/profile-apply.ts +24 -14
  100. package/src/spoke/session-context.ts +183 -33
  101. package/src/spoke/spoke.ts +129 -41
  102. package/src/spoke/timeline.ts +30 -3
  103. package/src/spoke/tracker.ts +17 -1
  104. package/src/status.ts +2 -1
  105. package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
  106. package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
  107. package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
  108. package/web/dist/assets/Library-BXbJvTUS.js +2 -0
  109. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
  110. package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
  111. package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
  112. package/web/dist/assets/index-BabrNDB3.css +1 -0
  113. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  114. package/web/dist/assets/live-OgQWPrSY.js +5 -0
  115. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  116. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
  117. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
  118. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
  119. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  120. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  121. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  122. package/web/dist/index.html +4 -4
  123. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  124. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  125. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  126. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  127. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  128. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  129. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  130. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  131. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  132. package/web/dist/assets/live-YyTjE47g.js +0 -5
  133. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -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). 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).
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
- /** The session's local skill and command names; absent: skills and commands are not applied. */
48
- readonly localNames?: (sessionID: string) => Promise<ProfileLocalNames>
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
- 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
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 result = profileApplyResult(req, plan.status, plan.notes)
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, in key order (instructions, board,
5
- // notes). The whole set replaces the previous one.
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, SESSION_CONTEXT_KEYS, type SessionContextRequest, type SessionContextResult, toolDenied } from "../../packages/protocol/src/concierge.ts"
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 file = this.options.lockFile
66
- if (!file) return
67
- let stamp: string
68
- try {
69
- stamp = stampOf(file)
70
- } catch {
71
- return
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
- 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
- }
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
- 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)
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 parts = SESSION_CONTEXT_KEYS.flatMap((key) => (byKey.has(key) ? [byKey.get(key)!] : []))
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
- /** What the sessions frame says about the session (locked ones only): its agent and context revision. */
170
- report(sessionID: string): { agent?: string; contextRevision?: number } {
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
  }