@pimote/pimote 0.2.0 → 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 (54) hide show
  1. package/README.md +43 -16
  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/BNqgidwO.js +5 -0
  5. package/client/build/_app/immutable/chunks/D26i4pYm.js +1 -0
  6. package/client/build/_app/immutable/chunks/D_Fpgknp.js +1 -0
  7. package/client/build/_app/immutable/chunks/DoVhjU85.js +1 -0
  8. package/client/build/_app/immutable/chunks/DzqbY2XU.js +1 -0
  9. package/client/build/_app/immutable/entry/{app.CNzpBgAg.js → app.DO-zgzyy.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.BZlrOH0-.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.B8zmHMre.js → 1.B2l9JGRO.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +54 -0
  14. package/client/build/_app/version.json +1 -1
  15. package/client/build/index.html +7 -7
  16. package/package.json +7 -3
  17. package/server/dist/auto-drain-on-abort.js +49 -0
  18. package/server/dist/config.js +21 -0
  19. package/server/dist/extension-ui-bridge.js +14 -1
  20. package/server/dist/index.js +31 -1
  21. package/server/dist/message-mapper.js +38 -6
  22. package/server/dist/server.js +2 -2
  23. package/server/dist/session-manager.js +64 -2
  24. package/server/dist/voice/fsm/actions.js +6 -0
  25. package/server/dist/voice/fsm/events.js +7 -0
  26. package/server/dist/voice/fsm/reducer.js +74 -0
  27. package/server/dist/voice/fsm/reducers/lifecycle.js +146 -0
  28. package/server/dist/voice/fsm/reducers/streaming.js +220 -0
  29. package/server/dist/voice/fsm/reducers/walkback.js +73 -0
  30. package/server/dist/voice/fsm/state.js +21 -0
  31. package/server/dist/voice/fsm/text-extractor.js +128 -0
  32. package/server/dist/voice/index.js +319 -0
  33. package/server/dist/voice/interpreter-prompt.js +115 -0
  34. package/server/dist/voice/speechmux-client.js +153 -0
  35. package/server/dist/voice/state-machine.js +7 -0
  36. package/server/dist/voice/wait-for-idle.js +67 -0
  37. package/server/dist/voice/walk-back.js +198 -0
  38. package/server/dist/voice-orchestrator-boot.js +90 -0
  39. package/server/dist/voice-orchestrator.js +91 -0
  40. package/server/dist/ws-handler.js +108 -5
  41. package/shared/dist/index.d.ts +1 -0
  42. package/shared/dist/index.js +2 -0
  43. package/shared/dist/protocol.d.ts +614 -0
  44. package/shared/dist/protocol.js +30 -0
  45. package/client/build/_app/immutable/assets/0.DBrr7n4n.css +0 -2
  46. package/client/build/_app/immutable/assets/2.DE6k3bQj.css +0 -1
  47. package/client/build/_app/immutable/chunks/5vSSf6qG.js +0 -5
  48. package/client/build/_app/immutable/chunks/CT6ckxpD.js +0 -1
  49. package/client/build/_app/immutable/chunks/DlJOVoUQ.js +0 -1
  50. package/client/build/_app/immutable/chunks/YxmLwfhj.js +0 -1
  51. package/client/build/_app/immutable/chunks/yWVx3W2o.js +0 -1
  52. package/client/build/_app/immutable/entry/start.DYkTAHh1.js +0 -1
  53. package/client/build/_app/immutable/nodes/0.DNlQhEb_.js +0 -10
  54. package/client/build/_app/immutable/nodes/2.W9yV4-x2.js +0 -54
@@ -55,13 +55,36 @@ export function extractMessageEntryIds(branch) {
55
55
  }
56
56
  return ids;
57
57
  }
