@pimote/pimote 0.1.1 → 0.3.0

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 (61) hide show
  1. package/README.md +46 -17
  2. package/client/build/_app/immutable/assets/0.C7loWTOC.css +2 -0
  3. package/client/build/_app/immutable/assets/2.D9fiCd8W.css +1 -0
  4. package/client/build/_app/immutable/chunks/{BTSGQ0LP.js → B8lQCytv.js} +1 -1
  5. package/client/build/_app/immutable/chunks/BNqgidwO.js +5 -0
  6. package/client/build/_app/immutable/chunks/D26i4pYm.js +1 -0
  7. package/client/build/_app/immutable/chunks/D_Fpgknp.js +1 -0
  8. package/client/build/_app/immutable/chunks/DoVhjU85.js +1 -0
  9. package/client/build/_app/immutable/chunks/DzqbY2XU.js +1 -0
  10. package/client/build/_app/immutable/chunks/{L5t1qIFa.js → uZO1iyJZ.js} +2 -2
  11. package/client/build/_app/immutable/entry/app.DO-zgzyy.js +2 -0
  12. package/client/build/_app/immutable/entry/start.BZlrOH0-.js +1 -0
  13. package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +10 -0
  14. package/client/build/_app/immutable/nodes/1.B2l9JGRO.js +1 -0
  15. package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +54 -0
  16. package/client/build/_app/version.json +1 -1
  17. package/client/build/index.html +8 -8
  18. package/package.json +9 -5
  19. package/patches/{@mariozechner+pi-coding-agent+0.65.0.patch → @mariozechner+pi-coding-agent+0.67.6.patch} +4 -4
  20. package/server/dist/auto-drain-on-abort.js +49 -0
  21. package/server/dist/config.js +21 -0
  22. package/server/dist/extension-ui-bridge.js +14 -1
  23. package/server/dist/folder-index.js +8 -4
  24. package/server/dist/git-branch.js +32 -0
  25. package/server/dist/index.js +31 -1
  26. package/server/dist/message-mapper.js +99 -4
  27. package/server/dist/server.js +5 -2
  28. package/server/dist/session-manager.js +99 -6
  29. package/server/dist/voice/fsm/actions.js +6 -0
  30. package/server/dist/voice/fsm/events.js +7 -0
  31. package/server/dist/voice/fsm/reducer.js +74 -0
  32. package/server/dist/voice/fsm/reducers/lifecycle.js +146 -0
  33. package/server/dist/voice/fsm/reducers/streaming.js +220 -0
  34. package/server/dist/voice/fsm/reducers/walkback.js +73 -0
  35. package/server/dist/voice/fsm/state.js +21 -0
  36. package/server/dist/voice/fsm/text-extractor.js +128 -0
  37. package/server/dist/voice/index.js +319 -0
  38. package/server/dist/voice/interpreter-prompt.js +115 -0
  39. package/server/dist/voice/speechmux-client.js +153 -0
  40. package/server/dist/voice/state-machine.js +7 -0
  41. package/server/dist/voice/wait-for-idle.js +67 -0
  42. package/server/dist/voice/walk-back.js +198 -0
  43. package/server/dist/voice-orchestrator-boot.js +90 -0
  44. package/server/dist/voice-orchestrator.js +91 -0
  45. package/server/dist/ws-handler.js +340 -36
  46. package/shared/dist/index.d.ts +1 -0
  47. package/shared/dist/index.js +2 -0
  48. package/shared/dist/protocol.d.ts +614 -0
  49. package/shared/dist/protocol.js +30 -0
  50. package/client/build/_app/immutable/assets/0.Cj7UL9cq.css +0 -2
  51. package/client/build/_app/immutable/assets/2.CIRqqeIr.css +0 -1
  52. package/client/build/_app/immutable/chunks/BEKHoMUP.js +0 -1
  53. package/client/build/_app/immutable/chunks/CfQ6Egqh.js +0 -1
  54. package/client/build/_app/immutable/chunks/DQ-KfPq0.js +0 -1
  55. package/client/build/_app/immutable/chunks/DfA0ecbz.js +0 -1
  56. package/client/build/_app/immutable/chunks/Dnh9Emns.js +0 -5
  57. package/client/build/_app/immutable/entry/app.j0V4R67V.js +0 -2
  58. package/client/build/_app/immutable/entry/start.wkfo4Ebw.js +0 -1
  59. package/client/build/_app/immutable/nodes/0.CUipL_P7.js +0 -5
  60. package/client/build/_app/immutable/nodes/1.ex7ejMby.js +0 -1
  61. package/client/build/_app/immutable/nodes/2.165oQG9Z.js +0 -49
