@huynle/hub 0.8.1 → 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 (97) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +32 -1
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +116 -53
  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 +92 -7
  22. package/packages/protocol/src/voice-recording.ts +107 -0
  23. package/packages/protocol/src/wire.ts +1 -0
  24. package/packages/runtime-openshell/package.json +1 -1
  25. package/packages/runtime-sdk/package.json +1 -1
  26. package/packages/sdk/package.json +1 -1
  27. package/packages/sdk/src/client.ts +3 -1
  28. package/packages/spoke-sdk/package.json +1 -1
  29. package/packages/spoke-sdk/src/connection.ts +13 -1
  30. package/src/central/agent/acting-routes.ts +3 -2
  31. package/src/central/agent/acting.ts +67 -12
  32. package/src/central/agent/delivery.ts +12 -3
  33. package/src/central/agent/escalation.ts +30 -9
  34. package/src/central/agent/launch-spec.ts +38 -19
  35. package/src/central/agent/routes.ts +14 -1
  36. package/src/central/artifacts/blob-store.ts +6 -1
  37. package/src/central/artifacts/blob-support.ts +3 -1
  38. package/src/central/concierge/board-render.ts +34 -11
  39. package/src/central/concierge/context.ts +3 -1
  40. package/src/central/concierge/index.ts +1 -1
  41. package/src/central/concierge/notifier.ts +2 -1
  42. package/src/central/concierge/prompt.ts +7 -4
  43. package/src/central/config.ts +4 -0
  44. package/src/central/csp.ts +55 -0
  45. package/src/central/http.ts +134 -29
  46. package/src/central/profiles/content.ts +16 -0
  47. package/src/central/recording/clock.ts +76 -0
  48. package/src/central/recording/concierge-taps.ts +104 -0
  49. package/src/central/recording/export.ts +215 -0
  50. package/src/central/recording/index.ts +658 -0
  51. package/src/central/recording/ingest.ts +209 -0
  52. package/src/central/recording/retention.ts +71 -0
  53. package/src/central/recording/routes.ts +281 -0
  54. package/src/central/recording/schema.ts +232 -0
  55. package/src/central/recording/settings.ts +35 -0
  56. package/src/central/recording/store.ts +316 -0
  57. package/src/central/recording/taps.ts +105 -0
  58. package/src/central/recording/zip.ts +138 -0
  59. package/src/central/registry.ts +91 -6
  60. package/src/central/revert-windows.ts +66 -0
  61. package/src/central/spoke-ws.ts +8 -3
  62. package/src/central/teams/staffing.ts +2 -2
  63. package/src/index.ts +26 -14
  64. package/src/spoke/deliver.ts +6 -0
  65. package/src/spoke/fork-revert.ts +94 -9
  66. package/src/spoke/hub-tool.ts +2 -2
  67. package/src/spoke/locations.ts +80 -0
  68. package/src/spoke/profile-apply.ts +24 -14
  69. package/src/spoke/session-context.ts +96 -6
  70. package/src/spoke/spoke.ts +119 -36
  71. package/src/spoke/timeline.ts +30 -3
  72. package/src/spoke/tracker.ts +23 -2
  73. package/src/status.ts +33 -6
  74. package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
  75. package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
  76. package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  77. package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
  78. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  79. package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
  80. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  81. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  82. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  83. package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
  84. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  85. package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
  86. package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
  87. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  88. package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
  89. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  90. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  91. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  92. package/web/dist/index.html +5 -16
  93. package/web/dist/theme-boot.js +12 -0
  94. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
  95. package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
  96. package/web/dist/assets/index-BabrNDB3.css +0 -1
  97. package/web/dist/assets/index-lIOfSYck.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
+ }
@@ -11,7 +11,7 @@
11
11
  // another one and reaches it through SpokeHub.request (pod RPC). Events, alerts and speech stay with the pod that
12
12
  // holds the connection; remote state is never re-exported.
13
13
  import path from "node:path"
14
- import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
14
+ import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type RevertCommitted, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
15
15
  import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
16
16
  import { sanitizeLabel } from "./agent/envelope"
