@huynle/hub 0.8.2 → 0.8.4

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 (68) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +31 -0
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +35 -7
  6. package/packages/cli/package.json +1 -1
  7. package/packages/codex/package.json +1 -1
  8. package/packages/node/package.json +1 -1
  9. package/packages/preset/package.json +1 -1
  10. package/packages/protocol/package.json +1 -1
  11. package/packages/protocol/src/index.ts +16 -3
  12. package/packages/protocol/src/voice-recording.ts +107 -0
  13. package/packages/runtime-openshell/package.json +1 -1
  14. package/packages/runtime-sdk/package.json +1 -1
  15. package/packages/sdk/package.json +1 -1
  16. package/packages/spoke-sdk/package.json +1 -1
  17. package/src/central/agent/routes.ts +7 -1
  18. package/src/central/artifacts/blob-store.ts +6 -1
  19. package/src/central/artifacts/blob-support.ts +3 -1
  20. package/src/central/concierge/board-render.ts +34 -11
  21. package/src/central/concierge/index.ts +1 -1
  22. package/src/central/concierge/notifier.ts +2 -1
  23. package/src/central/config.ts +4 -0
  24. package/src/central/csp.ts +55 -0
  25. package/src/central/http.ts +85 -9
  26. package/src/central/profiles/content.ts +16 -0
  27. package/src/central/recording/clock.ts +76 -0
  28. package/src/central/recording/concierge-taps.ts +104 -0
  29. package/src/central/recording/export.ts +215 -0
  30. package/src/central/recording/index.ts +658 -0
  31. package/src/central/recording/ingest.ts +209 -0
  32. package/src/central/recording/retention.ts +71 -0
  33. package/src/central/recording/routes.ts +281 -0
  34. package/src/central/recording/schema.ts +232 -0
  35. package/src/central/recording/settings.ts +35 -0
  36. package/src/central/recording/store.ts +316 -0
  37. package/src/central/recording/taps.ts +105 -0
  38. package/src/central/recording/zip.ts +138 -0
  39. package/src/central/registry.ts +161 -25
  40. package/src/central/spoke-ws.ts +2 -0
  41. package/src/index.ts +28 -0
  42. package/src/spoke/session-context.ts +28 -8
  43. package/src/spoke/spoke.ts +153 -10
  44. package/src/spoke/tracker.ts +6 -1
  45. package/src/status.ts +101 -19
  46. package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-Cb1DcrQf.js} +1 -1
  47. package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-XTnwXl-Y.js} +1 -1
  48. package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-C37tZZ3e.js} +1 -1
  49. package/web/dist/assets/{Library-BXbJvTUS.js → Library-CZSSHvOJ.js} +2 -2
  50. package/web/dist/assets/ProfilesDestination-dZa3vioh.js +1 -0
  51. package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-DJt-Kk84.js} +1 -1
  52. package/web/dist/assets/Watchtower-BXaUaXO1.js +2 -0
  53. package/web/dist/assets/index-DVSkeAvn.css +1 -0
  54. package/web/dist/assets/index-DifgVuKS.js +34 -0
  55. package/web/dist/assets/{live-OgQWPrSY.js → live-D-RZbeUi.js} +1 -1
  56. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  57. package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-DILxLFEq.js} +1 -1
  58. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  59. package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-PvN9Qbt2.js} +4 -56
  60. package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-BS6ii3M7.js} +1 -1
  61. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  62. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  63. package/web/dist/index.html +5 -16
  64. package/web/dist/theme-boot.js +12 -0
  65. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
  66. package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
  67. package/web/dist/assets/index-BabrNDB3.css +0 -1
  68. package/web/dist/assets/index-g4V9mfSr.js +0 -34
@@ -2,9 +2,10 @@
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
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.
5
+ // after the conversation (V-01: it changes between turns, and OpenCode sends tools, system, then messages, so a
6
+ // board in the system prompt made every push re-write the cached conversation; see applySessionContext) as
7
+ // user-side <system-update> text (V-01b: OmniRoute hoists a system message after the conversation back into the
8
+ // system prompt). The whole set replaces the previous one.
8
9
  // - The kept tools are put in name order (the request lists them in the hook's record order).
9
10
  // - tools.allow: the exact tool names the session keeps; the hook deletes every other tool on every turn (unknown
10
11
  // future tools included: fail closed). tools.deny: globs of tool names deleted as well.
@@ -73,22 +74,41 @@ function hint(message: HookMessage): void {
73
74
  if (part && part.cache === undefined) message.content[index] = { ...part, cache: EPHEMERAL }
74
75
  }
