@huynle/hub 0.8.2 → 0.8.3

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 +80 -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 +9 -1
  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 +68 -3
  40. package/src/central/spoke-ws.ts +2 -0
  41. package/src/spoke/session-context.ts +28 -8
  42. package/src/spoke/spoke.ts +3 -0
  43. package/src/spoke/tracker.ts +6 -1
  44. package/src/status.ts +31 -5
  45. package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-CSotAWob.js} +1 -1
  46. package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-DPHjiWce.js} +1 -1
  47. package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  48. package/web/dist/assets/{Library-BXbJvTUS.js → Library-DTLEyekn.js} +2 -2
  49. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  50. package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-C-5lYeeM.js} +1 -1
  51. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  52. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  53. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  54. package/web/dist/assets/{live-OgQWPrSY.js → live-CDWcyavI.js} +1 -1
  55. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  56. package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-CNFdTDTr.js} +1 -1
  57. package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-C45jPvYX.js} +4 -56
  58. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  59. package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-sA9a8vYJ.js} +1 -1
  60. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  61. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  62. package/web/dist/index.html +5 -16
  63. package/web/dist/theme-boot.js +12 -0
  64. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
  65. package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
  66. package/web/dist/assets/index-BabrNDB3.css +0 -1
  67. package/web/dist/assets/index-g4V9mfSr.js +0 -34