58
+ /**
59
+ * True when the message is pi-agent-core's synthetic aborted placeholder
60
+ * (pushed into agent.state.messages on session.abort() but never persisted
61
+ * via message_end). Identifying these lets entryId alignment skip over them.
62
+ */
63
+ export function isAbortedPlaceholderMessage(msg) {
64
+ if (msg.role !== 'assistant')
65
+ return false;
66
+ if (msg.aborted !== true)
67
+ return false;
68
+ return msg.content.every((c) => c.type === 'text' && !c.text);
69
+ }
58
70
  /**
59
71
  * Apply entry IDs from the session manager onto mapped messages.
60
- * Zips by index — both arrays must correspond 1:1.
72
+ *
73
+ * Subtle alignment: `messages` comes from `agent.state.messages`, which
74
+ * includes pi-agent-core's synthetic aborted placeholders (abort pushes an
75
+ * empty assistant into state but never persists an entry for it).
76
+ * `entryIds` comes from persisted session entries, which do NOT include
77
+ * those placeholders. We walk the messages and skip aborted placeholders
78
+ * so the persisted IDs land on the correct real messages.
61
79
  */
62
80
  export function applyEntryIds(messages, entryIds) {
63
- for (let i = 0; i < messages.length && i < entryIds.length; i++) {
64
- messages[i].entryId = entryIds[i];
81
+ let idIdx = 0;
82
+ for (let i = 0; i < messages.length; i++) {
83
+ if (isAbortedPlaceholderMessage(messages[i]))
84
+ continue;
85
+ if (idIdx >= entryIds.length)
86
+ break;
87
+ messages[i].entryId = entryIds[idIdx++];
65
88
  }
66
89
  }
67
90
  export function mapAgentMessage(msg) {
@@ -104,8 +127,12 @@ export function mapAgentMessage(msg) {
104
127
  console.warn('[message-mapper] Content value:', msg.content);
105
128
  content.push({ type: 'text', text: `[Unexpected content type: ${typeof msg.content}]` });
106
129
  }
107
- // Log empty content for debugging
108
- if (content.length === 0) {
130
+ // Aborted assistant turns are a real signal in voice mode (every barge-in
131
+ // produces one via pi-agent-core's handleRunFailure) and shouldn't be
132
+ // confused with malformed messages. Log the empty-content warning only
133
+ // when it's NOT an expected aborted turn.
134
+ const aborted = role === 'assistant' && msg.stopReason === 'aborted';
135
+ if (content.length === 0 && !aborted) {
109
136
  console.warn('[message-mapper] Empty content array for message:', { role, content: msg.content });
110
137
  }
111
138
  // Handle custom messages — preserve customType and display flag for the client
@@ -139,5 +166,10 @@ export function mapAgentMessage(msg) {
139
166
  // Note: msg.id is typically undefined for standard SDK messages (UserMessage,
140
167
  // AssistantMessage, ToolResultMessage). Entry IDs are applied separately via
141
168
  // applyEntryIds() using the session manager's branch entries.
142
- return { role, content, ...(msg.id ? { entryId: msg.id } : {}) };
169
+ return {
170
+ role,
171
+ content,
172
+ ...(msg.id ? { entryId: msg.id } : {}),
173
+ ...(aborted ? { aborted: true } : {}),
174
+ };
143
175
  }
@@ -79,7 +79,7 @@ async function serveFallback(res) {
79
79
  res.end(JSON.stringify({ error: 'not found' }));
80
80
  }
81
81
  }
82
- export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore) {
82
+ export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceOrchestrator) {
83
83
  const clientVersion = await loadClientVersion();
84
84
  if (clientVersion) {
85
85
  console.log(`[pimote] Client build version: ${clientVersion}`);
@@ -157,7 +157,7 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
157
157
  // The close handler skips cleanup when the registry already points to a
158
158
  // different handler, so this is the only place it runs.
159
159
  const existing = clientRegistry.get(clientId);
160
- const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry);
160
+ const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry, voiceOrchestrator);
161
161
  clientRegistry.set(clientId, handler);
162
162
  if (existing) {
163
163
  existing.cleanup();
@@ -2,6 +2,8 @@ import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessi
2
2
  import { EventBuffer } from './event-buffer.js';
3
3
  import { applyPanelMessage, getMergedPanelCards } from './panel-state.js';
4
4
  import { getGitBranch } from './git-branch.js';
5
+ import { createVoiceExtension } from './voice/index.js';
6
+ import { autoDrainOnAbort } from './auto-drain-on-abort.js';
5
7
  // ---- Slot-based helpers (operate on ManagedSlot) ----
6
8
  /** Send an event to the client connected to this slot. No-op if disconnected. */
7
9
  export function sendSlotEvent(slot, event) {
@@ -74,6 +76,13 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
74
76
  if (slotRef.slot)
75
77
  callbacks.onAgentEnd?.(sessionId, slotRef.slot);
76
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]);
77
86
  }
