@huynle/hub 0.8.1 → 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 (67) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +87 -52
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  8. package/packages/cli/src/artifacts-mcp.ts +8 -7
  9. package/packages/cli/src/mcp.ts +4 -3
  10. package/packages/cli/src/profiles-mcp.ts +3 -3
  11. package/packages/cli/src/team-intake-mcp.ts +8 -7
  12. package/packages/cli/src/teams-mcp.ts +2 -1
  13. package/packages/cli/src/tool-args.ts +6 -0
  14. package/packages/cli/src/watchlist.ts +5 -4
  15. package/packages/codex/package.json +1 -1
  16. package/packages/node/package.json +1 -1
  17. package/packages/preset/package.json +1 -1
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/protocol/src/concierge.ts +4 -1
  20. package/packages/protocol/src/embedded-media.ts +59 -0
  21. package/packages/protocol/src/index.ts +83 -6
  22. package/packages/protocol/src/wire.ts +1 -0
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/client.ts +3 -1
  27. package/packages/spoke-sdk/package.json +1 -1
  28. package/packages/spoke-sdk/src/connection.ts +13 -1
  29. package/src/central/agent/acting-routes.ts +3 -2
  30. package/src/central/agent/acting.ts +67 -12
  31. package/src/central/agent/delivery.ts +12 -3
  32. package/src/central/agent/escalation.ts +30 -9
  33. package/src/central/agent/launch-spec.ts +38 -19
  34. package/src/central/agent/routes.ts +7 -0
  35. package/src/central/concierge/context.ts +3 -1
  36. package/src/central/concierge/prompt.ts +7 -4
  37. package/src/central/http.ts +50 -21
  38. package/src/central/registry.ts +23 -3
  39. package/src/central/revert-windows.ts +66 -0
  40. package/src/central/spoke-ws.ts +6 -3
  41. package/src/central/teams/staffing.ts +2 -2
  42. package/src/index.ts +26 -14
  43. package/src/spoke/deliver.ts +6 -0
  44. package/src/spoke/fork-revert.ts +94 -9
  45. package/src/spoke/hub-tool.ts +2 -2
  46. package/src/spoke/locations.ts +80 -0
  47. package/src/spoke/profile-apply.ts +24 -14
  48. package/src/spoke/session-context.ts +76 -6
  49. package/src/spoke/spoke.ts +116 -36
  50. package/src/spoke/timeline.ts +30 -3
  51. package/src/spoke/tracker.ts +17 -1
  52. package/src/status.ts +2 -1
  53. package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-R0ZTpHca.js} +1 -1
  54. package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-C9KrBna8.js} +1 -1
  55. package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-sP2mZccC.js} +1 -1
  56. package/web/dist/assets/{Library-LXHsP5Fw.js → Library-BXbJvTUS.js} +2 -2
  57. package/web/dist/assets/{ProfilesDestination-DhVVcAep.js → ProfilesDestination-D7XyLTJ7.js} +1 -1
  58. package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-Dgxt8UI3.js} +1 -1
  59. package/web/dist/assets/{Watchtower-DxaJYQp_.js → Watchtower-DJHx6cWw.js} +2 -2
  60. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  61. package/web/dist/assets/{live-BDq0AABg.js → live-OgQWPrSY.js} +1 -1
  62. package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-DjEipyto.js} +1 -1
  63. package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-B648jKGO.js} +1 -1
  64. package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-CNLyboIj.js} +1 -1
  65. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  66. package/web/dist/index.html +1 -1
  67. package/web/dist/assets/index-lIOfSYck.js +0 -34
@@ -11,6 +11,7 @@ export interface PermissionLocation {
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
13
  vcs?: { diff(): Promise<ReadonlyArray<unknown>>; branch(): Promise<string | undefined> }
14
+ names?(): Promise<LocalNames>
14
15
  }
15
16
 