@@ -0,0 +1,105 @@
1
+ // REC-01 registry taps (contract: Event schema → Hub): turn.started / first.token / turn.ended of the recorded root
2
+ // session, tool rows (summary only, never the output), say.sent per spoken sentence, reply.full, utterance.delivered.
3
+ // The registry calls these only from the session owner's connection; everything here is synchronous and cheap, the
4
+ // writes happen in the recording service (asynchronous, best effort).
5
+ import type { HubEventKind } from "../../protocol"
6
+ import type { RecordingTap } from "../registry"
7
+
8
+ export interface TapSink {
9
+ /** Records a hub event into every active recording of (userID, sessionID); a no-op without one. */
10
+ hub(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>): void
11
+ /** False when the user surely has no active recording (a fresh, empty cache); true when unsure. */
12
+ mayRecord(userID: string): boolean
13
+ }
14
+
15
+ const OUTCOMES: Record<string, "succeeded" | "failed" | "interrupted"> = {
16
+ "session.execution.succeeded": "succeeded",
17
+ "session.execution.failed": "failed",
18
+ "session.execution.interrupted": "interrupted",
19
+ }
20
+ const TOOL_STATES: Record<string, "called" | "success" | "failed"> = {
21
+ "session.tool.called": "called",
22
+ "session.tool.success": "success",
23
+ "session.tool.failed": "failed",
24
+ }
25
+
26
+ const remember = <K, V>(map: Map<K, V>, key: K, value: V, max = 1000) => {
27
+ map.delete(key)
28
+ map.set(key, value)
29
+ if (map.size > max) map.delete(map.keys().next().value as K)
30
+ }
31
+
32
+ export class RegistryTaps implements RecordingTap {
33
+ /** Root sessions whose turn started and whose first text delta is not seen yet. */
34
+ private readonly firstToken = new Set<string>()
35
+ /** Tool names by call ID (session.tool.input.started carries the name, the later events do not). */
36
+ private readonly tools = new Map<string, string>()
37
+
38
+ constructor(private readonly sink: TapSink) {}
39
+
40
+ wants(tenant: string, sessionID: string, type: string): boolean {
41
+ if (type === "session.text.delta") return this.firstToken.has(sessionID) && this.sink.mayRecord(tenant)
42
+ if (type === "session.execution.started") return true
43
+ return this.sink.mayRecord(tenant)
44
+ }
45
+
46
+ event(tenant: string, sessionID: string, type: string, data: Record<string, unknown>): void {
47
+ if (type === "session.execution.started") {
48
+ this.firstToken.add(sessionID)
49
+ if (this.firstToken.size > 1000) this.firstToken.delete(this.firstToken.values().next().value as string)
50
+ this.sink.hub(tenant, sessionID, "turn.started", {})
51
+ return
52
+ }
53
+ const outcome = OUTCOMES[type]
54
+ if (outcome) {
55
+ this.firstToken.delete(sessionID)
56
+ this.sink.hub(tenant, sessionID, "turn.ended", { outcome })
57
+ return
58
+ }
59
+ if (type === "session.text.delta") {
60
+ if (!this.firstToken.delete(sessionID)) return
61
+ const { assistantMessageID: messageID, ordinal } = data
62
+ this.sink.hub(tenant, sessionID, "first.token", { ...(typeof messageID === "string" ? { messageID } : {}), ...(typeof ordinal === "number" ? { ordinal } : {}) })
63
+ return
64
+ }
65
+ const callID = typeof data.id === "string" ? data.id : undefined
66
+ if (type === "session.tool.input.started") {
67
+ if (callID && typeof data.name === "string") remember(this.tools, callID, data.name.slice(0, 120))
68
+ return
69
+ }
70
+ const state = TOOL_STATES[type]
71
+ if (!state || !callID) return
72
+ const tool = this.tools.get(callID)
73
+ if (state !== "called") this.tools.delete(callID)
74
+ // The summary only (summarizeToolInput on the spoke): never the input itself, never the output or error.
75
+ const summary = state === "called" && typeof data.summary === "string" ? data.summary : undefined
76
+ this.sink.hub(tenant, sessionID, "tool", { callID, ...(tool ? { tool } : {}), ...(summary !== undefined ? { summary } : {}), state })
77
+ }
78
+
79
+ sentence(tenant: string, sessionID: string, messageID: string, ordinal: number, seq: number, text: string): void {
80
+ this.sink.hub(tenant, sessionID, "say.sent", { messageID, ordinal, seq, text })
81
+ }
82
+
83
+ reply(tenant: string, sessionID: string, messageID: string, ordinal: number, text: string): void {
84
+ if (!text.trim()) return
85
+ this.sink.hub(tenant, sessionID, "reply.full", { messageID, ordinal, text })
86
+ }
87
+
88
+ heard(tenant: string, sessionID: string, inboxID: string): void {
89
+ this.sink.hub(tenant, sessionID, "utterance.delivered", { inboxID })
90
+ }
91
+ }
92
+
93
+ /** A TTS ref header "<messageID>:<ordinal>:<seq>:<i>" (seq may be empty); undefined when malformed. */
94
+ export function parseTtsRef(header: string | null): { messageID: string; ordinal: number; seq?: number; i: number } | undefined {
95
+ if (!header || header.length > 400) return undefined
96
+ const parts = header.split(":")
97
+ if (parts.length < 4) return undefined
98
+ const i = Number(parts.pop())
99
+ const seqText = parts.pop()!
100
+ const ordinal = Number(parts.pop())
101
+ const messageID = parts.join(":")
102
+ const seq = seqText === "" ? undefined : Number(seqText)
103
+ if (!messageID || !Number.isSafeInteger(i) || !Number.isSafeInteger(ordinal) || (seq !== undefined && !Number.isSafeInteger(seq))) return undefined
104
+ return { messageID, ordinal, ...(seq !== undefined ? { seq } : {}), i }
105
+ }
@@ -0,0 +1,138 @@
1
+ // REC-01 streaming zip writer for recording exports (contract: Export response): the STORE method (no
2
+ // compression; WAV hardly compresses), CRC-32 (Bun.hash.crc32), data descriptors (flag bit 3) so nothing is
3
+ // buffered per entry, UTF-8 names (flag bit 11), no ZIP64 (RECORDING_LIMITS.userBytes keeps an export far below 4 GiB).
4
+
5
+ export interface ZipEntry {
6
+ /** Path inside the archive ("manifest.json", "<id>/audio/000001.wav"). */
7
+ readonly name: string
8
+ /** The entry's bytes, read once when the writer reaches it; undefined writes an empty entry. */
9
+ body(): Promise<Uint8Array | ReadableStream<Uint8Array> | undefined>
10
+ }
11
+
12
+ const FLAGS = 0x0008 | 0x0800
13
+ /** 1980-01-01 00:00 in DOS time (the exports carry their own timestamps in the manifest). */
14
+ const DOS_TIME = 0
15
+ const DOS_DATE = (0 << 9) | (1 << 5) | 1
16
+
17
+ interface Written {
18
+ readonly name: Uint8Array
19
+ readonly offset: number
20
+ readonly crc: number
21
+ readonly size: number
22
+ }
23
+
24
+ const le = (size: number) => {
25
+ const bytes = new Uint8Array(size)
26
+ return { bytes, view: new DataView(bytes.buffer) }
27
+ }
28
+
29
+ function localHeader(name: Uint8Array): Uint8Array {
30
+ const { bytes, view } = le(30 + name.length)
31
+ view.setUint32(0, 0x04034b50, true)
32
+ view.setUint16(4, 20, true)
33
+ view.setUint16(6, FLAGS, true)
34
+ view.setUint16(8, 0, true)
35
+ view.setUint16(10, DOS_TIME, true)
36
+ view.setUint16(12, DOS_DATE, true)
37
+ // CRC and sizes follow in the data descriptor.
38
+ view.setUint16(26, name.length, true)
39
+ bytes.set(name, 30)
40
+ return bytes
41
+ }
42
+
43
+ function descriptor(crc: number, size: number): Uint8Array {
44
+ const { bytes, view } = le(16)
45
+ view.setUint32(0, 0x08074b50, true)
46
+ view.setUint32(4, crc, true)
47
+ view.setUint32(8, size, true)
48
+ view.setUint32(12, size, true)
49
+ return bytes
50
+ }
51
+
52
+ function centralEntry(entry: Written): Uint8Array {
53
+ const { bytes, view } = le(46 + entry.name.length)
54
+ view.setUint32(0, 0x02014b50, true)
55
+ view.setUint16(4, 20, true)
56
+ view.setUint16(6, 20, true)
57
+ view.setUint16(8, FLAGS, true)
58
+ view.setUint16(10, 0, true)
59
+ view.setUint16(12, DOS_TIME, true)
60
+ view.setUint16(14, DOS_DATE, true)
61
+ view.setUint32(16, entry.crc, true)
62
+ view.setUint32(20, entry.size, true)
63
+ view.setUint32(24, entry.size, true)
64
+ view.setUint16(28, entry.name.length, true)
65
+ view.setUint32(42, entry.offset, true)
66
+ bytes.set(entry.name, 46)
67
+ return bytes
68
+ }
69
+
70
+ function endOfDirectory(count: number, size: number, offset: number): Uint8Array {
71
+ const { bytes, view } = le(22)
72
+ view.setUint32(0, 0x06054b50, true)
73
+ view.setUint16(8, count, true)
74
+ view.setUint16(10, count, true)
75
+ view.setUint32(12, size, true)
76
+ view.setUint32(16, offset, true)
77
+ return bytes
78
+ }
79
+
80
+ /** The archive of `entries`, streamed: each entry's body is read only when the reader gets to it. */
81
+ export function zipStream(entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>): ReadableStream<Uint8Array> {
82
+ async function* produce(): AsyncGenerator<Uint8Array> {
83
+ const written: Written[] = []
84
+ let offset = 0
85
+ for await (const entry of entries as AsyncIterable<ZipEntry>) {
86
+ const name = new TextEncoder().encode(entry.name)
87
+ const header = localHeader(name)
88
+ const start = offset
89
+ yield header
90
+ offset += header.length
91
+ let crc = 0
92
+ let size = 0
93
+ const body = await entry.body()
94
+ const take = (chunk: Uint8Array) => {
95
+ crc = Bun.hash.crc32(chunk, crc)
96
+ size += chunk.length
97
+ offset += chunk.length
98
+ }
99
+ if (body instanceof Uint8Array) {
100
+ take(body)
101
+ if (body.length) yield body
102
+ } else if (body) {
103
+ for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
104
+ take(chunk)
105
+ if (chunk.length) yield chunk
106
+ }
107
+ }
108
+ crc >>>= 0
109
+ const tail = descriptor(crc, size)
110
+ yield tail
111
+ offset += tail.length
112
+ written.push({ name, offset: start, crc, size })
113
+ }
114
+ const directoryStart = offset
115
+ let directorySize = 0
116
+ for (const entry of written) {
117
+ const bytes = centralEntry(entry)
118
+ directorySize += bytes.length
119
+ yield bytes
120
+ }
121
+ yield endOfDirectory(written.length, directorySize, directoryStart)
122
+ }
123
+ const iterator = produce()
124
+ return new ReadableStream<Uint8Array>({
125
+ async pull(controller) {
126
+ try {
127
+ const { done, value } = await iterator.next()
128
+ if (done) controller.close()
129
+ else controller.enqueue(value)
130
+ } catch (cause) {
131
+ controller.error(cause)
132
+ }
133
+ },
134
+ async cancel() {
135
+ await iterator.return(undefined)
136
+ },
137
+ })
138
+ }
@@ -20,6 +20,23 @@ import type { ActivityStep, NotificationKind } from "./push"
20
20
 