17
17
  import { DoneWatch } from "./done"
@@ -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 {
@@ -224,6 +241,8 @@ export interface SessionView extends BrowserFields {
224
241
  readonly terminal?: TerminalInfo
225
242
  /** A staged revert (feature "revert"), as the most recent report says. */
226
243
  readonly revert?: SessionRevertInfo
244
+ /** The latest committed revert's removed window (feature "revert"), as the most recent report says. */
245
+ readonly revertCommitted?: RevertCommitted
227
246
  /** (CON-04) A locked (Concierge) session: the agent and the context revision the most recent report says. */
228
247
  readonly agent?: string
229
248
  readonly contextRevision?: number
@@ -397,6 +416,9 @@ const eventSessionID = (event: ForwardedEvent): string | undefined => {
397
416
  const isOn = (s: SessionInfo) => s.exposed !== false
398
417
 
399
418
  export class Registry {
419
+ /** (REC-01) Voice recording taps; optional, best effort. */
420
+ recordingTap?: RecordingTap
421
+
400
422
  private readonly conns = new Map<string, Conn>()
401
423
  private readonly boards = new Map<string, StatusBoard>()
402
424
  /**
@@ -553,6 +575,12 @@ export class Registry {
553
575
  return conn && this.tenant(conn)
554
576
  }
555
577
 
578
+ /** The committed revert windows the connection reports for its own tenant's sessions (feature "revert"). */
579
+ revertCommits(connID: string): Array<{ sessionID: string; committed: RevertCommitted }> {
580
+ const conn = this.conn(connID)
581
+ return conn ? this.visible(conn).flatMap((s) => (s.revertCommitted ? [{ sessionID: s.id, committed: s.revertCommitted }] : [])) : []
582
+ }
583
+
556
584
  /** IDs of the sessions the connection reports for its own tenant (subagents included); empty when unknown. */
557
585
  reportedSessions(connID: string): string[] {
558
586
  const conn = this.conn(connID)
@@ -736,6 +764,7 @@ export class Registry {
736
764
  }
737
765
 
738
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)
739
768
  const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
740
769
  this.observeBoards(event, tenant)
741
770
  if (sessionID) this.activitySteps(event.type, sessionID, data, before)
@@ -761,6 +790,31 @@ export class Registry {
761
790
  if (closedPermission) this.permissionClosed(...closedPermission)
762
791
  }
763
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
+
764
818
  /** Records when a session's run started (kept by the first process to report it while it runs). */
765
819
  private trackRun(type: string, sessionID: string): void {
766
820
  if (type === "session.execution.started") {
@@ -808,8 +862,10 @@ export class Registry {
808
862
 
809
863
  /** Remembers an utterance's inbox item so its delivery is reported as a heard event. */
810
864
  trackUtterance(sessionID: string, inboxID: string): void {
811
- if (this.deliveries.delete(inboxID)) this.emit({ type: "heard", sessionID, inboxID })
812
- 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)
813
869
  }
814
870
 
815
871
  /**
@@ -833,6 +889,7 @@ export class Registry {
833
889
  if (sessionID === undefined) return remember(this.deliveries, inboxID, true)
834
890
  this.utterances.delete(inboxID)
835
891
  this.emit({ type: "heard", sessionID, inboxID })
892
+ this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
836
893
  }
837
894
 
838
895
  /** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
@@ -864,13 +921,16 @@ export class Registry {
864
921
  const state = this.rootSpeech.get(key) ?? { sessionID, connID: "", stream: new SentenceStream(), seq: 0 }
865
922
  this.rootSpeech.delete(key)
866
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)
867
925
  // Nobody is listening on a phone: push the whole reply so it reaches a locked screen.
868
926
  if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
869
927
  }
870
928
 
871
929
  private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
872
930
  this.heardFrom(state.sessionID, text)
873
- 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)
874
934
  }
875
935
 
876
936
  /** The run stopped or conversation mode ended: the rest of its text parts is not spoken. */
@@ -1174,6 +1234,15 @@ export class Registry {
1174
1234
  }
1175
1235
 
1176
1236
  /** Something in the session's tree is working or waiting for the user (typed input steers then). */
1237
+ /**
1238
+ * Whether a process reports a staged revert for the session (feature "revert"): anything admitted into it would
1239
+ * commit the revert (OpenCode's next prompt does), so hub deliveries and act prompts wait or are refused.
1240
+ */
1241
+ revertStaged(sessionID: string): boolean {
1242
+ for (const conn of this.all()) if (this.reports(conn, sessionID)?.revert) return true
1243
+ return false
1244
+ }
1245
+
1177
1246
  running(sessionID: string): boolean {
1178
1247
  const state = this.browserFields(sessionID).state
1179
1248
  return state === "working" || state === "blocked"
@@ -1296,6 +1365,7 @@ export class Registry {
1296
1365
  updated: info.updated,
1297
1366
  ...(terminal ? { open: true, terminal } : {}),
1298
1367
  ...(info.revert ? { revert: info.revert } : {}),
1368
+ ...(info.revertCommitted ? { revertCommitted: info.revertCommitted } : {}),
1299
1369
  ...(info.agent !== undefined ? { agent: info.agent } : {}),
1300
1370
  ...(info.contextRevision !== undefined ? { contextRevision: info.contextRevision } : {}),
1301
1371
  ...fields,
@@ -1745,10 +1815,12 @@ export class Registry {
1745
1815
  recommend(requestID: string, recommendation: BlockerRecommendation): boolean {
1746
1816
  const origin = this.permissions.get(requestID)
1747
1817
  if (!origin) return false
1748
- const why = recommendation.why === undefined ? "" : sanitizeLabel(recommendation.why, RECOMMENDATION_WHY_MAX)
1818
+ // (R-03) Without recommends it only links the Decision: no why either.
1819
+ const recommends = recommendation.recommends === "approve"
1820
+ const why = !recommends || recommendation.why === undefined ? "" : sanitizeLabel(recommendation.why, RECOMMENDATION_WHY_MAX)
1749
1821
  origin.recommendation = {
1750
1822
  by: sanitizeLabel(recommendation.by, RECOMMENDATION_BY_MAX) || "An agent",
1751
- recommends: "approve",
1823
+ ...(recommends ? { recommends: "approve" as const } : {}),
1752
1824
  ...(why ? { why } : {}),
1753
1825
  ...(recommendation.decisionID && DECISION_ID.test(recommendation.decisionID) ? { decisionID: recommendation.decisionID } : {}),
1754
1826
  }
@@ -1788,6 +1860,19 @@ export class Registry {
1788
1860
  }
1789
1861
  }
1790
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
+
1791
1876
  /** After a stop or interrupt: releases the turn locks in the session's tree and ends their waiting requests. */
1792
1877
  cancelTurns(sessionID: string): void {
1793
1878
  const rootID = this.rootOf(sessionID) ?? sessionID
@@ -0,0 +1,66 @@
1
+ // Committed revert windows (feature "revert", docs/session-view.md → Fork and revert). A committed revert removed a
2
+ // session's history from the reverted message on; hub message rows (agent messages the session sent or got) from
3
+ // the reverted message's time until the revert was staged belonged to that history (later ones were held for after
4
+ // it, decision 3q94ez94). The spoke reports the latest window (SessionInfo.revertCommitted); the hub keeps every
5
+ // window per (user, session) in the StateStore, so GET /api/sessions/:id/messages leaves those rows out for good
6
+ // (also after the spoke restarts or another process reports the session).
7
+ import type { RevertCommitted } from "../protocol"
8
+ import type { StateStore } from "./backend/types"
9
+
10
+ const COLLECTION = "revertWindows"
11
+ /** Windows kept per session (the oldest go first). */
12
+ export const REVERT_WINDOWS_MAX = 50
13
+
14
+ export interface RevertWindow {
15
+ readonly from: number
16
+ readonly until: number
17
+ /** Commit time (identifies the commit). */
18
+ readonly at: number
19
+ }
20
+
21
+ interface Stored {
22
+ readonly windows: readonly RevertWindow[]
23
+ }
24
+
25
+ /** Whether a row at `at` falls into one of the windows. */
26
+ export function inRevertWindow(at: unknown, windows: readonly RevertWindow[]): boolean {
27
+ return typeof at === "number" && windows.some((w) => at >= w.from && at < w.until)
28
+ }
29
+
30
+ export class RevertWindows {
31
+ /** Windows known stored (by user, session and the whole window): the frames that repeat them read nothing. */
32
+ private readonly stored = new Set<string>()
33
+
34
+ constructor(private readonly state: StateStore) {}
35
+
36
+ /**
37
+ * A reported window (each sessions frame repeats it): kept unless known stored. Only a stored (or already present)
38
+ * window is remembered, so one reported first without its start (nothing to store), or whose write failed, is taken
39
+ * again from the next frame. Errors are the caller's.
40
+ */
41
+ async observe(userID: string, sessionID: string, committed: RevertCommitted): Promise<void> {
42
+ if (committed.from === undefined || committed.until <= committed.from) return
43
+ const key = JSON.stringify([userID, sessionID, committed.from, committed.until, committed.at])
44
+ if (this.stored.has(key)) return
45
+ if (await this.record(userID, sessionID, committed)) this.stored.add(key)
46
+ }
47
+
48
+ async list(userID: string, sessionID: string): Promise<readonly RevertWindow[]> {
49
+ return (await this.state.get<Stored>(userID, COLLECTION, sessionID))?.value.windows ?? []
50
+ }
51
+
52
+ /**
53
+ * Keeps a reported window (one without a start removes no rows: skipped). True when it is stored now (new, or
54
+ * already there); false when skipped or the write lost a race (version check).
55
+ */
56
+ async record(userID: string, sessionID: string, committed: RevertCommitted): Promise<boolean> {
57
+ if (committed.from === undefined || committed.until <= committed.from) return false
58
+ const window: RevertWindow = { from: committed.from, until: committed.until, at: committed.at }
59
+ const current = await this.state.get<Stored>(userID, COLLECTION, sessionID)
60
+ const windows = current?.value.windows ?? []
61
+ if (windows.some((w) => w.at === window.at && w.from === window.from && w.until === window.until)) return true
62
+ const next = [...windows, window].slice(-REVERT_WINDOWS_MAX)
63
+ const result = await this.state.put<Stored>(userID, COLLECTION, sessionID, { windows: next }, current ? { ifVersion: current.version } : {})
64
+ return result.ok
65
+ }
66
+ }
@@ -61,9 +61,12 @@ export interface Notifier {
61
61
 
62
62
  /** The spoke answered with an error, or disconnected before answering. */
63
63
  export class SpokeError extends Error {
64
- constructor(message: string) {
64
+ /** The refusal's protocol code (Res.code, e.g. "busy", "not_found"), when the spoke sent one. */
65
+ readonly code?: string
66
+ constructor(message: string, code?: string) {
65
67
  super(message)
66
68
  this.name = "SpokeError"
69
+ if (code !== undefined) this.code = code
67
70
  }
68
71
  }
69
72
 
@@ -518,6 +521,8 @@ export class SpokeHub {
518
521
  const owner = this.options.registry.sessionTenant(sessionID)
519
522
  if (owner !== undefined && owner !== call.tenant) throw new SpokeError("Unknown session")
520
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)
521
526
  return call.kind === "node" ? this.requestNode(call.connID, call.req) : this.request(call.connID, call.req)
522
527
  }
523
528
 
@@ -669,7 +674,7 @@ export class SpokeHub {
669
674
  }
670
675
 
671
676
  /** Resolves or rejects the pending request a `res` frame answers. */
672
- private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string }): void {
677
+ private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string; readonly code?: string }): void {
673
678
  const pending = this.pending.get(message.id)
674
679
  if (!pending || pending.connID !== connID) return
675
680
  this.pending.delete(message.id)
@@ -683,7 +688,7 @@ export class SpokeHub {
683
688
  return pending.resolve(message.result)
684
689
  }
685
690
  if (message.ok) pending.resolve(message.result)
686
- else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`))
691
+ else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`, message.code))
687
692
  }
688
693
 
689
694
  private async answer(ws: Socket, frame: Req<SpokeRequest>): Promise<void> {
@@ -547,7 +547,7 @@ export class TeamStaffingService {
547
547
  /** The launch body: the caller's, with the role's placement/harness/model/profile defaults and team labels. */
548
548
  private async launchSpec(userID: string, team: Team, role: TeamRoleSpec, base: unknown, requestID: string): Promise<Record<string, unknown>> {
549
549
  if (!isObj(base)) throw new AgentError("invalid", "launch must be a launch body (workspace, prompt, …)")
550
- if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send an Idempotency-Key header instead" })
550
+ if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send idempotencyKey (the Idempotency-Key header) instead" })
551
551
  const labels = isObj(base.labels) ? base.labels : base.labels === undefined ? {} : undefined
552
552
  if (!labels) throw new AgentError("invalid", "launch.labels must be an object")
553
553
  if (TEAM_LAUNCH_LABEL in labels || TEAM_REQUEST_LABEL in labels) throw new AgentError("invalid", `launch.labels ${TEAM_LAUNCH_LABEL} and ${TEAM_REQUEST_LABEL} are set by staffing`)
@@ -567,7 +567,7 @@ export class TeamStaffingService {
567
567
  ...(agentProfile !== undefined ? { agentProfile } : {}),
568
568
  labels: { ...labels, [TEAM_LAUNCH_LABEL]: team.id, [TEAM_REQUEST_LABEL]: requestID },
569
569
  }
570
- validateLaunchSpec(spec)
570
+ validateLaunchSpec(spec, { staffing: true })
571
571
  return spec
572
572
  }
573
573
 
package/src/index.ts CHANGED
@@ -27,9 +27,10 @@ import path from "node:path"
27
27
  import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
28
28
  import { PromptReferences } from "./spoke/prompt-refs"
29
29
  import { PROFILE_TOOL, PROFILE_TOOL_DESCRIPTION, PROFILE_TOOL_INPUT } from "./spoke/profile-apply"
30
- import { replyPermission, vcsReader, type PermissionLocation, type VcsReader } from "./spoke/locations"
30
+ import { listedNames, namesReader, replyPermission, serverNames, vcsReader, type PermissionLocation, type VcsReader } from "./spoke/locations"
31
31
  import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
32
32
  import { joinProcess, type Membership } from "./spoke/singleton"
33
+ import { applySessionContext, sortTools } from "./spoke/session-context"
33
34
  import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
34
35
 
35
36
  /** Where the spoke keeps the IDs of Concierge sessions (always restricted to the Concierge's tools). */
@@ -171,15 +172,24 @@ function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
171
172
  function permissionLocation(ctx: Context): PermissionLocation {
172
173
  const permission = ctx.permission as any
173
174
  const vcs = (ctx as any).vcs
175
+ const skill = (ctx as any).skill
176
+ const command = (ctx as any).command
174
177
  return {
175
178
  directory: ctx.location.directory,
176
179
  get: (input) => permission.get(input),
177
180
  reply: (input) => permission.reply(input),
178
- // OpenCode's plugin vcs answers for this instance's location only (whatever location is passed).
181
+ // OpenCode's plugin vcs, skill and command lists answer for this instance's location only (whatever location is passed).
179
182
  ...(vcs?.diff && vcs?.get ? { vcs: vcsOf(vcs, ctx.location.directory) } : {}),
183
+ ...(skill?.list && command?.list ? { names: () => localNamesOf(skill, command, ctx.location.directory) } : {}),
180
184
  }
181
185
  }
182
186
 
187
+ /** A plugin instance's own location's skill and command names (its lists answer only for that location). */
188
+ async function localNamesOf(skill: any, command: any, directory: string) {
189
+ const [skills, commands] = await Promise.all([skill.list({ location: { directory } }), command.list({ location: { directory } })])
190
+ return { skills: listedNames(skills), commands: listedNames(commands) }
191
+ }
192
+
183
193
  /** A vcs API's working-copy changes and current branch of a directory (plugin API or own-server client). */
184
194
  function vcsOf(vcs: any, directory: string) {
185
195
  return {
@@ -206,7 +216,10 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
206
216
  const skill = (ctx as any).skill
207
217
  const command = (ctx as any).command
208
218
  const vcsRead = vcsReader(locations, async () => (canList(kind) ? serverVcs(await ownClient(kind).catch(() => undefined)) : undefined))
209
- const names = (value: unknown) => (unwrap<ReadonlyArray<{ name?: unknown }>>(value, Array.isArray) ?? []).flatMap((item) => (typeof item?.name === "string" ? [item.name] : []))
219
+ const namesRead = namesReader(locations, async () => {
220
+ const client = canList(kind) ? await ownClient(kind).catch(() => undefined) : undefined
221
+ return client?.skill && client?.command ? serverNames(client) : undefined
222
+ })
210
223
  return {
211
224
  directory: ctx.location.directory,
212
225
  opencodeVersion: ctx.app?.version ?? "unknown",
@@ -253,14 +266,9 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
253
266
  unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
254
267
  ...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
255
268
  // Hub profiles (src/spoke/profile-apply.ts): local skills/commands win over a profile's; its own need the tool.
256
- ...(skill?.list && command?.list
257
- ? {
258
- localNames: async (directory: string) => {
259
- const [skills, commands] = await Promise.all([skill.list({ location: { directory } }), command.list({ location: { directory } })])
260
- return { skills: names(skills), commands: names(commands) }
261
- },
262
- }
263
- : {}),
269
+ // Read in the session's own directory (src/spoke/locations.ts); unknown (undefined): the profile's skills and
270
+ // commands are not offered, never checked against another folder's names.
271
+ ...((skill?.list && command?.list) || canList(kind) ? { localNames: (directory: string) => namesRead(directory) } : {}),
264
272
  profileTool: typeof (ctx as any).tool?.transform === "function",
265
273
  // A directory's branch and changes: its own plugin instance, else the own server (src/spoke/locations.ts).
266
274
  ...(vcs?.diff || canList(kind) ? { vcsDiff: (directory: string) => vcsRead.diff(directory), vcsBranch: (directory: string) => vcsRead.branch(directory) } : {}),
@@ -364,12 +372,16 @@ export default definePlugin({
364
372
  // A hub profile: after OpenCode's own parts (local instructions win), for this session only.
365
373
  for (const text of spoke?.profiles.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
366
374
  if (event.tools && !spoke?.profiles.toolVisible(event.sessionID)) delete event.tools[PROFILE_TOOL]
367
- // (CON-04) The hub's session context: its parts after the profile's, its denied tools removed.
368
- for (const text of spoke?.contexts.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
375
+ // (CON-04) The hub's session context: its stable parts after the profile's, its board after the conversation
376
+ // (V-01: the prompt cache survives a board change), its denied tools removed, the kept tools in name order.
377
+ if (spoke) applySessionContext(event as any, spoke.contexts.systemParts(event.sessionID), spoke.contexts.tailParts(event.sessionID))
369
378
  if (event.tools) spoke?.contexts.filterTools(event.sessionID, event.tools as Record<string, unknown>)
370
379
  // The concierge agent keeps exactly CONCIERGE_TOOLS even without a hub context (a spoke that restarted before
371
380
  // the hub re-sent it): OpenCode 2.2.3 offers `skill` whatever the agent's rules say.
372
- if (event.tools && event.agent === CONCIERGE_AGENT) for (const name of Object.keys(event.tools)) if (!(CONCIERGE_TOOLS as readonly string[]).includes(name)) delete event.tools[name]
381
+ if (event.tools && event.agent === CONCIERGE_AGENT) {
382
+ for (const name of Object.keys(event.tools)) if (!(CONCIERGE_TOOLS as readonly string[]).includes(name)) delete event.tools[name]
383
+ sortTools(event.tools as Record<string, unknown>)
384
+ }
373
385
  }),
374
386
  ),
375
387
  // The voice agent stops one worker ("cancel the deploy one") without stopping itself or the others.