16
17
  /**
@@ -73,3 +74,82 @@ export function vcsReader(locations: () => readonly PermissionLocation[], server
73
74
  },
74
75
  }
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.
@@ -18,15 +21,76 @@
18
21
  // another agent.
19
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)
@@ -191,9 +255,9 @@ export class SessionContexts {
191
255
  this.cleared.delete(req.sessionID)
192
256
  if (req.lockAgent) this.lock(req.sessionID)
193
257
  const byKey = new Map(req.parts.map((p) => [p.key, p.text]))
194
- 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)!] : []))
195
259
  const allow = req.tools?.allow
196
- 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) } : {}) })
197
261
  this.bound(this.contexts)
198
262
  return { revision: req.revision }
199
263
  }
@@ -207,11 +271,16 @@ export class SessionContexts {
207
271
  while (map.size > this.maxSessions) map.delete(map.keys().next().value as string)
208
272
  }
209
273
 
210
- /** 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). */
211
275
  systemParts(sessionID: string): string[] {
212
276
  return [...(this.contexts.get(sessionID)?.parts ?? [])]
213
277
  }
214
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
+
215
284
  /** A Concierge session: only CONCIERGE_TOOLS, its agent and model kept, no conversation mode. */
216
285
  locked(sessionID: string): boolean {
217
286
  if (!this.locks.has(sessionID)) this.refresh()
@@ -234,6 +303,7 @@ export class SessionContexts {
234
303
  filterTools(sessionID: string, tools: Record<string, unknown>): void {
235
304
  if (!this.contexts.has(sessionID) && !this.locked(sessionID)) return
236
305
  for (const name of Object.keys(tools)) if (this.denied(sessionID, name)) delete tools[name]
306
+ sortTools(tools)
237
307
  }
238
308
 
239
309
  /** Records the agent a locked session runs on; true when it changed (a new report is due). */
@@ -33,12 +33,12 @@ import {
33
33
  type TurnResult,
34
34
  } from "../protocol"
35
35
  import { extractReply } from "../reply"
36
- import { busyError, NOT_A_USER_MESSAGE, REVERT_BUSY, revertPreview, revertSummary, userContent, userIndex, visibleMessages } from "./fork-revert"
36
+ import { copiesHubIdentity, forkIdentityNote, notAUserMessage, opError, REVERT_BUSY, REVERT_END_BUSY, revertPreview, revertSummary, userContent, userIndex, visibleMessages, withStagedAt } from "./fork-revert"
37
37
  import { TurnTracker } from "../turns"
38
38
  import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineItems } from "./timeline"
39
- import { SpokeConnection, type Backoff, type ConnectionState } from "./client"
39
+ import { CodedError, SpokeConnection, type Backoff, type ConnectionState } from "./client"
40
40
  import { PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
41
- import { DeliveryLedger, deferredWhileBusy, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY } from "./deliver"
41
+ import { DeliveryLedger, deferredWhileBusy, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
42
42
  import { pollEnrollment, startEnrollment } from "./enroll"
43
43
  import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
44
44
  import type { LocalHubResult } from "./localhub"
@@ -157,8 +157,11 @@ export interface SpokeHost {
157
157
  vcsDiff?(directory: string): Promise<ReadonlyArray<unknown> | undefined>
158
158
  /** Current branch of a directory. */
159
159
  vcsBranch?(directory: string): Promise<string | undefined>
160
- /** (HWA-16) A location's local skill and command names (they win over a hub profile's); absent: none applied. */
161
- localNames?(directory: string): Promise<{ skills: readonly string[]; commands: readonly string[] }>
160
+ /**
161
+ * (HWA-16) A location's local skill and command names (they win over a hub profile's), read in that folder;
162
+ * undefined: unknown (the profile's skills and commands are not offered). Absent: none applied.
163
+ */
164
+ localNames?(directory: string): Promise<{ skills: readonly string[]; commands: readonly string[] } | undefined>
162
165
  /** (HWA-16) The plugin registered the hub_profile tool (profile skills and commands need it). */
163
166
  readonly profileTool?: boolean
164
167
  /**
@@ -198,6 +201,8 @@ export interface SpokeOptions {
198
201
  readonly pluginVersion: string
199
202
  readonly turnTimeoutMs?: number
200
203
  readonly snapshotDebounceMs?: number
204
+ /** How long a fork waits for its hub identity note to go in (default FORK_NOTE_TIMEOUT_MS). */
205
+ readonly forkNoteTimeoutMs?: number
201
206
  readonly snapshotIntervalMs?: number
202
207
  readonly seedIntervalMs?: number
203
208
  /** Delay before re-listing the service's sessions after a session event (default 2 s). */
@@ -298,6 +303,8 @@ const FALLBACK_AGENT = "build"
298
303
  const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
299
304
  const ENROLL_RETRY_MS = 500
300
305
  const NOT_SHARED = "This session is not shared with voice"
306
+ /** How long a fork waits for its hub identity note (a synthetic message) before it answers without it. */
307
+ export const FORK_NOTE_TIMEOUT_MS = 5_000
301
308
  const CANNOT_FORK = "This OpenCode process cannot fork sessions (its server cannot be reached)"
302
309
  const CANNOT_REVERT = "This OpenCode process cannot revert sessions (its server cannot be reached)"
303
310
  /** OpenCode's revert events (session.revert.staged, .cleared, .committed). */
@@ -340,6 +347,8 @@ export class Spoke {
340
347
  private readonly turns = new TurnTracker()
341
348
  /** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
342
349
  private readonly timelines = new TimelineCache()
350
+ /** Per session: a counter of revert events (a staged event's late read must not undo a later one). */
351
+ private readonly revertSeq = new Map<string, number>()
343
352
  private readonly voiceTurns = new Map<string, number>()
344
353
  private readonly running = new Set<string>()
345
354
  /** Hub messages applied (by messageID), so a retry is not admitted again. */
@@ -1058,7 +1067,8 @@ export class Spoke {
1058
1067
  case "form":
1059
1068
  return this.form("cancel" in req ? { sessionID: req.sessionID, formID: req.formID, cancel: true } : { sessionID: req.sessionID, formID: req.formID, answer: req.answer })
1060
1069
  case "context":
1061
- return { messages: contextMessages(await this.host.context(req.sessionID)) }
1070
+ // A staged revert's hidden messages are not part of the conversation any more.
1071
+ return { messages: contextMessages(await this.shownMessages(req.sessionID)) }
1062
1072
  case "models": {
1063
1073
  const raw = await this.host.listModels?.() ?? []
1064
1074
  return {
@@ -1152,27 +1162,45 @@ export class Spoke {
1152
1162
  // Only a session this process reports (mode "always" exposes any ID; a profile needs a real session).
1153
1163
  if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
1154
1164
  return this.profiles.handle(req)
1165
+ // Fork and revert: OpenCode's busy and not-found failures answer coded refusals (Res.code).
1155
1166
  case "fork":
1156
- return this.fork(req.sessionID, req.messageID)
1157
- case "revertPreview": {
1158
- const messages = await this.host.context(req.sessionID)
1159
- const index = userIndex(messages, req.messageID)
1160
- if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1161
- return revertPreview(messages, index) satisfies ResultFor["revertPreview"]
1162
- }
1167
+ return this.fork(req.sessionID, req.messageID).catch((cause) => {
1168
+ throw opError(cause, REVERT_BUSY)
1169
+ })
1170
+ case "revertPreview":
1171
+ return this.revertPreview(req.sessionID, req.messageID).catch((cause) => {
1172
+ throw opError(cause, REVERT_BUSY)
1173
+ })
1163
1174
  case "revert":
1164
- return this.revert(req.sessionID, req.messageID, req.files)
1175
+ return this.revert(req.sessionID, req.messageID, req.files).catch((cause) => {
1176
+ throw opError(cause, REVERT_BUSY)
1177
+ })
1165
1178
  case "revertRestore":
1166
- if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1167
- await this.host.revertClear!(req.sessionID)
1168
- return this.revertDone(req.sessionID)
1169
1179
  case "revertCommit":
1170
- if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1171
- await this.host.revertCommit!(req.sessionID)
1172
- return this.revertDone(req.sessionID)
1180
+ return this.revertEnd(req.sessionID, req.op).catch((cause) => {
1181
+ throw opError(cause, REVERT_END_BUSY)
1182
+ })
1173
1183
  }
1174
1184
  }
1175
1185
 
1186
+ private async revertPreview(sessionID: string, messageID: string): Promise<ResultFor["revertPreview"]> {
1187
+ const messages = await this.host.context(sessionID)
1188
+ const index = userIndex(messages, messageID)
1189
+ if (index < 0) throw notAUserMessage()
1190
+ return revertPreview(messages, index)
1191
+ }
1192
+
1193
+ /** Restores (revert.clear) or commits a staged revert; refused while the session runs (OpenCode refuses it too). */
1194
+ private async revertEnd(sessionID: string, op: "revertRestore" | "revertCommit"): Promise<Record<string, never>> {
1195
+ if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1196
+ if (op === "revertRestore") await this.host.revertClear!(sessionID)
1197
+ else {
1198
+ await this.host.revertCommit!(sessionID)
1199
+ this.recordCommit(sessionID)
1200
+ }
1201
+ return this.revertDone(sessionID)
1202
+ }
1203
+
1176
1204
  private canRevert(): boolean {
1177
1205
  return this.host.revertStage !== undefined && this.host.revertClear !== undefined && this.host.revertCommit !== undefined
1178
1206
  }
@@ -1186,7 +1214,7 @@ export class Spoke {
1186
1214
  if (!fork) throw new Error(CANNOT_FORK)
1187
1215
  const messages = await this.host.context(sessionID)
1188
1216
  const index = userIndex(messages, messageID)
1189
- if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1217
+ if (index < 0) throw notAUserMessage()
1190
1218
  const session = await fork({ sessionID, before: messageID })
1191
1219
  if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode did not report the forked session")
1192
1220
  const title = typeof session.title === "string" ? session.title : ""
@@ -1203,9 +1231,34 @@ export class Spoke {
1203
1231
  })
1204
1232
  if (this.config.mode === "session") this.setSession(session.id, true)
1205
1233
  this.sendSnapshot()
1234
+ if (copiesHubIdentity(messages, index)) await this.forkNote(session.id, forkIdentityNote(sessionID, source?.title))
1206
1235
  return { sessionID: session.id, ...(title ? { title } : {}), ...userContent(messages[index]) }
1207
1236
  }
1208
1237
 
1238
+ /**
1239
+ * OpenCode's fork copies the source's hub primer (its hub name and roles), and has no way to drop a message: the
1240
+ * fork gets a hub note (admitted without a turn, like a primer) that the identity is not its own. Best effort.
1241
+ */
1242
+ private async forkNote(forkID: string, text: string): Promise<void> {
1243
+ const synthetic = this.host.synthetic?.bind(this.host)
1244
+ if (!synthetic) return
1245
+ const id = harnessMessageID(`fork-note:${forkID}`)
1246
+ let timer: ReturnType<typeof setTimeout> | undefined
1247
+ const ms = this.options.forkNoteTimeoutMs ?? FORK_NOTE_TIMEOUT_MS
1248
+ try {
1249
+ this.origins.hub(id)
1250
+ // Never holds the fork's answer for long: a note that is slow to go in is given up (logged).
1251
+ await Promise.race([
1252
+ synthetic({ sessionID: forkID, id, text, metadata: { source: PROMPT_SOURCE, messageID: id, kind: "primer", from: "hub", to: forkID }, delivery: "steer", resume: false }),
1253
+ new Promise((_, reject) => (timer = setTimeout(() => reject(new Error(`no answer in ${ms} ms`)), ms))),
1254
+ ])
1255
+ } catch (cause) {
1256
+ console.error("[hub] could not tell a fork that its hub identity is not its own", cause)
1257
+ } finally {
1258
+ clearTimeout(timer)
1259
+ }
1260
+ }
1261
+
1209
1262
  /**
1210
1263
  * Stages a revert from one of the session's user messages (feature "revert"), with the files restored when
1211
1264
  * `files`, and reports it at once. Refused while the session runs (OpenCode refuses it too).
@@ -1214,16 +1267,10 @@ export class Spoke {
1214
1267
  if (!this.canRevert()) throw new Error(CANNOT_REVERT)
1215
1268
  const messages = await this.host.context(sessionID)
1216
1269
  const index = userIndex(messages, messageID)
1217
- if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
1218
- if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new Error(REVERT_BUSY)
1219
- let staged: unknown
1220
- try {
1221
- staged = await this.host.revertStage!({ sessionID, messageID, files })
1222
- } catch (cause) {
1223
- if (busyError(cause)) throw new Error(REVERT_BUSY)
1224
- throw cause
1225
- }
1226
- const revert = revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!
1270
+ if (index < 0) throw notAUserMessage()
1271
+ if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new CodedError(REVERT_BUSY, "busy")
1272
+ const staged = await this.host.revertStage!({ sessionID, messageID, files })
1273
+ const revert = withStagedAt(revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!, this.tracker.revertOf(sessionID), Date.now())
1227
1274
  this.timelines.invalidate(sessionID)
1228
1275
  // Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
1229
1276
  this.tracker.setRevert(sessionID, revert)
@@ -1250,21 +1297,52 @@ export class Spoke {
1250
1297
  ])
1251
1298
  if (!this.canRevert()) return messages
1252
1299
  const raw = session ? (session as { revert?: unknown }).revert : undefined
1253
- const revert = session ? revertSummary(raw, messages) : this.tracker.revertOf(sessionID)
1300
+ const known = this.tracker.revertOf(sessionID)
1301
+ const read = session ? revertSummary(raw, messages) : known
1302
+ const revert = session && read ? withStagedAt(read, known, Date.now()) : read
1254
1303
  if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1255
1304
  return visibleMessages(messages, revert?.messageID)
1256
1305
  }
1257
1306
 
1258
- /** A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. */
1307
+ /**
1308
+ * Records a commit of the session's revert (op or OpenCode's committed event) as its removed window
1309
+ * (SessionInfo.revertCommitted). Without a known staged revert: the event of a commit already recorded changes
1310
+ * nothing; otherwise a window without a start (the console still reloads on it). Returns true when it changed.
1311
+ */
1312
+ private recordCommit(sessionID: string, to?: string): boolean {
1313
+ const staged = this.tracker.revertOf(sessionID)
1314
+ const messageID = staged?.messageID ?? to
1315
+ if (!messageID) return false
1316
+ if (!staged && this.tracker.committedOf(sessionID)?.messageID === messageID) return false
1317
+ const now = Date.now()
1318
+ return this.tracker.setCommitted(sessionID, { messageID, ...(staged?.at === undefined ? {} : { from: staged.at }), until: staged?.stagedAt ?? now, at: now })
1319
+ }
1320
+
1321
+ /**
1322
+ * A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. A staged
1323
+ * event reads the history; a later event that arrives meanwhile (a prompt committing it at once) wins, and the
1324
+ * late read only fills in the commit's window start.
1325
+ */
1259
1326
  private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
1260
1327
  if (!this.tracker.has(sessionID)) return
1328
+ const seq = (this.revertSeq.get(sessionID) ?? 0) + 1
1329
+ this.revertSeq.set(sessionID, seq)
1261
1330
  if (event.type !== "session.revert.staged") {
1262
- if (this.tracker.setRevert(sessionID, undefined)) this.scheduleSnapshot()
1331
+ const data = event.data as Record<string, unknown>
1332
+ const committed = event.type === "session.revert.committed" && this.recordCommit(sessionID, typeof data.to === "string" && data.to ? data.to : undefined)
1333
+ if (committed) this.timelines.invalidate(sessionID)
1334
+ if (this.tracker.setRevert(sessionID, undefined) || committed) this.scheduleSnapshot()
1263
1335
  return
1264
1336
  }
1265
1337
  try {
1266
1338
  const raw = (event.data as Record<string, unknown>).revert
1267
- const revert = revertSummary(raw, await this.host.context(sessionID))
1339
+ const read = revertSummary(raw, await this.host.context(sessionID))
1340
+ if (this.revertSeq.get(sessionID) !== seq) {
1341
+ const commit = this.tracker.committedOf(sessionID)
1342
+ if (commit && commit.from === undefined && read?.at !== undefined && read.messageID === commit.messageID && this.tracker.setCommitted(sessionID, { ...commit, from: read.at })) this.scheduleSnapshot()
1343
+ return
1344
+ }
1345
+ const revert = read && withStagedAt(read, this.tracker.revertOf(sessionID), Date.now())
1268
1346
  if (this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1269
1347
  } catch (cause) {
1270
1348
  console.error("[hub] could not read a staged revert", cause)
@@ -1279,6 +1357,8 @@ export class Spoke {
1279
1357
  const synthetic = this.host.synthetic?.bind(this.host)
1280
1358
  if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
1281
1359
  return this.deliveries.run(req.messageID, req.wake, async () => {
1360
+ // Defence in depth: the hub holds deliveries while it sees the revert; this process may know it first.
1361
+ if (this.tracker.revertOf(req.sessionID)) throw new Error(REVERT_STAGED)
1282
1362
  const subagent = (this.tracker.parentOf(req.sessionID) ?? (await this.host.getSession(req.sessionID).catch(() => undefined))?.parentID) !== undefined
1283
1363
  const busy = this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID)
1284
1364
  // A routine quiet message into a running turn would still cost a model call: refused, the hub keeps it
@@ -1454,7 +1534,7 @@ export class Spoke {
1454
1534
  }
1455
1535
  const host = this.host
1456
1536
  const [messages, models, mcp, diffs, branch] = await Promise.all([
1457
- optional(() => host.context(sessionID)),
1537
+ optional(() => this.shownMessages(sessionID)),
1458
1538
  optional(host.listModels && (() => host.listModels!())),
1459
1539
  optional(host.mcpList && (() => host.mcpList!(directory))),
1460
1540
  optional(host.vcsDiff && (() => host.vcsDiff!(directory))),
@@ -1,4 +1,4 @@
1
- import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, summarizeToolInput, type MessageKind, type PromptImage, type TimelineMessageItem } from "../protocol"
1
+ import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, redactEmbeddedMedia, summarizeToolInput, type MessageKind, type PromptImage, type TimelineMessageItem } from "../protocol"
2
2
  import { PROMPT_SOURCE } from "./conversation"
3
3
 
4
4
  /** An attachment of a user message: its position in the message's files (for timelineFile), type and name. */
@@ -45,6 +45,11 @@ export interface TimelineItem {
45
45
  /** Error and retry rows: the error's type (bounded) and HTTP status (100–599). */
46
46
  readonly errorType?: string
47
47
  readonly errorStatus?: number
48
+ /**
49
+ * (R-04) Error rows: a permission was denied and that stopped the turn (OpenCode fails the declined tool call with
50
+ * "The user declined this tool call" and the step with "Step interrupted"). Whoever denied it is the hub's audit's.
51
+ */
52
+ readonly declined?: true
48
53
  /** Retry rows: the attempt number and when the next attempt starts (epoch ms). */
49
54
  readonly attempt?: number
50
55
  readonly next?: number
@@ -56,6 +61,14 @@ const SUBAGENT_TOOLS = new Set(["subagent", "task"])
56
61
  const TITLE_MAX = 160
57
62
  const ERROR_TEXT_MAX = 1000
58
63
  const ERROR_TYPE_MAX = 64
64
+ /**
65
+ * OpenCode's error on a tool call the user (or an agent answering for them) denied, and on the step it then ends
66
+ * (2.2.3, SessionStep: failTool(call, {type: "aborted", message: DECLINED_TOOL}), failAssistant({type: "aborted",
67
+ * message: "Step interrupted"})). Both types are matched, so a tool that throws the same text (tool.execution) cannot
68
+ * mark its turn denied.
69
+ */
70
+ const DECLINED_TOOL = "The user declined this tool call"
71
+ const ABORTED = "aborted"
59
72
 
60
73
  const record = (value: unknown): Record<string, any> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {}
61
74
 
@@ -149,7 +162,7 @@ function errorFields(raw: unknown, fallback: string): { text: string; errorType?
149
162
  */
150
163
  function statusRow(sessionID: string, message: Record<string, any>, at: number | undefined, pos: number): TimelineItem | undefined {
151
164
  const base = { messageID: message.id as string, sessionID, ...(at === undefined ? {} : { at }), pos }
152
- if (message.error != null) return { key: `${message.id}:error`, kind: "error", ...errorFields(message.error, "failed"), ...base }
165
+ if (message.error != null) return { key: `${message.id}:error`, kind: "error", ...errorFields(message.error, "failed"), ...(declinedTool(message) ? { declined: true as const } : {}), ...base }
153
166
  if (message.retry == null || message.time?.completed != null) return undefined
154
167
  const retry = record(message.retry)
155
168
  const next = millis(retry.at)
@@ -163,6 +176,16 @@ function statusRow(sessionID: string, message: Record<string, any>, at: number |
163
176
  }
164
177
  }
165
178
 
179
+ /** (R-04) Whether the message's step was aborted because one of its tool calls' permission was denied. */
180
+ function declinedTool(message: Record<string, any>): boolean {
181
+ if (!Array.isArray(message.content) || record(message.error).type !== ABORTED) return false
182
+ return message.content.some((part: any) => {
183
+ if (part?.type !== "tool" || part.state?.status !== "error") return false
184
+ const error = record(part.state.error)
185
+ return error.type === ABORTED && error.message === DECLINED_TOOL
186
+ })
187
+ }
188
+
166
189
  /** A message the hub delivered (synthetic, with deliverMetadata): a "message" row; other synthetic messages: none. */
167
190
  function hubMessageRow(sessionID: string, message: Record<string, any>, at: number | undefined, pos: number): TimelineMessageItem | undefined {
168
191
  const meta = record(message.metadata)
@@ -325,8 +348,12 @@ export function timelinePart(messages: readonly unknown[], messageID: string, pa
325
348
  }
326
349
 
327
350
  const DETAIL_MAX_BYTES = 750_000
351
+ /**
352
+ * A tool call's detail for the console: embedded images (tool screenshots as data: URLs or base64 parts) become
353
+ * small placeholders, so they never count against the limit nor travel to the console; huge text still fails.
354
+ */
328
355
  export function boundedTimelinePart(messages: readonly unknown[], messageID: string, partID: string): unknown {
329
- const detail = timelinePart(messages, messageID, partID)
356
+ const detail = redactEmbeddedMedia(timelinePart(messages, messageID, partID))
330
357
  if (JSON.stringify(detail).length > DETAIL_MAX_BYTES) throw new Error("Timeline part is too large to display")
331
358
  return detail
332
359
  }