21
21
  export { ACTIVITY_INTERVAL_MINUTES } from "./store"
22
22
  /** A tool call that took at least this long is named in activity updates (shorter ones are only counted). */
23
+ /**
24
+ * (REC-01) Voice recording taps: what the hub sees of a recorded root session (docs/voice-recording.md). Called
25
+ * only from the session owner's connection (deduped like speakDelta), never changing behaviour; the tap writes
26
+ * only into recordings of `tenant` (the session's owner) and answers at once (its writes are asynchronous).
27
+ */
28
+ export interface RecordingTap {
29
+ /** Cheap pre-check before an event is tapped (e.g. a text delta only while a first token is awaited). */
30
+ wants(tenant: string, sessionID: string, type: string): boolean
31
+ event(tenant: string, sessionID: string, type: string, data: Record<string, unknown>): void
32
+ sentence(tenant: string, sessionID: string, messageID: string, ordinal: number, seq: number, text: string): void
33
+ reply(tenant: string, sessionID: string, messageID: string, ordinal: number, text: string): void
34
+ heard(tenant: string, sessionID: string, inboxID: string): void
35
+ }
36
+
37
+ /** Spoke events a RecordingTap sees (root sessions only). */
38
+ const TAPPED = new Set(["session.execution.started", "session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.tool.input.started", "session.tool.called", "session.tool.success", "session.tool.failed", "session.text.delta"])
39
+
23
40
  export const LONG_TOOL_MS = 30_000
