dsh-realtime-audio-ws 0.2.2 → 0.2.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/src/control.ts ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * The control channel: verbs, sent as text frames on the socket that already carries audio.
3
+ *
4
+ * Until this module there was no way to drive the plugin without a console. The socket was duplex and
5
+ * carried PCM16 in both directions, and a text frame was explicitly *not* part of its contract; the
6
+ * client half published itself on a `globalThis` key and that global was the on-switch. Two restarts
7
+ * and a false lead went into changing which session the voice steered, because the config was read at
8
+ * boot and only at boot.
9
+ *
10
+ * So this is a deliberate widening of a documented contract, and it is written as a **channel** rather
11
+ * than as a second API: one text frame in, exactly one text frame out, on the socket the client already
12
+ * holds.
13
+ *
14
+ * Five properties are load-bearing.
15
+ *
16
+ * - **Every frame is answered.** A control frame the channel cannot parse is answered with the reason,
17
+ * not dropped — the same rule `set` follows one layer down. A channel that reports nothing is
18
+ * indistinguishable from one that never received the frame.
19
+ * - **The reply is JSON, and small.** It carries the values a status surface needs and no prose to
20
+ * parse: `ok`, the verb as it was sent, and the fields for that verb.
21
+ * - **`steer` is a resolution, not a special case.** The verb addresses whichever plugin declares a live
22
+ * `sessionId`; it refuses with a reason rather than guessing when none or several do.
23
+ * - **`set` goes through the settings surface and nowhere else**, so a change on this channel takes the
24
+ * identical path — same parse, same refusal, same journal entry — as one from any other control plane.
25
+ * - **The verb vocabulary is closed.** A typo answers with the verbs that exist, because a silent
26
+ * fall-through is how a user concludes the feature is missing.
27
+ *
28
+ * @module dsh-realtime-audio-ws/control
29
+ */
30
+
31
+ import type { Journal, JournalEntry, RealtimeSettingInfo, RealtimeSettingRefusalCode, RealtimeSettings } from 'dsh-realtime'
32
+ import { redact } from 'dsh-realtime'
33
+ import type { RealtimeSessionRefusal, RealtimeSessionRequestOutcome, RealtimeVoiceStatus } from 'dsh-realtime-agent'
34
+
35
+ /** The verbs this channel answers. Closed on purpose: see the module note. */
36
+ export type ControlVerb = 'status' | 'start' | 'stop' | 'steer' | 'set'
37
+
38
+ /**
39
+ * Why a control frame was refused.
40
+ *
41
+ * The three settings codes are carried through unchanged, because they are the seam's vocabulary and a
42
+ * client should branch on one set of codes rather than on a translation of them.
43
+ */
44
+ export type ControlRefusalCode =
45
+ /** The first word is not a verb this channel has. */
46
+ | 'UNKNOWN_VERB'
47
+ /** The frame is malformed: a missing argument, an unexpected one, or a `set` with no `=`. */
48
+ | 'INVALID_CONTROL'
49
+ /** `steer` found no plugin declaring a live `sessionId`. */
50
+ | 'NO_STEER_TARGET'
51
+ /** `steer` found more than one, so the key to change is the caller's to name. */
52
+ | 'AMBIGUOUS_STEER_TARGET'
53
+ /** The verb could not be carried out at all — nothing answered a query it needs, or it threw. */
54
+ | 'CONTROL_FAILED'
55
+ /** No setting is registered under that key. */
56
+ | Extract<RealtimeSettingRefusalCode, 'UNKNOWN_SETTING'>
57
+ /** The setting exists and its class cannot honour a change: it needs a reconnect, or a restart. */
58
+ | Extract<RealtimeSettingRefusalCode, 'FROZEN_SETTING'>
59
+ /** The value did not parse, or the setting's own rules rejected it. */
60
+ | Extract<RealtimeSettingRefusalCode, 'INVALID_SETTING'>
61
+
62
+ /** One parsed control frame. */
63
+ export type ControlCommand =
64
+ | { readonly ok: true; readonly verb: 'status' | 'start' | 'stop' }
65
+ | { readonly ok: true; readonly verb: 'steer'; readonly sessionId: string }
66
+ | { readonly ok: true; readonly verb: 'set'; readonly key: string; readonly value: string }
67
+ | { readonly ok: false; readonly verb: string; readonly code: ControlRefusalCode; readonly reason: string }
68
+
69
+ /** A refusal, as any verb can produce one. */
70
+ export interface ControlRefusedReply {
71
+ readonly ok: false
72
+ /** The verb as it was sent — including a word that is not a verb, so a client can pair the reply. */
73
+ readonly verb: string
74
+ readonly code: ControlRefusalCode
75
+ readonly reason: string
76
+ /** The setting a `steer` or `set` addressed, when one was addressed. */
77
+ readonly key?: string
78
+ }
79
+
80
+ /** `status`: what this route is carrying, what the voice is doing, and what a change can reach. */
81
+ export interface ControlStatusReply {
82
+ readonly ok: true
83
+ readonly verb: 'status'
84
+ /** This route's own facts. */
85
+ readonly audio: { readonly path: string; readonly clients: number }
86
+ /**
87
+ * The voice session's state, or `null` when **no plugin answered the query** — which is what a
88
+ * composition with no agent row looks like, and is deliberately not the same as a session that is
89
+ * merely closed (that answers `open: false`).
90
+ */
91
+ readonly voice: RealtimeVoiceStatus | null
92
+ /** Every setting the running plugins declare, in registration order, with the values read now. */
93
+ readonly settings: readonly RealtimeSettingInfo[]
94
+ /** The journal's size and its last entry — enough to see that *something* just happened. */
95
+ readonly journal: { readonly size: number; readonly oldestSeq: number | undefined; readonly last: JournalEntry | null }
96
+ }
97
+
98
+ /** `start` / `stop`: what the request produced, not merely that it was made. */
99
+ export interface ControlSessionReply {
100
+ readonly ok: boolean
101
+ readonly verb: 'start' | 'stop'
102
+ /** The state **after** the attempt. */
103
+ readonly voice: RealtimeVoiceStatus
104
+ /** Present when the request did not achieve what it asked for. */
105
+ readonly refusal?: RealtimeSessionRefusal
106
+ }
107
+
108
+ /** `set` / `steer`: the change that landed, and the value the setting now holds. */
109
+ export interface ControlSettingReply {
110
+ readonly ok: true
111
+ readonly verb: 'set' | 'steer'
112
+ readonly key: string
113
+ /** What the setting is now — re-read from its owner, and `undefined` when the setting is write-only. */
114
+ readonly value: unknown
115
+ }
116
+
117
+ /** What one control frame produced. */
118
+ export type ControlReply = ControlRefusedReply | ControlStatusReply | ControlSessionReply | ControlSettingReply
119
+
120
+ /** What {@link createControlHandler} needs from the plugin that owns the route. */
121
+ export interface ControlDeps {
122
+ /** The seam's settings surface: the only path a change takes. */
123
+ readonly settings: Pick<RealtimeSettings, 'list' | 'apply'>
124
+ /** The seam's journal, for the size and the last entry a status reports. */
125
+ readonly journal: Pick<Journal, 'snapshot'>
126
+ /** This route's own pathname, reported as-is. */
127
+ readonly path: string
128
+ /** How many audio sockets this route is carrying right now. */
129
+ readonly clients: () => number
130
+ /**
131
+ * The voice session's state.
132
+ *
133
+ * `undefined` means nothing answered; a rejection means something answered badly, and the two are kept
134
+ * apart — the first is a composition without the agent, the second is a finding.
135
+ */
136
+ readonly voice: () => Promise<RealtimeVoiceStatus | undefined>
137
+ /** Run one of the two session requests the transport already makes. */
138
+ readonly request: (verb: 'start' | 'stop') => Promise<RealtimeSessionRequestOutcome | undefined>
139
+ }
140
+
141
+ /** The verbs, as a value, so an unknown one can be answered with the list. */
142
+ const VERBS: readonly ControlVerb[] = Object.freeze(['status', 'start', 'stop', 'steer', 'set'])
143
+
144
+ /**
145
+ * Read one text frame as a control command.
146
+ *
147
+ * A control frame is a **line**, so its line ending is stripped and nothing else is: `set x= 5` and
148
+ * `set x=5` are two different requests, and a channel that normalised one into the other would apply a
149
+ * change the caller did not ask for. For the same reason the verbs are matched exactly — an `INVALID_CONTROL`
150
+ * for `STATUS` is a better answer than a guess about intent, because it can be read and fixed.
151
+ *
152
+ * @param frame - the frame as it arrived, without its line ending.
153
+ * @returns the command, or a refusal naming the frame's fault.
154
+ */
155
+ export function parseControlFrame(frame: string): ControlCommand {
156
+ // The line ending and leading whitespace are the only things stripped: a control frame is a line, and a
157
+ // shell or a console may well hand over a leading space. Everything after the first `=` is left
158
+ // untouched, because `set x= 5` and `set x=5` are two different requests and a channel that normalised
159
+ // one into the other would apply a change the caller did not ask for.
160
+ const line = frame.replace(/[\r\n]+$/u, '').trimStart()
161
+ const split = line.search(/\s/u)
162
+ const verb = split < 0 ? line : line.slice(0, split)
163
+ const tail = split < 0 ? '' : line.slice(split)
164
+
165
+ const known = VERBS.find(candidate => candidate === verb)
166
+ if (known === undefined) {
167
+ return {
168
+ ok: false,
169
+ verb,
170
+ code: 'UNKNOWN_VERB',
171
+ reason: `"${verb}" is not a control verb — this channel answers ${VERBS.join(', ')}`,
172
+ }
173
+ }
174
+ if (known === 'status' || known === 'start' || known === 'stop') {
175
+ if (tail.trim() !== '') {
176
+ return { ok: false, verb: known, code: 'INVALID_CONTROL', reason: `"${known}" takes no argument, received ${quote(tail.trim())}` }
177
+ }
178
+ return { ok: true, verb: known }
179
+ }
180
+ if (known === 'steer') {
181
+ const sessionId = tail.trim()
182
+ if (sessionId === '') {
183
+ return { ok: false, verb: known, code: 'INVALID_CONTROL', reason: '"steer" needs the session id to steer at' }
184
+ }
185
+ return { ok: true, verb: known, sessionId }
186
+ }
187
+ // `set`: the key ends at the **first** `=`, and the value is everything after it. A value is allowed to
188
+ // contain `=` — and a split on the last one would silently cut a session id in half.
189
+ const argument = tail.trimStart()
190
+ const equals = argument.indexOf('=')
191
+ const key = equals < 0 ? '' : argument.slice(0, equals).trim()
192
+ if (equals < 0 || key === '') {
193
+ return { ok: false, verb: known, code: 'INVALID_CONTROL', reason: '"set" needs <key>=<value>' }
194
+ }
195
+ return { ok: true, verb: known, key, value: argument.slice(equals + 1) }
196
+ }
197
+
198
+ /**
199
+ * Build the channel's handler.
200
+ *
201
+ * The returned function is **total**: every frame it is given produces exactly one reply string, and it
202
+ * never rejects. That is a contract, not a hope — the caller writes the reply into a socket and has no
203
+ * one to catch a rejection, and a control frame that produces nothing at all is the silence this
204
+ * project keeps having to pay for.
205
+ *
206
+ * @param deps - the settings surface, the request edges, and this route's own facts.
207
+ * @returns a handler taking one frame and answering with the reply to send.
208
+ */
209
+ export function createControlHandler(deps: ControlDeps): (frame: string) => Promise<string> {
210
+ return async (frame: string): Promise<string> => {
211
+ try {
212
+ return encode(await dispatch(deps, frame))
213
+ } catch (error) {
214
+ // Reachable through any of the deps, not just the obvious one: a status query that throws, a
215
+ // count that throws. Refused rather than swallowed, because the alternative is a client waiting
216
+ // for an answer that will never come.
217
+ return encode({
218
+ ok: false,
219
+ verb: '',
220
+ code: 'CONTROL_FAILED',
221
+ reason: `the control handler failed: ${classOf(error)}`,
222
+ })
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Carry out one frame's command.
229
+ * @param deps - the handler's dependencies.
230
+ * @param frame - the frame as it arrived.
231
+ * @returns the reply to send.
232
+ */
233
+ async function dispatch(deps: ControlDeps, frame: string): Promise<ControlReply> {
234
+ const command = parseControlFrame(frame)
235
+ if (!command.ok) return command
236
+ switch (command.verb) {
237
+ case 'status': return await statusReply(deps)
238
+ case 'start':
239
+ case 'stop': return await sessionReply(deps, command.verb)
240
+ case 'steer': return steer(deps, command.sessionId)
241
+ case 'set': return change(deps, command.key, command.value, 'set')
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Answer `status`.
247
+ *
248
+ * A status that quietly omitted whether voice is live would be the same class of report this project
249
+ * exists to replace, so a query that *fails* refuses the whole verb: a partial status is indistinguishable
250
+ * from a healthy one to whoever is reading it in a bug report.
251
+ * @param deps - the handler's dependencies.
252
+ * @returns the status, or the refusal explaining why there is none.
253
+ */
254
+ async function statusReply(deps: ControlDeps): Promise<ControlReply> {
255
+ let voice: RealtimeVoiceStatus | null
256
+ try {
257
+ voice = await deps.voice() ?? null
258
+ } catch (error) {
259
+ return {
260
+ ok: false,
261
+ verb: 'status',
262
+ code: 'CONTROL_FAILED',
263
+ reason: `the voice status query failed: ${classOf(error)}`,
264
+ }
265
+ }
266
+ const entries = deps.journal.snapshot()
267
+ return {
268
+ ok: true,
269
+ verb: 'status',
270
+ audio: { path: deps.path, clients: deps.clients() },
271
+ voice,
272
+ settings: deps.settings.list(),
273
+ journal: {
274
+ size: entries.length,
275
+ oldestSeq: entries[0]?.seq,
276
+ last: entries.at(-1) ?? null,
277
+ },
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Answer `start` or `stop`.
283
+ *
284
+ * These produce an **outcome** rather than an acknowledgement, which is why the dispatch is `serial` and
285
+ * not `emit`: "asked" and "it worked" are the two things this project has already collapsed once, in the
286
+ * delegation path, at the cost of an evening.
287
+ * @param deps - the handler's dependencies.
288
+ * @param verb - which request to make.
289
+ * @returns what the request produced.
290
+ */
291
+ async function sessionReply(deps: ControlDeps, verb: 'start' | 'stop'): Promise<ControlReply> {
292
+ let outcome: RealtimeSessionRequestOutcome | undefined
293
+ try {
294
+ outcome = await deps.request(verb)
295
+ } catch (error) {
296
+ return {
297
+ ok: false,
298
+ verb,
299
+ code: 'CONTROL_FAILED',
300
+ reason: `the ${verb} request failed: ${classOf(error)}`,
301
+ }
302
+ }
303
+ if (outcome === undefined) {
304
+ // No listener: the agent row is absent or has not loaded. Saying so is the difference between a
305
+ // profile that is missing a row and a plugin that is broken.
306
+ return {
307
+ ok: false,
308
+ verb,
309
+ code: 'CONTROL_FAILED',
310
+ reason: `nothing answered the ${verb} request — is a realtime agent mounted in this profile?`,
311
+ }
312
+ }
313
+ return {
314
+ ok: outcome.ok,
315
+ verb,
316
+ voice: outcome.voice,
317
+ ...outcome.refusal === undefined ? {} : { refusal: outcome.refusal },
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Resolve `steer <sessionId>` to a setting and change it.
323
+ *
324
+ * The verb names a *purpose* rather than a key, because the purpose is the thing a user has: "make the
325
+ * voice talk to that session". Which key that is belongs to whichever plugin declares it, so this
326
+ * resolves at the moment of use and refuses when the answer is not unique.
327
+ * @param deps - the handler's dependencies.
328
+ * @param sessionId - the session to steer at.
329
+ * @returns the change's outcome, or the refusal explaining why there is no single target.
330
+ */
331
+ function steer(deps: ControlDeps, sessionId: string): ControlReply {
332
+ const targets = deps.settings
333
+ .list()
334
+ .filter(entry => entry.field === 'sessionId' && entry.scope === 'live' && entry.kind === 'string')
335
+ const only = targets[0]
336
+ if (only === undefined) {
337
+ return {
338
+ ok: false,
339
+ verb: 'steer',
340
+ code: 'NO_STEER_TARGET',
341
+ reason: 'no plugin declares a live sessionId to steer — use set <key>=<value>',
342
+ }
343
+ }
344
+ if (targets.length > 1) {
345
+ return {
346
+ ok: false,
347
+ verb: 'steer',
348
+ code: 'AMBIGUOUS_STEER_TARGET',
349
+ reason: `${String(targets.length)} plugins declare a sessionId (${targets.map(entry => entry.key).join(', ')}) — use set <key>=<value>`,
350
+ }
351
+ }
352
+ return change(deps, only.key, sessionId, 'steer')
353
+ }
354
+
355
+ /**
356
+ * Apply one change through the settings surface.
357
+ * @param deps - the handler's dependencies.
358
+ * @param key - the `<owner>.<field>` key.
359
+ * @param value - the value as text.
360
+ * @param verb - which verb asked, so the reply can be paired with it.
361
+ * @returns the change that landed, or the refusal with its reason.
362
+ */
363
+ function change(deps: ControlDeps, key: string, value: string, verb: 'set' | 'steer'): ControlReply {
364
+ const outcome = deps.settings.apply(key, value)
365
+ if (!outcome.ok) {
366
+ return { ok: false, verb, code: outcome.code, key: outcome.key, reason: outcome.reason }
367
+ }
368
+ return { ok: true, verb, key: outcome.key, value: outcome.value }
369
+ }
370
+
371
+ /**
372
+ * Serialise one reply.
373
+ * @param reply - the reply to send.
374
+ * @returns the text frame.
375
+ */
376
+ function encode(reply: ControlReply): string {
377
+ return JSON.stringify(reply)
378
+ }
379
+
380
+ /**
381
+ * Name a failure without repeating it.
382
+ *
383
+ * A class, never a message: the message is where a provider puts the key it refused, and this module
384
+ * holds no credential to redact against.
385
+ * @param error - whatever was thrown.
386
+ * @returns the failure's class, or its type for a non-Error throw.
387
+ */
388
+ function classOf(error: unknown): string {
389
+ return error instanceof Error ? error.name : typeof error
390
+ }
391
+
392
+ /**
393
+ * Quote part of a frame back, redacted and bounded.
394
+ *
395
+ * Redacted because the echo travels to whoever sent the frame and into whatever they render it in; a
396
+ * caller that pasted a credential to the wrong verb should not have it handed back for display.
397
+ * @param text - the text to quote.
398
+ * @returns a short, safe quotation.
399
+ */
400
+ function quote(text: string): string {
401
+ return JSON.stringify(redact(text).slice(0, 40))
402
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import type { Duplex } from 'node:stream'
22
22
  // Type-only: pulls the `Events` augmentation that declares the two audio events this package bridges.
23
23
  import type {} from 'dsh-realtime-agent'
24
24
  import { attachAudioSocket } from './bridge.ts'
25
+ import { createControlHandler } from './control.ts'
25
26
  import { diagnosticsRoute } from './diagnostics.ts'
26
27
  import {
27
28
  createRouteToken,
@@ -29,8 +30,9 @@ import {
29
30
  routeInjectionRow,
30
31
  tokenFromUrl,
31
32
  verdictFor,
32
- type InjectedGlobalRow,
33
+ type InjectedRow,
33
34
  } from './injection.ts'
35
+ import { stripRows } from './strip.ts'
34
36
  import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.ts'
35
37
  import {
36
38
  DEFAULT_DIAGNOSTICS_PATH,
@@ -51,9 +53,26 @@ export * from './types.ts'
51
53
  * stays where it is.
52
54
  */
53
55
  export { TOKEN_PARAM } from './injection.ts'
54
- export { attachAudioSocket, toBytes, type AudioSocketBridgeDeps } from './bridge.ts'
56
+ export { attachAudioSocket, toBytes, toText, type AudioSocketBridgeDeps } from './bridge.ts'
57
+ export {
58
+ createControlHandler,
59
+ parseControlFrame,
60
+ type ControlCommand,
61
+ type ControlDeps,
62
+ type ControlRefusedReply,
63
+ type ControlRefusalCode,
64
+ type ControlReply,
65
+ type ControlSessionReply,
66
+ type ControlSettingReply,
67
+ type ControlStatusReply,
68
+ type ControlVerb,
69
+ } from './control.ts'
55
70
  export { createUpgradeAcceptor, rejectUpgrade, type UpgradeAcceptor } from './upgrade.ts'
56
71
  export { diagnosticsRoute, type DiagnosticsDeps, type DiagnosticsJournal, type DiagnosticsRoute } from './diagnostics.ts'
72
+ export { STRIP_ELEMENT_ID, stripBootstrap, stripMarkup, stripRows } from './strip.ts'
73
+ export type { InjectedGlobalRow, InjectedRow } from './injection.ts'
74
+
75
+ import { createJournalFileSink } from './journal-file.ts'
57
76
 
58
77
  /** Plugin name. */
59
78
  export const name = 'realtime-audio-ws'
@@ -67,6 +86,10 @@ export const Config = Schema.object({
67
86
  // microphone: a connection takes an explicit action, an authenticated one, and its absence is silence.
68
87
  openSessionOnConnect: Schema.boolean().default(DEFAULT_OPEN_SESSION_ON_CONNECT),
69
88
  diagnosticsPath: Schema.string().default(DEFAULT_DIAGNOSTICS_PATH),
89
+ // Empty means no file, and empty is the default: a plugin that writes files because it was installed is a
90
+ // plugin writing files in somebody else's directory. The deployment that wants a recording is the
91
+ // deployment that names a path.
92
+ journalPath: Schema.string().default(''),
70
93
  })
71
94
 
72
95
  /**
@@ -111,19 +134,73 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
111
134
  const clients = new Set<AudioSocket>()
112
135
  const token = createRouteToken()
113
136
  const journal = injected.realtime.journal
137
+ // The one field the gate classifies as live for this route. Read at the moment each decision is
138
+ // made — at a connect, and at the last client leaving — rather than captured here, so turning it
139
+ // off does not need a restart and turning it on applies to the next connection.
140
+ const live = { openSessionOnConnect: config.openSessionOnConnect }
114
141
  // The token travels in a URL query, and this route records request targets, so the journal has to be
115
142
  // able to redact it before it records one. This is exactly why `addSecrets` is additive instead of a
116
143
  // constructor argument: the plugin that mints the token is the only one that can name it, and it
117
144
  // mints it here, after the journal already exists.
118
145
  journal.addSecrets([token])
146
+ // The record leaves the process when a path is named. Registered *after* the token, so that every entry
147
+ // the file receives has already been through the redaction that knows about it — a sink is handed
148
+ // entries after `record` has redacted them, and that ordering is the reason this is a sink rather than a
149
+ // second writer with its own idea of what a secret looks like.
150
+ if (config.journalPath.length > 0) {
151
+ journal.onEntry(createJournalFileSink(config.journalPath))
152
+ }
119
153
  // The injection event is declared by `@deepseek-ai/dsh-host-webserver`, which this package deliberately
120
154
  // does not import: importing it would augment `Context` with a second declaration of `webServer` and
121
155
  // turn the structural dependency above into a compile error. The name is cast at this one boundary.
122
156
  const onInjection = ctx.on as unknown as (
123
157
  name: string,
124
- listener: (table: InjectedGlobalRow[]) => void,
158
+ listener: (table: InjectedRow[]) => void,
125
159
  ) => unknown
126
160
 
161
+ /**
162
+ * The control channel: one text frame in, one text frame out, on the socket that carries audio.
163
+ *
164
+ * Every edge it needs is injected rather than reached for, and the two session requests go through the
165
+ * same events the transport already emits — one start path, not a second one beside it. The difference
166
+ * is that this caller *waits*: `serial` returns the agent's outcome, so `start` can answer with the
167
+ * session that opened rather than with an acknowledgement that it asked.
168
+ */
169
+ const control = createControlHandler({
170
+ settings: injected.realtime.settings,
171
+ journal,
172
+ path: config.path,
173
+ clients: () => clients.size,
174
+ voice: () => ctx.serial('realtime-agent/status'),
175
+ request: (verb) => verb === 'start'
176
+ ? ctx.serial('realtime-agent/start')
177
+ : ctx.serial('realtime-agent/stop'),
178
+ })
179
+
180
+ /**
181
+ * The tail of the control-reply queue.
182
+ *
183
+ * The channel answers in **arrival order**, which is a property of this queue rather than of the socket:
184
+ * a handler is asynchronous, so without it a slow `status` could be overtaken by the `set` behind it and
185
+ * a client pairing replies with frames would be guessing. One handler at a time costs a control frame
186
+ * the latency of the one before it, which is the right trade for a channel a person drives by hand.
187
+ */
188
+ let replies: Promise<unknown> = Promise.resolve()
189
+
190
+ ctx.effect(function* () {
191
+ const release = injected.realtime.settings.register(name, [
192
+ {
193
+ field: 'openSessionOnConnect',
194
+ kind: 'boolean',
195
+ scope: 'live',
196
+ describe: 'Ask the agent to open the session when an authenticated client connects',
197
+ get: () => live.openSessionOnConnect,
198
+ set: (value: boolean) => { live.openSessionOnConnect = value },
199
+ },
200
+ ])
201
+ yield () => { release() }
202
+ }, 'realtime-audio-ws.settings')
203
+
127
204
  ctx.effect(() => {
128
205
  // The page is told where the route is and what to present. The web server gathers this table on every
129
206
  // index render and every worker boot-payload request, so the row is built at emit time — the only
@@ -134,6 +211,9 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
134
211
  routeAuthority(services.webServer.config?.host, services.webServer.listenedPort),
135
212
  token,
136
213
  ))
214
+ // The panel's own two rows, after the settings it reads. Markup first, then the bootstrap that
215
+ // mounts the panel on it — the table's own ordering guarantee is what makes that safe.
216
+ table.push(...stripRows())
137
217
  })
138
218
  const unregisterDiagnostics = services.webServer.register(diagnosticsRoute({
139
219
  path: config.diagnosticsPath,
@@ -188,17 +268,23 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
188
268
  journal.record('socket.accepted', { clients: String(clients.size), url: target })
189
269
  // Ask for the session before the bridge goes in, so the first frames are written into a session
190
270
  // that is being opened rather than dropped by the mic seam's no-session rule.
191
- if (config.openSessionOnConnect) ctx.emit('realtime-agent/start')
271
+ if (live.openSessionOnConnect) ctx.emit('realtime-agent/start')
192
272
  attachAudioSocket(client, {
193
273
  emitMic: (pcm16) => { ctx.emit('realtime-agent/mic', pcm16) },
194
274
  subscribeAudio: (listener) => ctx.on('realtime-agent/audio', listener),
195
275
  maxFrameBytes: config.maxFrameBytes,
276
+ // The handler is total — every frame produces exactly one reply, and it refuses rather than
277
+ // rejecting — so there is nothing here to catch and nothing here may invent a second reply.
278
+ // `reply` drops the frame if the socket has gone in the meantime, and is total itself.
279
+ onControl: (frame, reply) => {
280
+ replies = replies.then(() => control(frame)).then(reply)
281
+ },
196
282
  onDetach: () => {
197
283
  clients.delete(client)
198
284
  journal.record('socket.closed', { clients: String(clients.size) })
199
285
  // The last client leaving ends the session. This also covers the transport's own disposal,
200
286
  // which terminates its clients — so a profile reload does not leave a session nobody holds.
201
- if (config.openSessionOnConnect && clients.size === 0) ctx.emit('realtime-agent/stop')
287
+ if (live.openSessionOnConnect && clients.size === 0) ctx.emit('realtime-agent/stop')
202
288
  },
203
289
  })
204
290
  })
package/src/injection.ts CHANGED
@@ -36,6 +36,20 @@ export interface InjectedGlobalRow {
36
36
  readonly value: unknown
37
37
  }
38
38
 
39
+ /**
40
+ * Any row this package contributes.
41
+ *
42
+ * The host's table takes six kinds; this package uses three, and describes only those — `html` for the
43
+ * panel's markup and `script` for the bootstrap that mounts it, beside the `global` row carrying the
44
+ * route's own settings. The payload contract is the host's and is stated where it is enforced: a `script`
45
+ * row's text or a `style` row's text must not contain the closing tag of its own element, because the row
46
+ * is spliced into the document's text.
47
+ */
48
+ export type InjectedRow =
49
+ | InjectedGlobalRow
50
+ | { readonly kind: 'html'; readonly placement: 'head' | 'body'; readonly html: string }
51
+ | { readonly kind: 'script'; readonly placement: 'head' | 'body'; readonly text: string }
52
+
39
53
  /**
40
54
  * Where the client should open its socket, or undefined when the host is not listening yet.
41
55
  *
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A journal sink that appends to a file, so the record outlives the process that made it.
3
+ *
4
+ * The journal has always *held* everything worth knowing and has always been unable to hand it over: it is
5
+ * served over a route that wants a per-boot capability token, which means the record can be read by the page
6
+ * that holds the token and by nobody else, and it cannot be read after the fact at all. Every failure this
7
+ * plugin has had was diagnosed by reading somebody else's log — a harness session file off disk, or a
8
+ * `curl` at a route that answered 401. This is the missing half: the same entries, on disk, readable with
9
+ * `cat` by whoever is handed the file.
10
+ *
11
+ * ## Why `appendFileSync`, deliberately
12
+ *
13
+ * The ordering of a diagnostic record is the record. An asynchronous append can interleave two entries or
14
+ * lose one to a process that exits during the write, and the failure this exists to explain is usually the
15
+ * last thing that happened. So each line is written synchronously, as one line, and a reader can trust that
16
+ * the file is ordered and complete up to the last entry it contains.
17
+ *
18
+ * ## Redaction already happened
19
+ *
20
+ * A sink is handed entries *after* `Journal.record` has redacted them, so the file carries exactly what the
21
+ * route would have served and never the values named in `redactSecrets`. That ordering is the whole reason
22
+ * this is a sink and not a second writer with its own copy of the redaction rules — two code paths that must
23
+ * agree about what a secret looks like are two code paths that eventually disagree.
24
+ *
25
+ * @module dsh-realtime-audio-ws/journal-file
26
+ */
27
+
28
+ import { appendFileSync, mkdirSync } from 'node:fs'
29
+ import { dirname } from 'node:path'
30
+ import type { JournalEntry } from 'dsh-realtime'
31
+
32
+ /**
33
+ * Build a sink that appends each entry to `path` as one JSON object per line.
34
+ *
35
+ * The parent directory is created here, at construction, rather than on the first write. That is the one
36
+ * failure this can have that is worth being loud about — a path that cannot be written — and at construction
37
+ * it is a startup error a caller can refuse to launch on, instead of a silent nothing that looks exactly
38
+ * like a quiet session. A *later* write that fails is swallowed by `Journal.onEntry`'s guard, by design.
39
+ * @param path - absolute or relative file path; the file is appended to, never truncated.
40
+ * @returns the sink, ready for `Journal.onEntry`.
41
+ */
42
+ export function createJournalFileSink(path: string): (entry: JournalEntry) => void {
43
+ mkdirSync(dirname(path), { recursive: true })
44
+ return (entry: JournalEntry): void => {
45
+ appendFileSync(path, `${JSON.stringify(entry)}\n`)
46
+ }
47
+ }