@@ -1,6 +1,9 @@
1
1
  import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, createEventBus, AuthStorage, ModelRegistry, getAgentDir, SessionManager as PiSessionManager, } from '@mariozechner/pi-coding-agent';
2
2
  import { EventBuffer } from './event-buffer.js';
3
3
  import { applyPanelMessage, getMergedPanelCards } from './panel-state.js';
4
+ import { getGitBranch } from './git-branch.js';
5
+ import { createVoiceExtension } from './voice/index.js';
6
+ import { autoDrainOnAbort } from './auto-drain-on-abort.js';
4
7
  // ---- Slot-based helpers (operate on ManagedSlot) ----
5
8
  /** Send an event to the client connected to this slot. No-op if disconnected. */
6
9
  export function sendSlotEvent(slot, event) {
@@ -59,6 +62,7 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
59
62
  panelState: new Map(),
60
63
  panelListenerUnsubs: [],
61
64
  panelThrottleTimer: null,
65
+ treeNavigationInProgress: false,
62
66
  };
63
67
  // Subscribe to session events
64
68
  const unsubscribe = session.subscribe((event) => {
@@ -72,6 +76,13 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
72
76
  if (slotRef.slot)
73
77
  callbacks.onAgentEnd?.(sessionId, slotRef.slot);
74
78
  callbacks.onStatusChange?.(sessionId, folderPath);
79
+ // If the run ended via abort and there are queued steering /
80
+ // follow-up messages, drain them — pi-agent-core's runLoop skips
81
+ // its trailing queue poll on the abort exit path, so without this
82
+ // queued messages would sit until the next prompt() call. Universal
83
+ // across pimote (not voice-specific) so typed-mode users also
84
+ // benefit. See `auto-drain-on-abort.ts` for rationale.
85
+ void autoDrainOnAbort(session, event.messages[event.messages.length - 1]);
75
86
  }
76
87
  eventBuffer.onEvent(event, sessionId, (e) => callbacks.sendEvent(e), () => session.messages[session.messages.length - 1]);
77
88
  });