75
76
 
77
+ /** (V-01b) The board as user-side text: in <system-update> tags, its markup escaped so no text can close them. */
78
+ export function boardUpdate(tail: readonly string[]): string {
79
+ const text = tail
80
+ .join("\n\n")
81
+ .replace(/[\ud800-\udfff]/gu, "") // lone surrogates (a strict provider rejects the request)
82
+ .replace(/&/g, "&amp;")
83
+ .replace(/</g, "&lt;")
84
+ .replace(/>/g, "&gt;")
85
+ return `<system-update>\n${text}\n</system-update>`
86
+ }
87
+
76
88
  /**
77
89
  * (V-01) Adds a session context to a context hook's request so the provider's prompt cache survives a board change.
78
90
  * OpenCode sends tools, the system parts, then the messages; it puts cache breakpoints on the last tool, the first and
79
91
  * 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
92
+ * hint); the board follows the conversation as one user message (with a hint), and the conversation's last message
81
93
  * 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.
94
+ * (or an empty one) nothing is added after the conversation; a request without messages gets the same <system-update>
95
+ * text at the end of its system prompt.
96
+ *
97
+ * (V-01b) The board is user-side text, never a system message: on the OmniRoute route (claude/claude-sonnet-5-5,
98
+ * measured through a logging proxy) a system message after the conversation is hoisted into the top-level system
99
+ * prompt, so a changed board invalidated the whole cached conversation again. The prompt's instructions say the
100
+ * board arrives in <system-update> tags and is never the user's words; boardUpdate escapes its markup.
83
101
  */
84
102
  export function applySessionContext(event: { system: HookPart[]; messages?: HookMessage[] }, system: readonly string[], tail: readonly string[]): void {
85
103
  const messages = Array.isArray(event.messages) ? event.messages : undefined
86
- const ending = messages ? system : [...system, ...tail]
104
+ // (V-01b verify) An empty board adds nothing; a board is always the escaped <system-update> text, never raw.
105
+ const board = tail.filter((text) => text.trim() !== "")
106
+ const ending = messages || !board.length ? system : [...system, boardUpdate(board)]
87
107
  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
108
+ if (!messages || !board.length) return
89
109
  const last = messages[lastIndex(messages, (m) => !isEffort(m))]
90
110
  if (last) hint(last)
91
- messages.push({ role: "system", content: [{ type: "text", text: tail.join("\n\n"), cache: EPHEMERAL }] })
111
+ messages.push({ role: "user", content: [{ type: "text", text: boardUpdate(board), cache: EPHEMERAL }] })
92
112
  }
93
113
 
94
114
  /** Locked sessions kept at most (oldest dropped; the hub keeps 20 Concierge sessions per user). */
