@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.
- package/CHANGELOG.md +118 -0
- package/README.md +31 -0
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +35 -7
- package/packages/cli/package.json +1 -1
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +16 -3
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/routes.ts +7 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +85 -9
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +161 -25
- package/src/central/spoke-ws.ts +2 -0
- package/src/index.ts +28 -0
- package/src/spoke/session-context.ts +28 -8
- package/src/spoke/spoke.ts +153 -10
- package/src/spoke/tracker.ts +6 -1
- package/src/status.ts +101 -19
- package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-Cb1DcrQf.js} +1 -1
- package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-XTnwXl-Y.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-C37tZZ3e.js} +1 -1
- package/web/dist/assets/{Library-BXbJvTUS.js → Library-CZSSHvOJ.js} +2 -2
- package/web/dist/assets/ProfilesDestination-dZa3vioh.js +1 -0
- package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-DJt-Kk84.js} +1 -1
- package/web/dist/assets/Watchtower-BXaUaXO1.js +2 -0
- package/web/dist/assets/index-DVSkeAvn.css +1 -0
- package/web/dist/assets/index-DifgVuKS.js +34 -0
- package/web/dist/assets/{live-OgQWPrSY.js → live-D-RZbeUi.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-DILxLFEq.js} +1 -1
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-PvN9Qbt2.js} +4 -56
- package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-BS6ii3M7.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
- package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- 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
|
+
}
|
package/src/central/registry.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// holds the connection; remote state is never re-exported.
|
|
13
13
|
import path from "node:path"
|
|
14
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
|
-
import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
15
|
+
import { BLOCKER_ID_MAX, StatusBoard, askBlocker, 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"
|
|
18
18
|
import { SentenceStream } from "./say-stream"
|
|
@@ -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 {
|
|
@@ -170,6 +187,17 @@ interface PendingPermission {
|
|
|
170
187
|
readonly tenant?: string
|
|
171
188
|
readonly request?: PermissionRequest
|
|
172
189
|
recommendation?: BlockerRecommendation
|
|
190
|
+
/** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
|
|
191
|
+
readonly blocker?: Blocker
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A pending form and the processes that forwarded it. */
|
|
195
|
+
interface PendingForm {
|
|
196
|
+
connIDs: string[]
|
|
197
|
+
readonly sessionID: string
|
|
198
|
+
readonly tenant?: string
|
|
199
|
+
/** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
|
|
200
|
+
readonly blocker?: Blocker
|
|
173
201
|
}
|
|
174
202
|
|
|
175
203
|
/** Characters of a recommendation's author and reason on a blocker. */
|
|
@@ -382,6 +410,8 @@ interface WorkerSpeech {
|
|
|
382
410
|
|
|
383
411
|
/** How many spoken texts, utterances and early deliveries are remembered for dedupe and matching. */
|
|
384
412
|
const MEMORY = 1000
|
|
413
|
+
/** Pending permissions, and pending forms, kept per tenant (oldest dropped). */
|
|
414
|
+
export const PENDING_PER_TENANT = 200
|
|
385
415
|
|
|
386
416
|
/** Adds to a bounded insertion-ordered map, dropping the oldest entry when full. */
|
|
387
417
|
function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void {
|
|
@@ -389,6 +419,12 @@ function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void
|
|
|
389
419
|
if (map.size > limit) map.delete(map.keys().next().value!)
|
|
390
420
|
}
|
|
391
421
|
|
|
422
|
+
/** Adds an ID to a bounded set of IDs (oldest dropped). */
|
|
423
|
+
function rememberID(set: Set<string>, id: string, limit = 1000): void {
|
|
424
|
+
set.add(id)
|
|
425
|
+
if (set.size > limit) set.delete(set.values().next().value!)
|
|
426
|
+
}
|
|
427
|
+
|
|
392
428
|
const eventSessionID = (event: ForwardedEvent): string | undefined => {
|
|
393
429
|
const data = event.data as Record<string, any>
|
|
394
430
|
const id = event.type === "form.created" ? data.form?.sessionID : data.sessionID
|
|
@@ -399,6 +435,9 @@ const eventSessionID = (event: ForwardedEvent): string | undefined => {
|
|
|
399
435
|
const isOn = (s: SessionInfo) => s.exposed !== false
|
|
400
436
|
|
|
401
437
|
export class Registry {
|
|
438
|
+
/** (REC-01) Voice recording taps; optional, best effort. */
|
|
439
|
+
recordingTap?: RecordingTap
|
|
440
|
+
|
|
402
441
|
private readonly conns = new Map<string, Conn>()
|
|
403
442
|
private readonly boards = new Map<string, StatusBoard>()
|
|
404
443
|
/**
|
|
@@ -420,9 +459,11 @@ export class Registry {
|
|
|
420
459
|
private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
|
|
421
460
|
private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
|
|
422
461
|
/** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
|
|
423
|
-
private readonly forms = new Map<string,
|
|
424
|
-
/** Form IDs already alerted (forms may be forwarded by several processes). */
|
|
462
|
+
private readonly forms = new Map<string, PendingForm>()
|
|
463
|
+
/** Form IDs already alerted (forms may be forwarded by several processes, and re-announced after a reconnect). */
|
|
425
464
|
private readonly alertedForms = new Set<string>()
|
|
465
|
+
/** Permission IDs already alerted (a reconnecting spoke re-announces what is still pending). */
|
|
466
|
+
private readonly alertedPermissions = new Set<string>()
|
|
426
467
|
/** Sessions whose current run raised a providerError notification (once per run; cleared when a run starts). */
|
|
427
468
|
private readonly providerAlerted = new Set<string>()
|
|
428
469
|
/** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
|
|
@@ -699,6 +740,8 @@ export class Registry {
|
|
|
699
740
|
const owner = this.sessionTenant(id)
|
|
700
741
|
if (owner !== undefined && owner !== tenant) return
|
|
701
742
|
}
|
|
743
|
+
// A question or permission whose ID no card can carry is never kept (no card, no answer; unbounded memory).
|
|
744
|
+
if (oversizedBlockerID(event.type, data)) return
|
|
702
745
|
if (sessionID) conn.activity.set(sessionID, this.tick())
|
|
703
746
|
if (sessionID && event.type.startsWith("session.execution.")) {
|
|
704
747
|
if (event.type === "session.execution.interrupted" && data.inferred === true) remember(this.inferredEnds, sessionID, this.now())
|
|
@@ -713,37 +756,50 @@ export class Registry {
|
|
|
713
756
|
if (event.type === "permission.asked" && typeof data.id === "string" && sessionID) {
|
|
714
757
|
const known = this.permissions.get(data.id)
|
|
715
758
|
if (known) {
|
|
716
|
-
// The same request forwarded by another process: one more place to answer it
|
|
759
|
+
// The same request forwarded by another process (or re-announced): one more place to answer it, and its
|
|
760
|
+
// blocker back on a status board that lost it.
|
|
717
761
|
if (!known.connIDs.includes(connID)) known.connIDs.push(connID)
|
|
762
|
+
this.observeBoards(event, tenant)
|
|
718
763
|
return
|
|
719
764
|
}
|
|
720
765
|
const request = permissionRequest(data)
|
|
721
|
-
|
|
722
|
-
this.
|
|
766
|
+
const blocker = askBlocker(event)
|
|
767
|
+
this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}), ...(blocker ? { blocker } : {}) })
|
|
768
|
+
this.capPending(this.permissions, tenant, (id) => this.dropPermission(id))
|
|
769
|
+
if (!this.alertedPermissions.has(data.id) && this.alert("permission", sessionID, { requestID: data.id })) rememberID(this.alertedPermissions, data.id)
|
|
723
770
|
}
|
|
724
771
|
if (event.type === "form.created" && sessionID) {
|
|
725
772
|
const formID = (data.form as Record<string, unknown> | undefined)?.id
|
|
726
773
|
if (typeof formID === "string") {
|
|
727
774
|
const known = this.forms.get(formID)
|
|
728
775
|
if (known && !known.connIDs.includes(connID)) known.connIDs.push(connID)
|
|
729
|
-
else if (!known)
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
this.alert("question", sessionID)
|
|
776
|
+
else if (!known) {
|
|
777
|
+
const blocker = askBlocker(event)
|
|
778
|
+
this.forms.set(formID, { connIDs: [connID], sessionID, tenant, ...(blocker ? { blocker } : {}) })
|
|
779
|
+
this.capPending(this.forms, tenant, (id) => this.dropForm(id))
|
|
780
|
+
}
|
|
735
781
|
}
|
|
782
|
+
if (typeof formID === "string" && !this.alertedForms.has(formID) && this.alert("question", sessionID)) rememberID(this.alertedForms, formID)
|
|
736
783
|
}
|
|
737
784
|
let closedPermission: [string, PendingPermission] | undefined
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
785
|
+
const permissionClose = event.type === "permission.replied" && typeof data.requestID === "string"
|
|
786
|
+
const formClose = (event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string"
|
|
787
|
+
if (permissionClose || formClose) {
|
|
788
|
+
const id = (permissionClose ? data.requestID : data.id) as string
|
|
789
|
+
const map: Map<string, PendingPermission | PendingForm> = permissionClose ? this.permissions : this.forms
|
|
790
|
+
const origin = map.get(id)
|
|
791
|
+
// Only the tenant that asked closes it (IDs are not secret enough to let another tenant end it).
|
|
792
|
+
const own = origin !== undefined && (origin.tenant === undefined || origin.tenant === tenant)
|
|
793
|
+
if (own && data.unlisted === true) {
|
|
794
|
+
// A spoke found it no longer listed by its own harness: only that process stops holding it.
|
|
795
|
+
origin.connIDs = origin.connIDs.filter((c) => c !== connID)
|
|
796
|
+
if (origin.connIDs.length) return
|
|
797
|
+
}
|
|
798
|
+
if (own && map.delete(id) && permissionClose) closedPermission = [id, origin as PendingPermission]
|
|
744
799
|
}
|
|
745
800
|
|
|
746
801
|
if (sessionID) this.trackRun(event.type, sessionID)
|
|
802
|
+
if (sessionID && this.recordingTap && TAPPED.has(event.type)) this.tap(connID, tenant, sessionID, event.type, data)
|
|
747
803
|
const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
|
|
748
804
|
this.observeBoards(event, tenant)
|
|
749
805
|
if (sessionID) this.activitySteps(event.type, sessionID, data, before)
|
|
@@ -769,6 +825,31 @@ export class Registry {
|
|
|
769
825
|
if (closedPermission) this.permissionClosed(...closedPermission)
|
|
770
826
|
}
|
|
771
827
|
|
|
828
|
+
/** (REC-01) Hands a root session's event to the recording tap once: from the session's owner connection only. */
|
|
829
|
+
private tap(connID: string, tenant: string | undefined, sessionID: string, type: string, data: Record<string, unknown>): void {
|
|
830
|
+
const tap = this.recordingTap
|
|
831
|
+
if (!tap || tenant === undefined) return
|
|
832
|
+
try {
|
|
833
|
+
if (!tap.wants(tenant, sessionID, type) || this.rootOf(sessionID) !== sessionID || this.owner(sessionID) !== connID) return
|
|
834
|
+
tap.event(tenant, sessionID, type, data)
|
|
835
|
+
} catch (cause) {
|
|
836
|
+
console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** (REC-01) A tap call that never throws into the registry. */
|
|
841
|
+
private tapSafely(call: (tap: RecordingTap, tenant: string) => void, sessionID: string): void {
|
|
842
|
+
const tap = this.recordingTap
|
|
843
|
+
if (!tap) return
|
|
844
|
+
const tenant = this.sessionTenant(sessionID)
|
|
845
|
+
if (tenant === undefined) return
|
|
846
|
+
try {
|
|
847
|
+
call(tap, tenant)
|
|
848
|
+
} catch (cause) {
|
|
849
|
+
console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
772
853
|
/** Records when a session's run started (kept by the first process to report it while it runs). */
|
|
773
854
|
private trackRun(type: string, sessionID: string): void {
|
|
774
855
|
if (type === "session.execution.started") {
|
|
@@ -816,8 +897,10 @@ export class Registry {
|
|
|
816
897
|
|
|
817
898
|
/** Remembers an utterance's inbox item so its delivery is reported as a heard event. */
|
|
818
899
|
trackUtterance(sessionID: string, inboxID: string): void {
|
|
819
|
-
if (this.deliveries.delete(inboxID))
|
|
820
|
-
|
|
900
|
+
if (this.deliveries.delete(inboxID)) {
|
|
901
|
+
this.emit({ type: "heard", sessionID, inboxID })
|
|
902
|
+
this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
|
|
903
|
+
} else remember(this.utterances, inboxID, sessionID)
|
|
821
904
|
}
|
|
822
905
|
|
|
823
906
|
/**
|
|
@@ -841,6 +924,7 @@ export class Registry {
|
|
|
841
924
|
if (sessionID === undefined) return remember(this.deliveries, inboxID, true)
|
|
842
925
|
this.utterances.delete(inboxID)
|
|
843
926
|
this.emit({ type: "heard", sessionID, inboxID })
|
|
927
|
+
this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
|
|
844
928
|
}
|
|
845
929
|
|
|
846
930
|
/** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
|
|
@@ -872,13 +956,16 @@ export class Registry {
|
|
|
872
956
|
const state = this.rootSpeech.get(key) ?? { sessionID, connID: "", stream: new SentenceStream(), seq: 0 }
|
|
873
957
|
this.rootSpeech.delete(key)
|
|
874
958
|
for (const sentence of state.stream.end(text)) this.emitSentence(state, messageID, ordinal, sentence)
|
|
959
|
+
this.tapSafely((tap, tenant) => tap.reply(tenant, sessionID, messageID, ordinal, text), sessionID)
|
|
875
960
|
// Nobody is listening on a phone: push the whole reply so it reaches a locked screen.
|
|
876
961
|
if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
|
|
877
962
|
}
|
|
878
963
|
|
|
879
964
|
private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
|
|
880
965
|
this.heardFrom(state.sessionID, text)
|
|
881
|
-
|
|
966
|
+
const seq = state.seq++
|
|
967
|
+
this.emit({ type: "say", sessionID: state.sessionID, messageID, ordinal, seq, text })
|
|
968
|
+
this.tapSafely((tap, tenant) => tap.sentence(tenant, state.sessionID, messageID, ordinal, seq, text), state.sessionID)
|
|
882
969
|
}
|
|
883
970
|
|
|
884
971
|
/** The run stopped or conversation mode ended: the rest of its text parts is not spoken. */
|
|
@@ -1808,6 +1895,19 @@ export class Registry {
|
|
|
1808
1895
|
}
|
|
1809
1896
|
}
|
|
1810
1897
|
|
|
1898
|
+
/**
|
|
1899
|
+
* The user dismissed the session's last run failure: its status board stops showing it (consoles and the Concierge
|
|
1900
|
+
* see the state leave "error") until it fails again. True when there was one to clear.
|
|
1901
|
+
*/
|
|
1902
|
+
dismissError(sessionID: string): boolean {
|
|
1903
|
+
const rootID = this.rootOf(sessionID)
|
|
1904
|
+
const board = rootID === undefined ? undefined : this.boards.get(rootID)
|
|
1905
|
+
if (rootID === undefined || !board?.observe({ type: "session.error.dismissed", data: { sessionID } })) return false
|
|
1906
|
+
this.changed(this.sessionTenant(rootID))
|
|
1907
|
+
this.emit({ type: "status", sessionID: rootID, sessions: this.live(board.snapshot().sessions) })
|
|
1908
|
+
return true
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1811
1911
|
/** After a stop or interrupt: releases the turn locks in the session's tree and ends their waiting requests. */
|
|
1812
1912
|
cancelTurns(sessionID: string): void {
|
|
1813
1913
|
const rootID = this.rootOf(sessionID) ?? sessionID
|
|
@@ -1886,6 +1986,33 @@ export class Registry {
|
|
|
1886
1986
|
if (next.length === pending.length) break
|
|
1887
1987
|
pending = next
|
|
1888
1988
|
}
|
|
1989
|
+
this.restoreBlockers()
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/** Keeps at most PENDING_PER_TENANT entries of the tenant in a pending map: the oldest are dropped (with their blocker). */
|
|
1993
|
+
private capPending(map: Map<string, PendingPermission | PendingForm>, tenant: string, drop: (id: string) => void): void {
|
|
1994
|
+
const own = [...map].filter(([, origin]) => (origin.tenant ?? this.sessionTenant(origin.sessionID)) === tenant)
|
|
1995
|
+
for (const [id] of own.slice(0, Math.max(0, own.length - PENDING_PER_TENANT))) drop(id)
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/**
|
|
1999
|
+
* Puts pending permissions and forms back on the status boards that lost them: a board recreated after its
|
|
2000
|
+
* session left the reported sessions for a moment, or one created after the question was asked (events reach
|
|
2001
|
+
* only existing boards). OpenCode still waits for them; only a reply, a cancel or their processes going away
|
|
2002
|
+
* end them (production 2026-11-05: a question card vanished while the session still waited).
|
|
2003
|
+
*/
|
|
2004
|
+
private restoreBlockers(): void {
|
|
2005
|
+
const restored = new Set<string>()
|
|
2006
|
+
const restore = ({ sessionID, tenant, blocker }: PendingPermission | PendingForm) => {
|
|
2007
|
+
if (!blocker) return
|
|
2008
|
+
const rootID = this.rootOf(sessionID)
|
|
2009
|
+
const board = rootID === undefined ? undefined : this.boards.get(rootID)
|
|
2010
|
+
if (!rootID || !board || (tenant !== undefined && this.sessionTenant(rootID) !== tenant)) return
|
|
2011
|
+
if (board.restoreBlocker(sessionID, blocker)) restored.add(rootID)
|
|
2012
|
+
}
|
|
2013
|
+
for (const origin of this.permissions.values()) restore(origin)
|
|
2014
|
+
for (const origin of this.forms.values()) restore(origin)
|
|
2015
|
+
for (const rootID of restored) this.emit({ type: "status", sessionID: rootID, sessions: this.live(this.boards.get(rootID)!.snapshot().sessions) })
|
|
1889
2016
|
}
|
|
1890
2017
|
|
|
1891
2018
|
// ---------- cluster (design §9) ----------
|
|
@@ -1913,7 +2040,7 @@ export class Registry {
|
|
|
1913
2040
|
const add = (tenant: string | undefined) => tenant !== undefined && tenants.add(tenant)
|
|
1914
2041
|
for (const rootID of this.boards.keys()) add(this.sessionTenant(rootID))
|
|
1915
2042
|
for (const origin of this.permissions.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
|
|
1916
|
-
for (const origin of this.forms.values()) add(this.sessionTenant(origin.sessionID))
|
|
2043
|
+
for (const origin of this.forms.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
|
|
1917
2044
|
for (const entry of this.presence.values()) add(this.sessionTenant(entry.sessionID))
|
|
1918
2045
|
for (const sessionID of this.active.keys()) add(this.sessionTenant(sessionID))
|
|
1919
2046
|
return [...tenants]
|
|
@@ -2048,11 +2175,12 @@ export class Registry {
|
|
|
2048
2175
|
* Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
|
|
2049
2176
|
* session some device shows (spoken replies check that themselves: the watching device speaks them).
|
|
2050
2177
|
*/
|
|
2051
|
-
|
|
2178
|
+
/** True when it was raised (or skipped because a device shows the session); false for a session on no board. */
|
|
2179
|
+
private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): boolean {
|
|
2052
2180
|
const entry = [...this.boards].find(([, board]) => board.get(sessionID))
|
|
2053
|
-
if (!entry) return
|
|
2181
|
+
if (!entry) return false
|
|
2054
2182
|
const [rootID, board] = entry
|
|
2055
|
-
if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return
|
|
2183
|
+
if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return true
|
|
2056
2184
|
const alert: Alert = {
|
|
2057
2185
|
kind,
|
|
2058
2186
|
sessionID: rootID,
|
|
@@ -2065,6 +2193,7 @@ export class Registry {
|
|
|
2065
2193
|
}
|
|
2066
2194
|
const owner = this.sessionTenant(rootID)
|
|
2067
2195
|
for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
|
|
2196
|
+
return true
|
|
2068
2197
|
}
|
|
2069
2198
|
|
|
2070
2199
|
private emit(message: RegistryMessage): void {
|
|
@@ -2072,3 +2201,10 @@ export class Registry {
|
|
|
2072
2201
|
for (const listener of this.listeners) listener(message, tenant)
|
|
2073
2202
|
}
|
|
2074
2203
|
}
|
|
2204
|
+
|
|
2205
|
+
/** A permission.asked / form.created (or their closes) with an ID longer than any card keeps. */
|
|
2206
|
+
function oversizedBlockerID(type: string, data: Record<string, unknown>): boolean {
|
|
2207
|
+
const ids =
|
|
2208
|
+
type === "permission.asked" ? [data.id] : type === "form.created" ? [(data.form as Record<string, unknown> | undefined)?.id] : []
|
|
2209
|
+
return ids.some((id) => typeof id === "string" && id.length > BLOCKER_ID_MAX)
|
|
2210
|
+
}
|
package/src/central/spoke-ws.ts
CHANGED
|
@@ -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
|
|
package/src/index.ts
CHANGED
|
@@ -162,6 +162,33 @@ async function activity(kind: "service" | "standalone", directories: readonly st
|
|
|
162
162
|
return { active: Object.keys(active ?? {}), jobs }
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* What OpenCode waits for in each session on this process's own server: its pending forms (session.form.list) and
|
|
167
|
+
* permission requests (session.permission.list). A session that cannot be read is left out; nothing when the
|
|
168
|
+
* server cannot be reached.
|
|
169
|
+
*/
|
|
170
|
+
async function pending(kind: "service" | "standalone", sessionIDs: readonly string[]) {
|
|
171
|
+
const client = await ownClient(kind).catch(() => undefined)
|
|
172
|
+
if (!client) return []
|
|
173
|
+
const out: Array<{ sessionID: string; forms: unknown[]; permissions: unknown[] }> = []
|
|
174
|
+
// One budget for the whole listing (the spoke gives up after 5 s too): a hung server stops being asked.
|
|
175
|
+
const signal = AbortSignal.timeout(PENDING_LIST_TIMEOUT_MS)
|
|
176
|
+
for (const sessionID of sessionIDs) {
|
|
177
|
+
if (signal.aborted) break
|
|
178
|
+
try {
|
|
179
|
+
const [forms, permissions] = await Promise.all([client.session.form.list({ sessionID }, { signal }), client.permission.list({ sessionID }, { signal })])
|
|
180
|
+
const list = (v: unknown) => unwrap<unknown[]>(v, Array.isArray)
|
|
181
|
+
out.push({ sessionID, forms: Array.isArray(list(forms)) ? list(forms) : [], permissions: Array.isArray(list(permissions)) ? list(permissions) : [] })
|
|
182
|
+
} catch {
|
|
183
|
+
// a session this server does not have (any more)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** How long one listing of pending forms and permissions may take on the own server. */
|
|
190
|
+
const PENDING_LIST_TIMEOUT_MS = 5000
|
|
191
|
+
|
|
165
192
|
/** Plugin API results may arrive bare or wrapped as { location, data }. */
|
|
166
193
|
function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
|
|
167
194
|
const v = value as any
|
|
@@ -253,6 +280,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
253
280
|
},
|
|
254
281
|
...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
|
|
255
282
|
activity: (directories) => activity(kind, directories),
|
|
283
|
+
pending: (sessionIDs) => pending(kind, sessionIDs),
|
|
256
284
|
// Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
|
|
257
285
|
...(canList(kind)
|
|
258
286
|
? {
|