@@ -125,18 +136,48 @@ export class PimoteSessionManager {
125
136
  modelRegistry;
126
137
  sessions = new Map();
127
138
  idleCheckHandle = null;
139
+ gitBranchCheckHandle = null;
140
+ lastKnownGitBranchBySession = new Map();
128
141
  onStatusChange;
129
142
  onSessionClosed;
143
+ onGitBranchChange;
144
+ /** Fired synchronously before a session's state is torn down (e.g. idle
145
+ * reap, explicit close). Consumers use this to drop external bookkeeping
146
+ * (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
147
+ onBeforeSessionClose;
130
148
  constructor(config, pushNotificationService) {
131
149
  this.config = config;
132
150
  this.pushNotificationService = pushNotificationService;
133
151
  this.authStorage = AuthStorage.create();
134
152
  this.modelRegistry = ModelRegistry.create(this.authStorage);
135
153
  }
154
+ /**
155
+ * Build the voice extension factory for this server's config, if possible.
156
+ * Returns undefined (and logs a warning) when neither voice-specific model
157
+ * refs nor fallback defaultProvider/defaultModel are configured — existing
158
+ * non-voice deployments continue to work unchanged.
159
+ */
160
+ buildVoiceExtensionFactory() {
161
+ const interpreter = this.config.defaultInterpreterModel ??
162
+ (this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
163
+ const worker = this.config.defaultWorkerModel ??
164
+ (this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
165
+ if (!interpreter || !worker) {
166
+ console.warn('[pimote] voice extension disabled: no defaultInterpreterModel/defaultWorkerModel or fallback defaultProvider/defaultModel in config');
167
+ return undefined;
168
+ }
169
+ return createVoiceExtension({
170
+ defaultInterpreterModel: interpreter,
171
+ defaultWorkerModel: worker,
172
+ });
173
+ }
136
174
  async openSession(folderPath, sessionFilePath) {
137
175
  const eventBusRef = { current: null };
138
176
  const sharedAuthStorage = this.authStorage;
139
177
  const sharedModelRegistry = this.modelRegistry;
178
+ const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
179
+ const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
180
+ const voiceExtensionFactory = this.buildVoiceExtensionFactory();
140
181
  const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
141
182
  const eventBus = createEventBus();
142
183
  eventBusRef.current = eventBus;
@@ -145,7 +186,10 @@ export class PimoteSessionManager {
145
186
  agentDir,
146
187
  authStorage: sharedAuthStorage,
147
188
  modelRegistry: sharedModelRegistry,
148
- resourceLoaderOptions: { eventBus },
189
+ resourceLoaderOptions: {
190
+ eventBus,
191
+ ...(voiceExtensionFactory ? { extensionFactories: [voiceExtensionFactory] } : {}),
192
+ },
149
193
  });
150
194
  return {
151
195
  ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
@@ -154,9 +198,9 @@ export class PimoteSessionManager {
154
198
  };
155
199
  };
156
200
  const runtime = await createAgentSessionRuntime(factory, {
157
- cwd: folderPath,
201
+ cwd: effectiveFolderPath,
158
202
  agentDir: getAgentDir(),
159
- sessionManager: sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath),
203
+ sessionManager,
160
204
  });
161
205
  const session = runtime.session;
162
206
  const sessionId = session.sessionId;
@@ -180,16 +224,22 @@ export class PimoteSessionManager {
180
224
  session.setThinkingLevel(this.config.defaultThinkingLevel);
181
225
  console.log(`[pimote] Set default thinking level: ${this.config.defaultThinkingLevel}`);
182
226
  }
227
+ // Pimote convention: drain the entire steering / follow-up queue into a
228
+ // single consolidated run rather than one message per run. Matches the
229
+ // UX expectation that queued messages are delivered together when the
230
+ // agent next processes, not metered out across multiple turns.
231
+ session.setSteeringMode('all');
232
+ session.setFollowUpMode('all');
183
233
  // Create the slot object. Use a slotRef so createSessionState callbacks can reference the slot.
184
234
  const slotRef = { slot: null };
185
235
  const sessionState = createSessionState(session, eventBusRef.current, this.config, {
186
236
  onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
187
237
  onAgentEnd: (sid, s) => this.handleAgentEnd(sid, s),
188
238
  sendEvent: (e) => sendSlotEvent(slot, e),
189
- }, slotRef, folderPath);
239
+ }, slotRef, effectiveFolderPath);
190
240
  const slot = {
191
241
  runtime,
192
- folderPath,
242
+ folderPath: effectiveFolderPath,
193
243
  eventBusRef,
194
244
  connection: null,
195
245
  sessionState,
@@ -199,6 +249,7 @@ export class PimoteSessionManager {
199
249
  };
200
250
  slotRef.slot = slot;
201
251
  this.sessions.set(sessionId, slot);
252
+ this.lastKnownGitBranchBySession.set(sessionId, getGitBranch(effectiveFolderPath));
202
253
  return sessionId;
203
254
  }