@@ -141,6 +141,12 @@ export interface SpokeHost {
141
141
  * were already running when this process started, or while its event stream was down.
142
142
  */
143
143
  activity?(directories: readonly string[]): Promise<{ readonly active: readonly string[]; readonly jobs: ReadonlyArray<JobInfo & { readonly sessionID: string }> }>
144
+ /**
145
+ * What OpenCode waits for in each of the sessions, from this process's own server: pending forms (FormInfo, as in
146
+ * form.created) and permission requests (as in permission.asked). A session it could not read is left out.
147
+ * Absent, or empty when the server cannot be reached: only what this process saw asked is re-announced.
148
+ */
149
+ pending?(sessionIDs: readonly string[]): Promise<ReadonlyArray<PendingAsks>>
144
150
  /**
145
151
  * Recent root sessions with their workspace: those in the history window (`days`, at most `max`), plus `include`
146
152
  * (always listed: open, running, ...). The background service lists every session; a foreground `opencode serve`
@@ -177,6 +183,13 @@ export interface SpokeHost {
177
183
  revertCommit?(sessionID: string): Promise<unknown>
178
184
  }
179
185
 
186
+ /** A session's pending forms and permission requests as OpenCode lists them (raw, see SpokeHost.pending). */
187
+ export interface PendingAsks {
188
+ readonly sessionID: string
189
+ readonly forms: readonly unknown[]
190
+ readonly permissions: readonly unknown[]
191
+ }
192
+
180
193
  /** A form reply for OpenCode: an answer, or a cancel. */
181
194
  export type FormAction =
182
195
  | { readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }
@@ -205,6 +218,8 @@ export interface SpokeOptions {
205
218
  readonly forkNoteTimeoutMs?: number
206
219
  readonly snapshotIntervalMs?: number
207
220
  readonly seedIntervalMs?: number
221
+ /** Longest the own server may take to list a seed's pending forms and permissions (default 5 s). */
222
+ readonly pendingTimeoutMs?: number
208
223
  /** Delay before re-listing the service's sessions after a session event (default 2 s). */
209
224
  readonly seedDebounceMs?: number
210
225
  /** Longest one session listing may take before it is abandoned and later listings run (default 60 s). */
@@ -389,6 +404,15 @@ export class Spoke {
389
404
  private started = false
390
405
  /** A seedActivity call is in flight. */
391
406
  private seeding = false
407
+ /**
408
+ * Pending permissions and forms (`per <id>` / `frm <id>`) with the event that asked them, as forwarded: the hub
409
+ * keeps them only as long as this connection, so they are announced again after a reconnect (see reannounce).
410
+ * `seq` orders them against a pending listing (one asked while OpenCode was being asked is never closed by it).
411
+ */
412
+ private readonly asks = new Map<string, { sessionID: string; event: { type: string; data: Record<string, unknown> }; seq: number }>()
413
+ private askSeq = 0
414
+ /** Asks seen answered or cancelled (an ID never comes back): a listing taken before the reply cannot revive them. */
415
+ private readonly closedAsks = new Set<string>()
392
416
  /** Sessions shown by OpenCode TUIs (voice.presence). */
393
417
  private readonly presenceBook = new PresenceBook()
394
418
  /** Form replies waiting for a TUI to carry them out (when the plugin cannot reach its own server). */
@@ -907,6 +931,9 @@ export class Spoke {
907
931
  onWelcome: () => {
908
932
  this.sendSnapshot()
909
933
  this.sendTerminals(true)
934
+ // A new connection: the hub dropped what was pending with the old one (or restarted). What this process
935
+ // remembers goes out again at once; the seed then asks OpenCode what it still waits for.
936
+ this.reannounce()
910
937
  void this.seedActivity()
911
938
  },
912
939
  onState: (state, error) => {
@@ -1135,6 +1162,9 @@ export class Spoke {
1135
1162
  return {}
1136
1163
  case "rename":
1137
1164
  return this.rename(req.sessionID, req.title)
1165
+ case "dismissError":
1166
+ this.unheardFailures.delete(req.sessionID)
1167
+ return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
1138
1168
  case "autoName":
1139
1169
  return this.autoName(req.sessionID)
1140
1170
  case "stop":
@@ -1781,6 +1811,7 @@ export class Spoke {
1781
1811
  const title = (event.data as Record<string, unknown>).title
1782
1812
  if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
1783
1813
  }
1814
+ this.trackAsk(event)
1784
1815
  if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
1785
1816
  if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
1786
1817
  if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
@@ -1788,21 +1819,123 @@ export class Spoke {
1788
1819
  this.scheduleSeed()
1789
1820
  }
1790
1821
  void this.fetchInfo()
1791
- if (this.connection?.state === "connected" && (FORWARDED_EVENTS.has(event.type) || COMPACTED_EVENTS.has(event.type))) {
1792
- // Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
1793
- // get their session from the job.
1794
- const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
1795
- if (compact && shouldForward(this.config, this.tracker, compact)) {
1796
- this.announce(compact)
1797
- this.connection.send({ t: "event", event: compact })
1798
- if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
1799
- }
1800
- }
1822
+ this.forward(event)
1801
1823
  } catch (cause) {
1802
1824
  console.error("[hub] event handling failed", cause)
1803
1825
  }
1804
1826
  }
1805
1827
 
1828
+ /** Sends an OpenCode event to the hub (compact), when connected and the hub may see its session. */
1829
+ private forward(event: EventLike): void {
1830
+ if (this.connection?.state !== "connected" || (!FORWARDED_EVENTS.has(event.type) && !COMPACTED_EVENTS.has(event.type))) return
1831
+ // Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
1832
+ // get their session from the job.
1833
+ const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
1834
+ if (!compact || !shouldForward(this.config, this.tracker, compact)) return
1835
+ this.announce(compact)
1836
+ this.connection.send({ t: "event", event: compact })
1837
+ const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
1838
+ if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
1839
+ }
1840
+
1841
+ /** Keeps `asks` current: a permission or form asked, replied, cancelled, or its session deleted. */
1842
+ private trackAsk(event: EventLike): void {
1843
+ const data = (event.data ?? {}) as Record<string, any>
1844
+ if (event.type === "permission.asked" && typeof data.id === "string" && typeof data.sessionID === "string") {
1845
+ this.rememberAsk(`per ${data.id}`, data.sessionID, event)
1846
+ } else if (event.type === "form.created" && typeof data.form?.id === "string" && typeof data.form?.sessionID === "string") {
1847
+ this.rememberAsk(`frm ${data.form.id}`, data.form.sessionID, event)
1848
+ } else if (event.type === "permission.replied" && typeof data.requestID === "string") this.closeAsk(`per ${data.requestID}`)
1849
+ else if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") this.closeAsk(`frm ${data.id}`)
1850
+ else if (event.type === "session.deleted" && typeof data.sessionID === "string") {
1851
+ for (const [key, ask] of this.asks) if (ask.sessionID === data.sessionID) this.asks.delete(key)
1852
+ }
1853
+ }
1854
+
1855
+ private closeAsk(key: string): void {
1856
+ this.asks.delete(key)
1857
+ this.closedAsks.add(key)
1858
+ if (this.closedAsks.size > ASKS_CLOSED_KEPT) this.closedAsks.delete(this.closedAsks.values().next().value!)
1859
+ }
1860
+
1861
+ private rememberAsk(key: string, sessionID: string, event: EventLike): void {
1862
+ if (this.closedAsks.has(key)) return
1863
+ this.asks.delete(key)
1864
+ this.asks.set(key, { sessionID, event: { type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, seq: ++this.askSeq })
1865
+ if (this.asks.size > ASKS_KEPT) this.asks.delete(this.asks.keys().next().value!)
1866
+ }
1867
+
1868
+ /**
1869
+ * Announces every pending permission and form again (after a reconnect: the hub dropped them with the old
1870
+ * connection). A hub without "pendingReplay" would notify a re-sent permission again: it gets the forms only.
1871
+ */
1872
+ private reannounce(): void {
1873
+ const permissions = this.hubSupports("pendingReplay")
1874
+ for (const ask of [...this.asks.values()]) if (permissions || ask.event.type !== "permission.asked") this.forward(ask.event)
1875
+ }
1876
+
1877
+ /**
1878
+ * Asks OpenCode what the sessions wait for (the active ones, and those with something pending here): forms and
1879
+ * permissions this process never saw asked (it started afterwards, or its stream was down) are announced; those
1880
+ * it remembers that OpenCode no longer lists (answered while the stream was down) are closed on the hub.
1881
+ */
1882
+ private async reconcileAsks(active: readonly string[]): Promise<void> {
1883
+ const pending = this.host.pending
1884
+ if (!pending) return
1885
+ // Sessions with something remembered first: a reply missed while the stream was down is only seen by asking.
1886
+ const ids = [...new Set([...[...this.asks.values()].map((a) => a.sessionID).reverse(), ...active])].slice(0, PENDING_SESSIONS_MAX)
1887
+ if (!ids.length) return
1888
+ const asked = this.askSeq
1889
+ const limit = this.options.pendingTimeoutMs ?? PENDING_TIMEOUT_MS
1890
+ let timer: ReturnType<typeof setTimeout> | undefined
1891
+ let lists: ReadonlyArray<PendingAsks>
1892
+ try {
1893
+ const listed = Promise.resolve(pending.call(this.host, ids))
1894
+ listed.catch(() => {}) // a listing that fails after the timeout is not an unhandled rejection
1895
+ const timeout = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error(`no answer within ${limit} ms`)), limit)))
1896
+ lists = await Promise.race([listed, timeout])
1897
+ } catch (cause) {
1898
+ console.error("[hub] could not read pending questions and permissions", errorText(cause))
1899
+ return
1900
+ } finally {
1901
+ clearTimeout(timer)
1902
+ }
1903
+ for (const list of lists) {
1904
+ if (!ids.includes(list.sessionID)) continue
1905
+ const listed = new Set<string>()
1906
+ const found: EventLike[] = []
1907
+ for (const form of Array.isArray(list.forms) ? list.forms : []) {
1908
+ const f = form as Record<string, unknown> | null
1909
+ if (typeof f?.id !== "string" || f.sessionID !== list.sessionID) continue
1910
+ listed.add(`frm ${f.id}`)
1911
+ if (!this.asks.has(`frm ${f.id}`) && !this.closedAsks.has(`frm ${f.id}`)) found.push({ type: "form.created", data: { form: f } })
1912
+ }
1913
+ for (const request of Array.isArray(list.permissions) ? list.permissions : []) {
1914
+ const r = request as Record<string, unknown> | null
1915
+ if (typeof r?.id !== "string" || r.sessionID !== list.sessionID) continue
1916
+ listed.add(`per ${r.id}`)
1917
+ if (!this.asks.has(`per ${r.id}`) && !this.closedAsks.has(`per ${r.id}`)) found.push({ type: "permission.asked", data: r })
1918
+ }
1919
+ const gone: EventLike[] = []
1920
+ for (const [key, ask] of this.asks) {
1921
+ if (ask.sessionID !== list.sessionID || ask.seq > asked || listed.has(key)) continue
1922
+ const id = key.slice(4)
1923
+ // unlisted: the hub drops only this process's hold on it (another process may still hold it).
1924
+ gone.push(
1925
+ key.startsWith("per ")
1926
+ ? { type: "permission.replied", data: { sessionID: ask.sessionID, requestID: id, unlisted: true } }
1927
+ : { type: "form.cancelled", data: { sessionID: ask.sessionID, id, unlisted: true } },
1928
+ )
1929
+ }
1930
+ for (const event of [...found, ...gone]) {
1931
+ this.trackAsk(event)
1932
+ this.tasks.observe(event, Date.now())
1933
+ if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
1934
+ this.forward(event)
1935
+ }
1936
+ }
1937
+ }
1938
+
1806
1939
  /**
1807
1940
  * Adds who put the item in the inbox (user or hub) to session.inbox.delivered, for hubs that route messages
1808
1941
  * (they reset reply-chain hops on a user's turn); when unknown, the event goes out unchanged.
@@ -1875,6 +2008,7 @@ export class Spoke {
1875
2008
  const event = { type: "session.seed", data: { sessionID: id, status, jobs, ...(error ? { error } : {}) } }
1876
2009
  if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
1877
2010
  }
2011
+ await this.reconcileAsks([...active])
1878
2012
  void this.fetchInfo()
1879
2013
  this.scheduleSnapshot()
1880
2014
  } catch (cause) {
@@ -2027,6 +2161,15 @@ const LISTING_TIMEOUT_MS = 60_000
2027
2161
  /** Directories whose shells are listed when seeding. */
2028
2162
  const SEED_DIRECTORIES = 20
2029
2163
 
2164
+ /** Pending permissions and forms remembered to announce again (oldest dropped). */
2165
+ const ASKS_KEPT = 200
2166
+ /** Answered or cancelled asks remembered (oldest dropped). */
2167
+ const ASKS_CLOSED_KEPT = 1000
2168
+ /** Sessions whose pending forms and permissions are read from OpenCode in one seed. */
2169
+ const PENDING_SESSIONS_MAX = 50
2170
+ /** Longest the own server may take to list them (SpokeOptions.pendingTimeoutMs). */
2171
+ const PENDING_TIMEOUT_MS = 5000
2172
+
2030
2173
  /** Keeps the terminals frame within the protocol's limits. */
2031
2174
  function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[] {
2032
2175
  return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
@@ -94,7 +94,7 @@ export class SessionTracker {
94
94
  private readonly jobs = new Map<string, { sessionID: string; running: boolean }>()
95
95
  /** A session's pending retry (session.retry.scheduled, or a retry status), until its run moves on. */
96
96
  private readonly retries = new Map<string, RetryStatus>()
97
- /** A session's last run failure, until its next run starts. */
97
+ /** A session's last run failure, until its next run starts (or the user dismisses it). */
98
98
  private readonly errors = new Map<string, CompactRunError>()
99
99
 
100
100
  /** Applies an event seen in this process. Returns true when the snapshot may have changed. */
@@ -339,6 +339,11 @@ export class SessionTracker {
339
339
  return this.errors.get(sessionID)
340
340
  }
341
341
 
342
+ /** Forgets the session's last run failure (the user dismissed it). True when there was one. */
343
+ dismissError(sessionID: string): boolean {
344
+ return this.errors.delete(sessionID)
345
+ }
346
+
342
347
  /** Sessions whose last run failed (no run started since). */
343
348
  failed(): string[] {
344
349
  return [...this.errors.keys()]
package/src/status.ts CHANGED
@@ -64,6 +64,8 @@ export interface FormFieldSpec {
64
64
 
65
65
  export const FORM_MAX_FIELDS = 20
66
66
  export const FORM_MAX_OPTIONS = 50
67
+ /** Largest blocker kept and shown (JSON characters): the board, the catalog and the hub's kept copy carry it. */
68
+ export const BLOCKER_MAX_BYTES = 64_000
67
69
  const FORM_TEXT_MAX = 2000
68
70
  const FORM_LABEL_MAX = 300
69
71
  const FIELD_TYPES = new Set<FormFieldSpec["type"]>(["string", "number", "integer", "boolean", "multiselect", "external"])
@@ -98,6 +100,22 @@ const SOURCE_ID_MAX = 200
98
100
  export const BLOCKER_MAX_RESOURCES = 10
99
101
  const BLOCKER_RESOURCE_MAX = 500
100
102
 
103
+ /** Longest blocker ID (a longer one is no usable request) and permission summary kept. */
104
+ export const BLOCKER_ID_MAX = 200
105
+ const BLOCKER_SUMMARY_MAX = 2000
106
+
107
+ /** The strings of `list` joined with ", ", at most `max` characters (never joins more than it keeps). */
108
+ function joinBounded(list: unknown, max: number): string {
109
+ if (!Array.isArray(list)) return ""
110
+ let out = ""
111
+ for (const v of list) {
112
+ if (typeof v !== "string") continue
113
+ out = out ? `${out}, ${v}` : v
114
+ if (out.length >= max) return out.slice(0, max)
115
+ }
116
+ return out
117
+ }
118
+
101
119
  /** Strings of `list`, bounded; `cut` is set when anything was dropped or shortened. */
102
120
  function boundedStrings(list: unknown, maxItems: number, maxLength: number): { items: string[]; cut: boolean } {
103
121
  if (!Array.isArray(list)) return { items: [], cut: list !== undefined }
@@ -134,7 +152,7 @@ function permissionBlocker(data: Record<string, any>): Blocker {
134
152
  return {
135
153
  kind: "permission",
136
154
  id: data.id,
137
- summary: [data.action, (data.resources ?? []).join(", ")].filter(Boolean).join(": "),
155
+ summary: [action, joinBounded(data.resources, BLOCKER_SUMMARY_MAX)].filter(Boolean).join(": "),
138
156
  ...(action ? { action } : {}),
139
157
  ...(resources.length ? { resources } : {}),
140
158
  }
@@ -169,29 +187,55 @@ export function formFieldSpec(raw: any): FormFieldSpec | undefined {
169
187
  const d = raw.default
170
188
  if (typeof d === "boolean" || num(d) !== undefined) out.default = d
171
189
  else if (typeof d === "string") out.default = d.slice(0, FORM_TEXT_MAX)
172
- else if (Array.isArray(d)) out.default = d.filter((x: unknown) => typeof x === "string").slice(0, FORM_MAX_OPTIONS)
190
+ else if (Array.isArray(d)) out.default = d.filter((x: unknown) => typeof x === "string").slice(0, FORM_MAX_OPTIONS).map((x: string) => x.slice(0, FORM_LABEL_MAX))
173
191
  if (Array.isArray(raw.when)) {
174
192
  const when = raw.when
175
193
  .filter((w: any) => typeof w?.key === "string" && (w.op === "eq" || w.op === "neq") && ["string", "number", "boolean"].includes(typeof w.value))
176
194
  .slice(0, 10)
177
- .map((w: any) => ({ key: w.key, op: w.op, value: w.value }))
195
+ .map((w: any) => ({ key: w.key.slice(0, FORM_LABEL_MAX), op: w.op, value: typeof w.value === "string" ? w.value.slice(0, FORM_LABEL_MAX) : w.value }))
178
196
  if (when.length) out.when = when
179
197
  }
180
198
  return out as unknown as FormFieldSpec
181
199
  }
182
200
 
183
- /** A form.created event's form as a blocker. */
201
+ /**
202
+ * A form.created event's form as a blocker, at most BLOCKER_MAX_BYTES: over it the descriptions go, then the fields
203
+ * (the card then asks to answer in the terminal).
204
+ */
184
205
  function formBlocker(form: any): Blocker {
185
- const fields = Array.isArray(form.fields) ? form.fields.map(formFieldSpec).filter(Boolean).slice(0, FORM_MAX_FIELDS) : []
186
- return {
187
- kind: "form",
206
+ const fields: FormFieldSpec[] = []
207
+ for (const raw of Array.isArray(form.fields) ? form.fields : []) {
208
+ if (fields.length >= FORM_MAX_FIELDS) break
209
+ const field = formFieldSpec(raw)
210
+ if (field) fields.push(field)
211
+ }
212
+ const head = {
213
+ kind: "form" as const,
188
214
  id: form.id,
189
215
  summary: typeof form.title === "string" && form.title ? form.title.slice(0, FORM_LABEL_MAX) : "question",
190
216
  ...(form.metadata?.kind === "question" ? { question: true as const } : {}),
191
- ...(fields.length ? { fields } : {}),
192
217
  }
218
+ const full: Blocker = { ...head, ...(fields.length ? { fields } : {}) }
219
+ if (JSON.stringify(full).length <= BLOCKER_MAX_BYTES) return full
220
+ const plain: Blocker = { ...head, fields: fields.map(withoutDescriptions) }
221
+ return JSON.stringify(plain).length <= BLOCKER_MAX_BYTES ? plain : head
193
222
  }
194
223
 
224
+ function withoutDescriptions(field: FormFieldSpec): FormFieldSpec {
225
+ const { description: _, ...rest } = field
226
+ return rest.options ? { ...rest, options: rest.options.map(({ description: _d, ...option }) => option) } : rest
227
+ }
228
+
229
+ /** The blocker a permission.asked or form.created event shows (bounded); undefined for other events. */
230
+ export function askBlocker(event: EventLike): Blocker | undefined {
231
+ const data = (event.data ?? {}) as Record<string, any>
232
+ if (event.type === "permission.asked" && usableID(data.id)) return permissionBlocker(data)
233
+ if (event.type === "form.created" && usableID(data.form?.id)) return formBlocker(data.form)
234
+ return undefined
235
+ }
236
+
237
+ const usableID = (id: unknown): id is string => typeof id === "string" && id.length > 0 && id.length <= BLOCKER_ID_MAX
238
+
195
239
  /** A model request OpenCode retries: the attempt, why, when the next one runs (epoch ms) and the error's type. */
196
240
  export interface RetryInfo {
197
241
  readonly attempt?: number
@@ -271,6 +315,8 @@ interface Entry {
271
315
  error?: string
272
316
  errorType?: string
273
317
  errorStatus?: number
318
+ /** The error the user dismissed (fingerprint), so a seed of it (a spoke that cannot forget it) stays hidden. */
319
+ dismissed?: string
274
320
  blockers: Blocker[]
275
321
  jobs: JobInfo[]
276
322
  }
@@ -313,6 +359,7 @@ export class StatusBoard {
313
359
  entry.running = true
314
360
  entry.retry = undefined
315
361
  clearError(entry)
362
+ entry.dismissed = undefined
316
363
  })
317
364
  case "session.execution.succeeded":
318
365
  case "session.execution.interrupted":
@@ -321,6 +368,7 @@ export class StatusBoard {
321
368
  return this.update(data.sessionID, (entry) => {
322
369
  endRun(entry)
323
370
  setError(entry, data.error)
371
+ entry.dismissed = undefined
324
372
  })
325
373
  // Output resumed: the retry succeeded (OpenCode sends no status for it).
326
374
  case "session.text.delta":
@@ -332,27 +380,39 @@ export class StatusBoard {
332
380
  entry.retry = undefined
333
381
  return true
334
382
  }
335
- case "permission.asked":
336
- return this.update(data.sessionID, (entry) => {
383
+ case "permission.asked": {
384
+ const blocker = askBlocker(event)
385
+ return !!blocker && this.update(data.sessionID, (entry) => {
337
386
  if (entry.blockers.some((b) => b.id === data.id)) return
338
- entry.blockers.push(permissionBlocker(data))
387
+ entry.blockers.push(blocker)
339
388
  })
389
+ }
340
390
  case "permission.replied":
341
391
  return this.unblock(data.sessionID, data.requestID)
342
- case "form.created":
343
- return this.update(data.form?.sessionID, (entry) => {
392
+ case "form.created": {
393
+ const blocker = askBlocker(event)
394
+ return !!blocker && this.update(data.form?.sessionID, (entry) => {
344
395
  if (entry.blockers.some((b) => b.id === data.form.id)) return
345
- entry.blockers.push(formBlocker(data.form))
396
+ entry.blockers.push(blocker)
346
397
  })
398
+ }
347
399
  case "form.replied":
348
400
  case "form.cancelled":
349
401
  return this.unblock(data.sessionID, data.id)
350
402
  case "session.status":
351
403
  return this.update(data.sessionID, (entry) => setStatus(entry, data.status))
404
+ // Hub-defined: the user dismissed the session's last error (it shows again on its next failure).
405
+ case "session.error.dismissed": {
406
+ const entry = typeof data.sessionID === "string" ? this.sessions.get(data.sessionID) : undefined
407
+ if (!entry?.error) return false
408
+ entry.dismissed = fingerprint(entry)
409
+ clearError(entry)
410
+ return true
411
+ }
352
412
  case "session.seed":
353
413
  return this.update(data.sessionID, (entry) => {
354
414
  setStatus(entry, data.status)
355
- if (typeof data.error?.message === "string" && data.error.message) setError(entry, data.error)
415
+ if (typeof data.error?.message === "string" && data.error.message && fingerprint(errorFields(data.error)) !== entry.dismissed) setError(entry, data.error)
356
416
  entry.jobs = entry.jobs.filter((job) => job.status !== "running")
357
417
  for (const job of Array.isArray(data.jobs) ? data.jobs : []) if (isJob(job)) upsertJob(entry, job)
358
418
  })
@@ -377,6 +437,14 @@ export class StatusBoard {
377
437
  }
378
438
  }
379
439
 
440
+ /** Puts a pending blocker (askBlocker) back on a session of the tree. True when it was missing. */
441
+ restoreBlocker(sessionID: string, blocker: Blocker): boolean {
442
+ const entry = this.sessions.get(sessionID)
443
+ if (!entry || entry.blockers.some((b) => b.id === blocker.id)) return false
444
+ entry.blockers.push(structuredClone(blocker))
445
+ return true
446
+ }
447
+
380
448
  get(id: string): SessionStatus | undefined {
381
449
  const entry = this.sessions.get(id)
382
450
  return entry && view(entry)
@@ -477,17 +545,31 @@ function setStatus(entry: Entry, status: any): void {
477
545
  ...(errorStatus !== undefined ? { errorStatus } : {}),
478
546
  }
479
547
  } else entry.retry = undefined
480
- if (type === "busy") clearError(entry)
548
+ if (type === "busy") {
549
+ clearError(entry)
550
+ entry.dismissed = undefined
551
+ }
481
552
  }
482
553
 
483
554
  const ERROR_TYPE_MAX = 100
484
555
  const ERROR_MESSAGE_MAX = 1000
485
556
 
486
557
  /** A failed run's error: its message, type and status. */
558
+ function errorFields(error: any): Pick<Entry, "error" | "errorType" | "errorStatus"> {
559
+ return {
560
+ error: typeof error?.message === "string" && error.message ? error.message.slice(0, ERROR_MESSAGE_MAX) : "failed",
561
+ errorType: text(error?.type, ERROR_TYPE_MAX),
562
+ errorStatus: num(error?.status),
563
+ }
564
+ }
565
+
487
566
  function setError(entry: Entry, error: any): void {
488
- entry.error = typeof error?.message === "string" && error.message ? error.message.slice(0, ERROR_MESSAGE_MAX) : "failed"
489
- entry.errorType = text(error?.type, ERROR_TYPE_MAX)
490
- entry.errorStatus = num(error?.status)
567
+ Object.assign(entry, errorFields(error))
568
+ }
569
+
570
+ /** An error as setError keeps it (message, type, status), to recognise a dismissed one when seeded again. */
571
+ function fingerprint(entry: Pick<Entry, "error" | "errorType" | "errorStatus">): string {
572
+ return JSON.stringify([entry.error, entry.errorType ?? null, entry.errorStatus ?? null])
491
573
  }
492
574
 
493
575
  function clearError(entry: Entry): void {
@@ -1 +1 @@
1
- import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-Dh1-78Cl.js";import{L as o,R as s,z as c}from"./index-g4V9mfSr.js";import{t as l}from"./ArtifactWorkbench-sP2mZccC.js";var u=a(`<div class="feature-page av-page">`),d=a(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(a){return(()=>{var f=u();return n(f,e(l,{get id(){return a.id},layout:`page`,get lead(){return[e(t,{get when(){return o()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(i,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>c(a.id)})}})),f})()}export{f as default};
1
+ import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-Qo2Pd9-O.js";import{L as o,R as s,z as c}from"./index-DifgVuKS.js";import{t as l}from"./ArtifactWorkbench-C37tZZ3e.js";var u=a(`<div class="feature-page av-page">`),d=a(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(a){return(()=>{var f=u();return n(f,e(l,{get id(){return a.id},layout:`page`,get lead(){return[e(t,{get when(){return o()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(i,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>c(a.id)})}})),f})()}export{f as default};
@@ -1 +1 @@
1
- import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-Dh1-78Cl.js";import{t as o}from"./ArtifactWorkbench-sP2mZccC.js";var s=a(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(a){return e(o,{get id(){return a.artifactID},layout:`pane`,onArtifact:e=>a.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(i,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(a.artifactID)}`)),o})()},get trailing(){return a.paneTools}})}export{c as ArtifactPane};
1
+ import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-Qo2Pd9-O.js";import{t as o}from"./ArtifactWorkbench-C37tZZ3e.js";var s=a(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(a){return e(o,{get id(){return a.artifactID},layout:`pane`,onArtifact:e=>a.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(i,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(a.artifactID)}`)),o})()},get trailing(){return a.paneTools}})}export{c as ArtifactPane};