78
87
  eventBuffer.onEvent(event, sessionId, (e) => callbacks.sendEvent(e), () => session.messages[session.messages.length - 1]);
79
88
  });
@@ -132,18 +141,43 @@ export class PimoteSessionManager {
132
141
  onStatusChange;
133
142
  onSessionClosed;
134
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;
135
148
  constructor(config, pushNotificationService) {
136
149
  this.config = config;
137
150
  this.pushNotificationService = pushNotificationService;
138
151
  this.authStorage = AuthStorage.create();
139
152
  this.modelRegistry = ModelRegistry.create(this.authStorage);
140
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
+ }
141
174
  async openSession(folderPath, sessionFilePath) {
142
175
  const eventBusRef = { current: null };
143
176
  const sharedAuthStorage = this.authStorage;
144
177
  const sharedModelRegistry = this.modelRegistry;
145
178
  const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
146
179
  const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
180
+ const voiceExtensionFactory = this.buildVoiceExtensionFactory();
147
181
  const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
148
182
  const eventBus = createEventBus();
149
183
  eventBusRef.current = eventBus;
@@ -152,7 +186,10 @@ export class PimoteSessionManager {
152
186
  agentDir,
153
187
  authStorage: sharedAuthStorage,
154
188
  modelRegistry: sharedModelRegistry,
155
- resourceLoaderOptions: { eventBus },
189
+ resourceLoaderOptions: {
190
+ eventBus,
191
+ ...(voiceExtensionFactory ? { extensionFactories: [voiceExtensionFactory] } : {}),
192
+ },
156
193
  });
157
194
  return {
158
195
  ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
@@ -187,6 +224,12 @@ export class PimoteSessionManager {
187
224
  session.setThinkingLevel(this.config.defaultThinkingLevel);
188
225
  console.log(`[pimote] Set default thinking level: ${this.config.defaultThinkingLevel}`);
189
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');
190
233
  // Create the slot object. Use a slotRef so createSessionState callbacks can reference the slot.
191
234
  const slotRef = { slot: null };
192
235
  const sessionState = createSessionState(session, eventBusRef.current, this.config, {
@@ -264,6 +307,14 @@ export class PimoteSessionManager {
264
307
  const slot = this.sessions.get(sessionId);
265
308
  if (!slot)
266
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
+ }
267
318
  teardownSessionState(slot.sessionState);
268
319
  slot.eventBusRef.current?.clear();
269
320
  const folderPath = slot.folderPath;
@@ -281,9 +332,15 @@ export class PimoteSessionManager {
281
332
  this.lastKnownGitBranchBySession.set(newId, lastKnown);
282
333
  }
283
334
  /** Rebuild a slot's SessionState after session replacement.
284
- * 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. */
285
339
  rebuildSessionState(slot) {
286
340
  teardownSessionState(slot.sessionState);
341
+ const newCwd = slot.runtime.session.sessionManager.getCwd();
342
+ if (newCwd)
343
+ slot.folderPath = newCwd;
287
344
  const slotRef = { slot: slot };
288
345
  slot.sessionState = createSessionState(slot.runtime.session, slot.eventBusRef.current, this.config, {
289
346
  onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
@@ -291,6 +348,11 @@ export class PimoteSessionManager {
291
348
  sendEvent: (e) => sendSlotEvent(slot, e),
292
349
  }, slotRef, slot.folderPath);
293
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
+ }
294
356
  getSession(sessionId) {
295
357
  return this.sessions.get(sessionId);
296
358
  }
@@ -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
+ }