204
255
  handleAgentEnd(sessionId, slot) {
@@ -256,22 +307,40 @@ export class PimoteSessionManager {
256
307
  const slot = this.sessions.get(sessionId);
257
308
  if (!slot)
258
309
  return;
310
+ if (this.onBeforeSessionClose) {
311
+ try {
312
+ await this.onBeforeSessionClose(sessionId, slot.folderPath);
313
+ }
314
+ catch (err) {
315
+ console.warn('[pimote] onBeforeSessionClose threw:', err);
316
+ }
317
+ }
259
318
  teardownSessionState(slot.sessionState);
260
319
  slot.eventBusRef.current?.clear();
261
320
  const folderPath = slot.folderPath;
262
321
  await slot.runtime.dispose();
263
322
  this.sessions.delete(sessionId);
323
+ this.lastKnownGitBranchBySession.delete(sessionId);
264
324
  this.onSessionClosed?.(sessionId, folderPath);
265
325
  }
266
326
  /** Re-key a slot in the session map after session replacement. */
267
327
  reKeySession(slot, oldId, newId) {
268
328
  this.sessions.delete(oldId);
269
329
  this.sessions.set(newId, slot);
330
+ const lastKnown = this.lastKnownGitBranchBySession.get(oldId) ?? null;
331
+ this.lastKnownGitBranchBySession.delete(oldId);
332
+ this.lastKnownGitBranchBySession.set(newId, lastKnown);
270
333
  }
271
334
  /** Rebuild a slot's SessionState after session replacement.
272
- * Tears down the old state and creates a new one from the current runtime.session. */
335
+ * Tears down the old state and creates a new one from the current runtime.session.
336
+ * Also refreshes slot.folderPath from the new session's header cwd, since fork-from
337
+ * (e.g. the worktree extension) can rebind the slot to a session whose cwd differs
338
+ * from the original. */
273
339
  rebuildSessionState(slot) {
274
340
  teardownSessionState(slot.sessionState);
341
+ const newCwd = slot.runtime.session.sessionManager.getCwd();
342
+ if (newCwd)
343
+ slot.folderPath = newCwd;
275
344
  const slotRef = { slot: slot };
276
345
  slot.sessionState = createSessionState(slot.runtime.session, slot.eventBusRef.current, this.config, {
277
346
  onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
@@ -279,6 +348,11 @@ export class PimoteSessionManager {
279
348
  sendEvent: (e) => sendSlotEvent(slot, e),
280
349
  }, slotRef, slot.folderPath);
281
350
  }
351
+ /** Alias for getSession that returns null (not undefined) for consumers
352
+ * that expect nullable pointers (e.g. VoiceSessionBusResolver). */
353
+ getSlot(sessionId) {
354
+ return this.sessions.get(sessionId) ?? null;
355
+ }
282
356
  getSession(sessionId) {
283
357
  return this.sessions.get(sessionId);
284
358
  }
@@ -289,6 +363,9 @@ export class PimoteSessionManager {
289
363
  this.stopIdleCheck();
290
364
  this.idleCheckHandle = setInterval(() => {
291
365
  for (const [sessionId, slot] of this.sessions) {
366
+ if (slot.sessionState.treeNavigationInProgress) {
367
+ continue;
368
+ }
292
369
  const clientId = slot.connection?.connectedClientId ?? null;
293
370
  const hasConnectedClient = clientId !== null && (isClientConnected?.(clientId) ?? false);
294
371
  if (!hasConnectedClient && Date.now() - slot.sessionState.lastActivity > idleTimeout) {
@@ -298,12 +375,28 @@ export class PimoteSessionManager {
298
375
  }
299
376
  }
300
377
  }, 60_000);
378
+ this.gitBranchCheckHandle = setInterval(() => {
379
+ for (const [sessionId, slot] of this.sessions) {
380
+ if (!slot.connection?.connectedClientId)
381
+ continue;
382
+ const next = getGitBranch(slot.folderPath);
383
+ const prev = this.lastKnownGitBranchBySession.get(sessionId) ?? null;
384
+ if (next !== prev) {
385
+ this.lastKnownGitBranchBySession.set(sessionId, next);
386
+ this.onGitBranchChange?.(sessionId, slot.folderPath);
387
+ }
388
+ }
389
+ }, 3000);
301
390
  }
302
391
  stopIdleCheck() {
303
392
  if (this.idleCheckHandle !== null) {
304
393
  clearInterval(this.idleCheckHandle);
305
394
  this.idleCheckHandle = null;
306
395
  }
396
+ if (this.gitBranchCheckHandle !== null) {
397
+ clearInterval(this.gitBranchCheckHandle);
398
+ this.gitBranchCheckHandle = null;
399
+ }
307
400
  }
308
401
  async dispose() {
309
402
  this.stopIdleCheck();
@@ -0,0 +1,6 @@
1
+ // Actions emitted by the voice FSM reducer.
2
+ //
3
+ // The reducer is pure: every side effect is encoded as one of these
4
+ // values. The shell in `index.ts` interprets each into an actual call
5
+ // against the pi SDK / speechmux WS / EventBus.
6
+ export {};
@@ -0,0 +1,7 @@
1
+ // Events the voice FSM consumes.
2
+ //
3
+ // Every external stimulus (EventBus, WS lifecycle, WS frames, SDK hooks)
4
+ // is normalized into one of these typed events before reaching the
5
+ // reducer. The shell in `index.ts` is the only place where ad-hoc input
6
+ // translation lives.
7
+ export {};
@@ -0,0 +1,74 @@
1
+ // Top-level voice FSM reducer.
2
+ //
3
+ // Folds the three sub-reducers (lifecycle, streaming, walkback) into a
4
+ // single transition. Every event is fanned out to the sub-reducers
5
+ // that care about it; results are merged.
6
+ //
7
+ // The sub-reducers don't know about each other. Two cross-cutting bits
8
+ // are handled here:
9
+ //
10
+ // 1. Frame buffering. The streaming reducer emits raw `OutgoingFrame`
11
+ // values; we route each through `bufferOrPassFrame` against the
12
+ // current lifecycle state to either forward (Active) or buffer
13
+ // (Activating). The buffered case mutates the lifecycle slice; the
14
+ // forwarded case becomes a `send_frame` action.
15
+ //
16
+ // 2. The `ws:incoming` `user` frame turns into a `send_user_message`
17
+ // action — and only when lifecycle is `active`. (The walkback
18
+ // reducer intentionally ignores user frames; this is the right
19
+ // place to handle them because it lives at the boundary between
20
+ // "do we even have a connection" and "what should the agent do".)
21
+ import { reduceLifecycle, applyLifecycleResult, bufferOrPassFrame } from './reducers/lifecycle.js';
22
+ import { reduceStreaming } from './reducers/streaming.js';
23
+ import { reduceWalkback, applyWalkbackResult } from './reducers/walkback.js';
24
+ export function reduce(prev, event, reducers) {
25
+ let state = prev;
26
+ const actions = [];
27
+ // ---- Lifecycle ---------------------------------------------------------
28
+ const life = reduceLifecycle(state.lifecycle, event, {
29
+ interpreterApplied: state.interpreterApplied,
30
+ config: reducers.config,
31
+ });
32
+ state = applyLifecycleResult(state, life);
33
+ actions.push(...life.actions);
34
+ // ---- Streaming ---------------------------------------------------------
35
+ // Only meaningful when activating or active. Dormant means we can't
36
+ // emit frames anywhere, so skip the work and any accidental frames.
37
+ if (state.lifecycle.kind !== 'dormant') {
38
+ const stream = reduceStreaming(state.message, event);
39
+ state = { ...state, message: stream.next };
40
+ for (const frame of stream.frames) {
41
+ const routed = bufferOrPassFrame(state.lifecycle, frame);
42
+ state = { ...state, lifecycle: routed.next };
43
+ actions.push(...routed.actions);
44
+ }
45
+ // Fold the latest ended speak id into runtime so walkback has a
46
+ // fallback target if speechmux's rollback/abort doesn't echo a
47
+ // speak_id (e.g. older speechmux build).
48
+ if (stream.endedSpeakIds.length > 0) {
49
+ state = {
50
+ ...state,
51
+ lastEmittedSpeakId: stream.endedSpeakIds[stream.endedSpeakIds.length - 1] ?? state.lastEmittedSpeakId,
52
+ };
53
+ }
54
+ }
55
+ // ---- Walkback ----------------------------------------------------------
56
+ const wb = reduceWalkback(state.walkback, state.lastEmittedSpeakId, event);
57
+ state = applyWalkbackResult(state, wb);
58
+ actions.push(...wb.actions);
59
+ // Clear lastEmittedSpeakId on full deactivation so a subsequent call
60
+ // doesn't carry it over.
61
+ if (event.type === 'eb:deactivate') {
62
+ state = { ...state, lastEmittedSpeakId: null };
63
+ }
64
+ // ---- Cross-cutting: incoming `user` frame → sendUserMessage ------------
65
+ if (event.type === 'ws:incoming' && event.frame.type === 'user') {
66
+ if (state.lifecycle.kind === 'active') {
67
+ actions.push({ kind: 'send_user_message', text: event.frame.text });
68
+ }
69
+ // If lifecycle isn't active, drop. The shell will log this — it
70
+ // means a user frame arrived after we tore down (or before we
71
+ // wired the WS), both of which are bugs upstream.
72
+ }
73
+ return { next: state, actions };
74
+ }
@@ -0,0 +1,146 @@
1
+ // Concern A: Lifecycle reducer.
2
+ //
3
+ // Responsible for:
4
+ // - Activate / deactivate of voice mode.
5
+ // - WS connection lifecycle (opened / failed / disconnected).
6
+ // - Buffering of outgoing speak frames during the `activating` window
7
+ // and flushing them on `ws:opened`.
8
+ //
9
+ // Holds NO knowledge of the streaming / walkback machines. Those plug in
10
+ // through the top-level dispatcher.
11
+ import { VOICE_CALL_STARTED_SENTINEL } from '../../state-machine.js';
12
+ /**
13
+ * Pure transition function for the lifecycle slice.
14
+ *
15
+ * Emits the bulk of the side-effect actions: model setup, sentinel user
16
+ * message, WS open/close, deactivate-request.
17
+ *
18
+ * Frame emission policy: whenever the streaming machine produces a
19
+ * `send_frame` action, it goes via the top-level dispatcher into
20
+ * `bufferOrPassFrame()` below — which either forwards it (Active) or
21
+ * appends to `pendingFrames` (Activating). On `ws:opened` we flush in
22
+ * order.
23
+ */
24
+ export function reduceLifecycle(prev, event, ctx) {
25
+ switch (event.type) {
26
+ case 'eb:activate': {
27
+ if (prev.kind !== 'dormant') {
28
+ // Re-activation while already active or activating is a no-op
29
+ // — the orchestrator's bind path is supposed to be the single
30
+ // owner. We log loudly in the shell; here we just stay put.
31
+ return { next: prev, interpreterAppliedNow: false, actions: [] };
32
+ }
33
+ const actions = [];
34
+ if (!ctx.interpreterApplied) {
35
+ actions.push({
36
+ kind: 'set_interpreter_model',
37
+ provider: ctx.config.defaultInterpreterModel.provider,
38
+ modelId: ctx.config.defaultInterpreterModel.modelId,
39
+ });
40
+ }
41
+ actions.push({ kind: 'send_user_message', text: VOICE_CALL_STARTED_SENTINEL });
42
+ actions.push({ kind: 'open_ws', url: event.msg.speechmuxWsUrl });
43
+ return {
44
+ next: {
45
+ kind: 'activating',
46
+ sessionId: event.msg.sessionId,
47
+ wsUrl: event.msg.speechmuxWsUrl,
48
+ pendingFrames: [],
49
+ },
50
+ interpreterAppliedNow: !ctx.interpreterApplied,
51
+ actions,
52
+ };
53
+ }
54
+ case 'eb:deactivate': {
55
+ if (prev.kind === 'dormant') {
56
+ return { next: prev, interpreterAppliedNow: false, actions: [] };
57
+ }
58
+ // Idempotent: close_ws is a no-op if no client is open.
59
+ return {
60
+ next: { kind: 'dormant' },
61
+ interpreterAppliedNow: false,
62
+ actions: [{ kind: 'close_ws' }],
63
+ };
64
+ }
65
+ case 'ws:opened': {
66
+ if (prev.kind !== 'activating') {
67
+ // Stray opened event (e.g. after a deactivate-then-open race).
68
+ // Close the new connection if we somehow have one.
69
+ return { next: prev, interpreterAppliedNow: false, actions: [] };
70
+ }
71
+ const actions = [];
72
+ // Flush buffered speak frames in arrival order. Done here, not
73
+ // in the streaming reducer, so frame ordering is preserved across
74
+ // the activating→active boundary.
75
+ for (const frame of prev.pendingFrames) {
76
+ actions.push({ kind: 'send_frame', frame });
77
+ }
78
+ return {
79
+ next: { kind: 'active', sessionId: prev.sessionId },
80
+ interpreterAppliedNow: false,
81
+ actions,
82
+ };
83
+ }
84
+ case 'ws:open_failed': {
85
+ if (prev.kind !== 'activating') {
86
+ return { next: prev, interpreterAppliedNow: false, actions: [] };
87
+ }
88
+ // Drop any buffered frames; the shell will rebuild from scratch
89
+ // on the next activate.
90
+ return {
91
+ next: { kind: 'dormant' },
92
+ interpreterAppliedNow: false,
93
+ actions: [{ kind: 'emit_deactivate_request' }],
94
+ };
95
+ }
96
+ case 'ws:disconnected': {
97
+ if (prev.kind === 'dormant') {
98
+ return { next: prev, interpreterAppliedNow: false, actions: [] };
99
+ }
100
+ return {
101
+ next: { kind: 'dormant' },
102
+ interpreterAppliedNow: false,
103
+ actions: [{ kind: 'emit_deactivate_request' }],
104
+ };
105
+ }
106
+ default:
107
+ return { next: prev, interpreterAppliedNow: false, actions: [] };
108
+ }
109
+ }
110
+ /**
111
+ * Apply a `send_frame` action against the lifecycle state. Returns
112
+ * either the same action (to be executed by the shell) or a state
113
+ * mutation that buffers the frame for later flush.
114
+ *
115
+ * Splitting this out keeps the streaming reducer agnostic of the
116
+ * lifecycle phase — it always emits `send_frame`; this function decides
117
+ * whether to forward or buffer.
118
+ */
119
+ export function bufferOrPassFrame(prev, frame) {
120
+ switch (prev.kind) {
121
+ case 'dormant':
122
+ // Frame produced while no call is bound — drop. Streaming reducer
123
+ // is supposed to no-op while dormant; if we land here it's a
124
+ // diagnostic the shell will log.
125
+ return { next: prev, actions: [] };
126
+ case 'activating':
127
+ return {
128
+ next: { ...prev, pendingFrames: [...prev.pendingFrames, frame] },
129
+ actions: [],
130
+ };
131
+ case 'active':
132
+ return { next: prev, actions: [{ kind: 'send_frame', frame }] };
133
+ }
134
+ }
135
+ /**
136
+ * Top-level merge helper used by the dispatcher to splice the lifecycle
137
+ * sub-state back into the runtime record. Kept here so the dispatcher
138
+ * stays mechanical.
139
+ */
140
+ export function applyLifecycleResult(prev, r) {
141
+ return {
142
+ ...prev,
143
+ lifecycle: r.next,
144
+ interpreterApplied: prev.interpreterApplied || r.interpreterAppliedNow,
145
+ };
146
+ }