24
41
 
25
42
  export class BusyError extends Error {
@@ -399,6 +416,9 @@ const eventSessionID = (event: ForwardedEvent): string | undefined => {
399
416
  const isOn = (s: SessionInfo) => s.exposed !== false
400
417
 
401
418
  export class Registry {
419
+ /** (REC-01) Voice recording taps; optional, best effort. */
420
+ recordingTap?: RecordingTap
421
+
402
422
  private readonly conns = new Map<string, Conn>()
403
423
  private readonly boards = new Map<string, StatusBoard>()
404
424
  /**
@@ -744,6 +764,7 @@ export class Registry {
744
764
  }
745
765
 
746
766
  if (sessionID) this.trackRun(event.type, sessionID)
767
+ if (sessionID && this.recordingTap && TAPPED.has(event.type)) this.tap(connID, tenant, sessionID, event.type, data)
747
768
  const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
748
769
  this.observeBoards(event, tenant)
749
770
  if (sessionID) this.activitySteps(event.type, sessionID, data, before)
@@ -769,6 +790,31 @@ export class Registry {
769
790
  if (closedPermission) this.permissionClosed(...closedPermission)
770
791
  }
771
792
 
793
+ /** (REC-01) Hands a root session's event to the recording tap once: from the session's owner connection only. */
794
+ private tap(connID: string, tenant: string | undefined, sessionID: string, type: string, data: Record<string, unknown>): void {
795
+ const tap = this.recordingTap
796
+ if (!tap || tenant === undefined) return
797
+ try {
798
+ if (!tap.wants(tenant, sessionID, type) || this.rootOf(sessionID) !== sessionID || this.owner(sessionID) !== connID) return
799
+ tap.event(tenant, sessionID, type, data)
800
+ } catch (cause) {
801
+ console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
802
+ }
803
+ }
804
+
805
+ /** (REC-01) A tap call that never throws into the registry. */
806
+ private tapSafely(call: (tap: RecordingTap, tenant: string) => void, sessionID: string): void {
807
+ const tap = this.recordingTap
808
+ if (!tap) return
809
+ const tenant = this.sessionTenant(sessionID)
810
+ if (tenant === undefined) return
811
+ try {
812
+ call(tap, tenant)
813
+ } catch (cause) {
814
+ console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
815
+ }
816
+ }
817
+
772
818
  /** Records when a session's run started (kept by the first process to report it while it runs). */
773
819
  private trackRun(type: string, sessionID: string): void {
774
820
  if (type === "session.execution.started") {
@@ -816,8 +862,10 @@ export class Registry {
816
862
 
817
863
  /** Remembers an utterance's inbox item so its delivery is reported as a heard event. */
818
864
  trackUtterance(sessionID: string, inboxID: string): void {
819
- if (this.deliveries.delete(inboxID)) this.emit({ type: "heard", sessionID, inboxID })
820
- else remember(this.utterances, inboxID, sessionID)
865
+ if (this.deliveries.delete(inboxID)) {
866
+ this.emit({ type: "heard", sessionID, inboxID })
867
+ this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
868
+ } else remember(this.utterances, inboxID, sessionID)
821
869
  }
822
870
 
823
871
  /**
@@ -841,6 +889,7 @@ export class Registry {
841
889
  if (sessionID === undefined) return remember(this.deliveries, inboxID, true)
842
890
  this.utterances.delete(inboxID)
843
891
  this.emit({ type: "heard", sessionID, inboxID })
892
+ this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
844
893
  }
845
894
 
846
895
  /** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
@@ -872,13 +921,16 @@ export class Registry {
872
921
  const state = this.rootSpeech.get(key) ?? { sessionID, connID: "", stream: new SentenceStream(), seq: 0 }
873
922
  this.rootSpeech.delete(key)
874
923
  for (const sentence of state.stream.end(text)) this.emitSentence(state, messageID, ordinal, sentence)
924
+ this.tapSafely((tap, tenant) => tap.reply(tenant, sessionID, messageID, ordinal, text), sessionID)
875
925
  // Nobody is listening on a phone: push the whole reply so it reaches a locked screen.
876
926
  if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
877
927
  }
878
928
 
879
929
  private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
880
930
  this.heardFrom(state.sessionID, text)
881
- this.emit({ type: "say", sessionID: state.sessionID, messageID, ordinal, seq: state.seq++, text })
931
+ const seq = state.seq++
932
+ this.emit({ type: "say", sessionID: state.sessionID, messageID, ordinal, seq, text })
933
+ this.tapSafely((tap, tenant) => tap.sentence(tenant, state.sessionID, messageID, ordinal, seq, text), state.sessionID)
882
934
  }
883
935
 
884
936
  /** The run stopped or conversation mode ended: the rest of its text parts is not spoken. */
@@ -1808,6 +1860,19 @@ export class Registry {
1808
1860
  }
1809
1861
  }
1810
1862
 
1863
+ /**
1864
+ * The user dismissed the session's last run failure: its status board stops showing it (consoles and the Concierge
1865
+ * see the state leave "error") until it fails again. True when there was one to clear.
1866
+ */
1867
+ dismissError(sessionID: string): boolean {
1868
+ const rootID = this.rootOf(sessionID)
1869
+ const board = rootID === undefined ? undefined : this.boards.get(rootID)
1870
+ if (rootID === undefined || !board?.observe({ type: "session.error.dismissed", data: { sessionID } })) return false
1871
+ this.changed(this.sessionTenant(rootID))
1872
+ this.emit({ type: "status", sessionID: rootID, sessions: this.live(board.snapshot().sessions) })
1873
+ return true
1874
+ }
1875
+
1811
1876
  /** After a stop or interrupt: releases the turn locks in the session's tree and ends their waiting requests. */
1812
1877
  cancelTurns(sessionID: string): void {
1813
1878
  const rootID = this.rootOf(sessionID) ?? sessionID
@@ -521,6 +521,8 @@ export class SpokeHub {
521
521
  const owner = this.options.registry.sessionTenant(sessionID)
522
522
  if (owner !== undefined && owner !== call.tenant) throw new SpokeError("Unknown session")
523
523
  }
524
+ // A dismissed error: the session's status board is this pod's (its connection is here), not the calling pod's.
525
+ if (call.kind === "spoke" && call.req.op === "dismissError") this.options.registry.dismissError(call.req.sessionID)
524
526
  return call.kind === "node" ? this.requestNode(call.connID, call.req) : this.request(call.connID, call.req)
525
527
  }
526
528
 
@@ -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). */
@@ -1135,6 +1135,9 @@ export class Spoke {
1135
1135
  return {}
1136
1136
  case "rename":
1137
1137
  return this.rename(req.sessionID, req.title)
1138
+ case "dismissError":
1139
+ this.unheardFailures.delete(req.sessionID)
1140
+ return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
1138
1141
  case "autoName":
1139
1142
  return this.autoName(req.sessionID)
1140
1143
  case "stop":
@@ -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
@@ -271,6 +271,8 @@ interface Entry {
271
271
  error?: string
272
272
  errorType?: string
273
273
  errorStatus?: number
274
+ /** The error the user dismissed (fingerprint), so a seed of it (a spoke that cannot forget it) stays hidden. */
275
+ dismissed?: string
274
276
  blockers: Blocker[]
275
277
  jobs: JobInfo[]
276
278
  }
@@ -313,6 +315,7 @@ export class StatusBoard {
313
315
  entry.running = true
314
316
  entry.retry = undefined
315
317
  clearError(entry)
318
+ entry.dismissed = undefined
316
319
  })
317
320
  case "session.execution.succeeded":
318
321
  case "session.execution.interrupted":
@@ -321,6 +324,7 @@ export class StatusBoard {
321
324
  return this.update(data.sessionID, (entry) => {
322
325
  endRun(entry)
323
326
  setError(entry, data.error)
327
+ entry.dismissed = undefined
324
328
  })
325
329
  // Output resumed: the retry succeeded (OpenCode sends no status for it).
326
330
  case "session.text.delta":
@@ -349,10 +353,18 @@ export class StatusBoard {
349
353
  return this.unblock(data.sessionID, data.id)
350
354
  case "session.status":
351
355
  return this.update(data.sessionID, (entry) => setStatus(entry, data.status))
356
+ // Hub-defined: the user dismissed the session's last error (it shows again on its next failure).
357
+ case "session.error.dismissed": {
358
+ const entry = typeof data.sessionID === "string" ? this.sessions.get(data.sessionID) : undefined
359
+ if (!entry?.error) return false
360
+ entry.dismissed = fingerprint(entry)
361
+ clearError(entry)
362
+ return true
363
+ }
352
364
  case "session.seed":
353
365
  return this.update(data.sessionID, (entry) => {
354
366
  setStatus(entry, data.status)
355
- if (typeof data.error?.message === "string" && data.error.message) setError(entry, data.error)
367
+ if (typeof data.error?.message === "string" && data.error.message && fingerprint(errorFields(data.error)) !== entry.dismissed) setError(entry, data.error)
356
368
  entry.jobs = entry.jobs.filter((job) => job.status !== "running")
357
369
  for (const job of Array.isArray(data.jobs) ? data.jobs : []) if (isJob(job)) upsertJob(entry, job)
358
370
  })
@@ -477,17 +489,31 @@ function setStatus(entry: Entry, status: any): void {
477
489
  ...(errorStatus !== undefined ? { errorStatus } : {}),
478
490
  }
479
491
  } else entry.retry = undefined
480
- if (type === "busy") clearError(entry)
492
+ if (type === "busy") {
493
+ clearError(entry)
494
+ entry.dismissed = undefined
495
+ }
481
496
  }
482
497
 
483
498
  const ERROR_TYPE_MAX = 100
484
499
  const ERROR_MESSAGE_MAX = 1000
485
500
 
486
501
  /** A failed run's error: its message, type and status. */
502
+ function errorFields(error: any): Pick<Entry, "error" | "errorType" | "errorStatus"> {
503
+ return {
504
+ error: typeof error?.message === "string" && error.message ? error.message.slice(0, ERROR_MESSAGE_MAX) : "failed",
505
+ errorType: text(error?.type, ERROR_TYPE_MAX),
506
+ errorStatus: num(error?.status),
507
+ }
508
+ }
509
+
487
510
  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)
511
+ Object.assign(entry, errorFields(error))
512
+ }
513
+
514
+ /** An error as setError keeps it (message, type, status), to recognise a dismissed one when seeded again. */
515
+ function fingerprint(entry: Pick<Entry, "error" | "errorType" | "errorStatus">): string {
516
+ return JSON.stringify([entry.error, entry.errorType ?? null, entry.errorStatus ?? null])
491
517
  }
492
518
 
493
519
  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-6mdQ7eUY.js";import{t as l}from"./ArtifactWorkbench-CkmWnP6L.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-CkmWnP6L.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};