@pimote/pimote 0.6.0 → 0.8.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 (51) hide show
  1. package/README.md +4 -1
  2. package/client/build/_app/immutable/assets/0.DmHGeVyH.css +2 -0
  3. package/client/build/_app/immutable/assets/{2.bfMycywk.css → 2.BtlPyuHL.css} +1 -1
  4. package/client/build/_app/immutable/chunks/BjlKVpoO.js +1 -0
  5. package/client/build/_app/immutable/chunks/Blm_TLGW.js +1 -0
  6. package/client/build/_app/immutable/chunks/COcpV1OD.js +1 -0
  7. package/client/build/_app/immutable/chunks/DMWd5mk8.js +1 -0
  8. package/client/build/_app/immutable/chunks/{DNqQZw5U.js → Daen0SYI.js} +2 -2
  9. package/client/build/_app/immutable/entry/{app.DZYoujEP.js → app.DW3BNxC_.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.DUfrZpFg.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.DzBRsuZ_.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.B5qlqMFD.js → 1.y-VB1JIj.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.Bz9KycIe.js +55 -0
  14. package/client/build/_app/version.json +1 -1
  15. package/client/build/index.html +7 -7
  16. package/package.json +2 -2
  17. package/server/dist/config.js +5 -2
  18. package/server/dist/event-buffer.js +9 -0
  19. package/server/dist/extension-ui-bridge.js +26 -10
  20. package/server/dist/file-references.js +123 -0
  21. package/server/dist/git-branch.js +12 -9
  22. package/server/dist/login-orchestrator.js +114 -0
  23. package/server/dist/push-infrastructure.js +13 -2
  24. package/server/dist/push-notification.js +18 -11
  25. package/server/dist/server.js +25 -2
  26. package/server/dist/session-cost.js +26 -2
  27. package/server/dist/session-manager.js +109 -6
  28. package/server/dist/static-host/gc.js +13 -0
  29. package/server/dist/static-host/http-handler.js +27 -1
  30. package/server/dist/static-host/index.js +24 -12
  31. package/server/dist/static-host/store.js +10 -1
  32. package/server/dist/static-host/tools.js +5 -1
  33. package/server/dist/voice/fsm/reducer.js +14 -2
  34. package/server/dist/voice/fsm/reducers/lifecycle.js +10 -4
  35. package/server/dist/voice/fsm/reducers/streaming.js +39 -3
  36. package/server/dist/voice/fsm/reducers/walkback.js +13 -10
  37. package/server/dist/voice/fsm/state.js +1 -1
  38. package/server/dist/voice/index.js +97 -41
  39. package/server/dist/voice/walk-back.js +94 -26
  40. package/server/dist/voice-orchestrator-boot.js +22 -5
  41. package/server/dist/voice-orchestrator.js +38 -1
  42. package/server/dist/ws-handler.js +195 -63
  43. package/shared/dist/protocol.d.ts +99 -2
  44. package/client/build/_app/immutable/assets/0.Dh2gYJ1J.css +0 -2
  45. package/client/build/_app/immutable/chunks/Czpnrh9t.js +0 -1
  46. package/client/build/_app/immutable/chunks/D1mCuOEu.js +0 -1
  47. package/client/build/_app/immutable/chunks/DHiuV2ft.js +0 -1
  48. package/client/build/_app/immutable/chunks/DegHYiTr.js +0 -1
  49. package/client/build/_app/immutable/entry/start.BNnDRfmt.js +0 -1
  50. package/client/build/_app/immutable/nodes/0.B20DMuGn.js +0 -10
  51. package/client/build/_app/immutable/nodes/2.CZjPJM-S.js +0 -55
@@ -2,6 +2,7 @@ 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 { LoginOrchestrator } from './login-orchestrator.js';
5
6
  import { createVoiceExtension } from './voice/index.js';
6
7
  import { autoDrainOnAbort } from './auto-drain-on-abort.js';
7
8
  // ---- Slot-based helpers (operate on ManagedSlot) ----
@@ -143,12 +144,33 @@ function scheduleSlotPanelPush(state, sessionId, sendEvent) {
143
144
  sendEvent({ type: 'panel_update', sessionId, cards });
144
145
  }, 200);
145
146
  }
147
+ /**
148
+ * Coalesce concurrent async operations keyed by `key`: while one is in flight,
149
+ * callers passing the same key share its promise instead of starting a second
150
+ * run. The map entry is cleared once the operation settles, so a later call
151
+ * with the same key runs fresh.
152
+ */
153
+ export async function singleFlight(map, key, run) {
154
+ const inflight = map.get(key);
155
+ if (inflight)
156
+ return inflight;
157
+ const p = run().finally(() => {
158
+ map.delete(key);
159
+ });
160
+ map.set(key, p);
161
+ return p;
162
+ }
146
163
  export class PimoteSessionManager {
147
164
  config;
148
165
  pushNotificationService;
149
166
  authStorage;
150
167
  modelRegistry;
168
+ loginOrchestrator;
151
169
  sessions = new Map();
170
+ /** In-flight `openSession` promises keyed by session file path, so two
171
+ * concurrent opens of the same on-disk session share one runtime instead
172
+ * of building (and leaking) a second one over the same file. */
173
+ inFlightOpens = new Map();
152
174
  idleCheckHandle = null;
153
175
  gitBranchCheckHandle = null;
154
176
  lastKnownGitBranchBySession = new Map();
@@ -159,12 +181,18 @@ export class PimoteSessionManager {
159
181
  * reap, explicit close). Consumers use this to drop external bookkeeping
160
182
  * (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
161
183
  onBeforeSessionClose;
184
+ /** Fired when a re-key collision evicts the slot currently holding the target
185
+ * session ID, BEFORE that slot is closed. Consumers notify the evicted slot's
186
+ * owning client (e.g. a `session_closed`/displaced event) while it is still
187
+ * addressable via getSlot. */
188
+ onSlotEvicted;
162
189
  staticHostFactory;
163
190
  constructor(config, pushNotificationService, options = {}) {
164
191
  this.config = config;
165
192
  this.pushNotificationService = pushNotificationService;
166
193
  this.authStorage = AuthStorage.create();
167
194
  this.modelRegistry = ModelRegistry.create(this.authStorage);
195
+ this.loginOrchestrator = new LoginOrchestrator(this.authStorage, this.modelRegistry);
168
196
  this.staticHostFactory = options.staticHostFactory;
169
197
  }
170
198
  /**
@@ -191,7 +219,37 @@ export class PimoteSessionManager {
191
219
  defaultWorkerModel: worker,
192
220
  });
193
221
  }
222
+ /**
223
+ * Open (or reopen) a session, returning its id.
224
+ *
225
+ * For an existing on-disk session (`sessionFilePath` provided), this guards
226
+ * against ever binding a SECOND pi runtime to the same session file: it
227
+ * returns the already-open session's id when it's live in memory, and
228
+ * coalesces concurrent opens of the same file into a single runtime. Without
229
+ * this, a reconnect double-fire or two devices opening the same session race
230
+ * between the (miss) existence check and the eventual `sessions.set`, spawn
231
+ * two runtimes appending to one file (corrupting history), and leak the
232
+ * first. New sessions (no file) create a fresh file each time, so they need
233
+ * no coalescing.
234
+ */
194
235
  async openSession(folderPath, sessionFilePath) {
236
+ if (!sessionFilePath) {
237
+ return this.doOpenSession(folderPath);
238
+ }
239
+ const alreadyOpenId = this.findSlotIdBySessionFile(sessionFilePath);
240
+ if (alreadyOpenId)
241
+ return alreadyOpenId;
242
+ return singleFlight(this.inFlightOpens, sessionFilePath, () => this.doOpenSession(folderPath, sessionFilePath));
243
+ }
244
+ /** Find the id of an open slot bound to the given session file, if any. */
245
+ findSlotIdBySessionFile(sessionFilePath) {
246
+ for (const [sid, slot] of this.sessions) {
247
+ if (slot.session.sessionFile === sessionFilePath)
248
+ return sid;
249
+ }
250
+ return undefined;
251
+ }
252
+ async doOpenSession(folderPath, sessionFilePath) {
195
253
  const eventBusRef = { current: null };
196
254
  const sharedAuthStorage = this.authStorage;
197
255
  const sharedModelRegistry = this.modelRegistry;
@@ -271,7 +329,7 @@ export class PimoteSessionManager {
271
329
  };
272
330
  slotRef.slot = slot;
273
331
  this.sessions.set(sessionId, slot);
274
- this.lastKnownGitBranchBySession.set(sessionId, getGitBranch(effectiveFolderPath));
332
+ this.lastKnownGitBranchBySession.set(sessionId, await getGitBranch(effectiveFolderPath));
275
333
  return sessionId;
276
334
  }
277
335
  handleAgentEnd(sessionId, slot) {
@@ -353,6 +411,45 @@ export class PimoteSessionManager {
353
411
  this.lastKnownGitBranchBySession.delete(oldId);
354
412
  this.lastKnownGitBranchBySession.set(newId, lastKnown);
355
413
  }
414
+ /** Sync read of the cached git branch for a session. Refreshed every 3s by the
415
+ * branch-check poll and seeded on open, so hot paths (sidebar broadcasts,
416
+ * get_session_meta) read this instead of shelling out to git on the event loop.
417
+ * May be up to ~3s stale; branch changes are rare so this is invisible in practice. */
418
+ getLastKnownGitBranch(sessionId) {
419
+ return this.lastKnownGitBranchBySession.get(sessionId) ?? null;
420
+ }
421
+ /** The single "session was replaced" business operation. Reconciles the session
422
+ * map (rebuild state, evict any collision, re-key) ALWAYS — regardless of whether
423
+ * a client owns the slot — so a reset triggered with no live owner can never leave
424
+ * the map keyed under a stale ID. Then notifies whatever connection currently owns
425
+ * the slot (never the issuer). All reset entry points (newSession/fork/navigateTree/
426
+ * switchSession, via WS commands or extension command-context) funnel here. */
427
+ async applySessionReset(slot) {
428
+ const newId = slot.runtime.session.sessionId;
429
+ const oldId = slot.sessionState.id;
430
+ // navigateTree stays in the same file — same session ID, nothing to re-key.
431
+ if (newId === oldId) {
432
+ await slot.connection?.onSessionReset?.(slot, { kind: 'unchanged' });
433
+ return;
434
+ }
435
+ // Rebuild session state (tears down old, creates new from runtime.session).
436
+ // Refreshes slot.folderPath from the new session header cwd (fork-from can
437
+ // change cwd, e.g. the worktree extension), so read folderPath after this.
438
+ this.rebuildSessionState(slot);
439
+ // Collision: another live slot already holds newId. This happens when an
440
+ // extension calls ctx.switchSession(path) onto a session file already open in
441
+ // a different slot (the new ID is the target file's existing ID, not a fresh
442
+ // one like fork's). Without eviction, reKeySession would overwrite the
443
+ // occupant's map entry and orphan its runtime — two runtimes on one file.
444
+ // Treat it as a takeover: notify the occupant's owner, then dispose it.
445
+ const occupant = this.sessions.get(newId);
446
+ if (occupant && occupant !== slot) {
447
+ this.onSlotEvicted?.(newId);
448
+ await this.closeSession(newId);
449
+ }
450
+ this.reKeySession(slot, oldId, newId);
451
+ await slot.connection?.onSessionReset?.(slot, { kind: 'rekeyed', oldId, newId, folderPath: slot.folderPath });
452
+ }
356
453
  /** Rebuild a slot's SessionState after session replacement.
357
454
  * Tears down the old state and creates a new one from the current runtime.session.
358
455
  * Also refreshes slot.folderPath from the new session's header cwd, since fork-from
@@ -378,6 +475,10 @@ export class PimoteSessionManager {
378
475
  getSession(sessionId) {
379
476
  return this.sessions.get(sessionId);
380
477
  }
478
+ /** The shared, server-wide login orchestrator (login is global, not session-scoped). */
479
+ getLoginOrchestrator() {
480
+ return this.loginOrchestrator;
481
+ }
381
482
  getAllSessions() {
382
483
  return Array.from(this.sessions.values());
383
484
  }
@@ -402,16 +503,18 @@ export class PimoteSessionManager {
402
503
  }
403
504
  }, 60_000);
404
505
  this.gitBranchCheckHandle = setInterval(() => {
405
- for (const [sessionId, slot] of this.sessions) {
406
- if (!slot.connection?.connectedClientId)
407
- continue;
408
- const next = getGitBranch(slot.folderPath);
506
+ // Snapshot connected sessions and refresh their branches in parallel. The
507
+ // lookups are async (execFile) so the poll never blocks the event loop;
508
+ // hot-path readers consume the cache via getLastKnownGitBranch.
509
+ const connected = [...this.sessions].filter(([, slot]) => slot.connection?.connectedClientId);
510
+ void Promise.all(connected.map(async ([sessionId, slot]) => {
511
+ const next = await getGitBranch(slot.folderPath);
409
512
  const prev = this.lastKnownGitBranchBySession.get(sessionId) ?? null;
410
513
  if (next !== prev) {
411
514
  this.lastKnownGitBranchBySession.set(sessionId, next);
412
515
  this.onGitBranchChange?.(sessionId, slot.folderPath);
413
516
  }
414
- }
517
+ }));
415
518
  }, 3000);
416
519
  }
417
520
  stopIdleCheck() {
@@ -25,6 +25,19 @@ export async function gcStaticHostStore(args) {
25
25
  }
26
26
  const suffix = '.json';
27
27
  for (const name of entries) {
28
+ // Orphan write tmp file (`<sessionId>.json.tmp`) left by a crash between
29
+ // writeFile and rename. GC runs at boot before any write, so a leftover
30
+ // .tmp is always stale — unlink unconditionally.
31
+ if (name.endsWith('.json.tmp')) {
32
+ try {
33
+ await unlink(join(storeDir, name));
34
+ }
35
+ catch (err) {
36
+ if (err.code !== 'ENOENT')
37
+ throw err;
38
+ }
39
+ continue;
40
+ }
28
41
  if (!name.endsWith(suffix))
29
42
  continue;
30
43
  const sessionId = name.slice(0, -suffix.length);
@@ -1,5 +1,5 @@
1
1
  import { createReadStream } from 'node:fs';
2
- import { stat } from 'node:fs/promises';
2
+ import { stat, realpath } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  const MIME_TYPES = {
5
5
  '.html': 'text/html; charset=utf-8',
@@ -126,11 +126,37 @@ export async function serveStaticHostRoute(req, res, registry) {
126
126
  send404(res);
127
127
  return true;
128
128
  }
129
+ // Symlink containment: stat()/createReadStream() follow symlinks, so a symlink
130
+ // INSIDE the bundle could point outside it (the `..` and path.resolve checks
131
+ // above are lexical and cannot see this). Resolve symlinks on both the target
132
+ // and the registered folder and require the real target to stay within.
133
+ try {
134
+ const [realTarget, realFolder] = await Promise.all([realpath(resolved), realpath(folderPath)]);
135
+ if (realTarget !== realFolder && !realTarget.startsWith(realFolder + path.sep)) {
136
+ send404(res);
137
+ return true;
138
+ }
139
+ }
140
+ catch {
141
+ send404(res);
142
+ return true;
143
+ }
129
144
  const ext = path.extname(resolved).toLowerCase();
130
145
  const mime = MIME_TYPES[ext] || 'application/octet-stream';
131
146
  res.writeHead(200, {
132
147
  'Content-Type': mime,
133
148
  'Cache-Control': 'no-cache, no-store, must-revalidate',
149
+ // Agent-authored content served same-origin with the control PWA. Two
150
+ // hardening headers (review finding M4):
151
+ // - nosniff: never let the browser MIME-sniff a bundle file into a script.
152
+ // - CSP connect-src http:/https: blocks ws:/wss:, so a (prompt-injected)
153
+ // bundle cannot open pimote's authenticated WebSocket and drive sessions.
154
+ // We stay same-origin (not sandboxed) so bundles keep localStorage and
155
+ // same-origin asset fetches; same-origin HTTP fetch + storage reads remain
156
+ // possible but are contained by the single-user model + edge auth. Full
157
+ // isolation would require serving bundles from a separate origin.
158
+ 'X-Content-Type-Options': 'nosniff',
159
+ 'Content-Security-Policy': 'connect-src http: https:',
134
160
  });
135
161
  if (req.method === 'HEAD') {
136
162
  res.end();
@@ -1,5 +1,5 @@
1
1
  import { Type } from 'typebox';
2
- import { executeRegisterTool, executeRemoveTool } from './tools.js';
2
+ import { executeRegisterTool, executeRemoveTool, resolveSlugCollision } from './tools.js';
3
3
  import { STATIC_HOST_TOOL_DESCRIPTION } from './prompt.js';
4
4
  export { InMemoryStaticHostRegistry } from './registry.js';
5
5
  export { FileStaticHostStore } from './store.js';
@@ -94,23 +94,35 @@ export function createStaticHostExtension(opts) {
94
94
  const file = await store.read(sessionId);
95
95
  if (!file)
96
96
  return;
97
+ // Replay persisted entries, re-suffixing any slug already taken (another
98
+ // session persisted the same slug, or this session reloaded earlier this
99
+ // boot). Re-suffixing keeps the bundle reachable; the old behaviour left a
100
+ // phantom entry in the file that the remove tool could never match (its
101
+ // registry lookup failed) and that got re-appended on every future write.
102
+ const replayed = [];
103
+ let mutated = false;
97
104
  for (const entry of file.entries) {
105
+ let slug = entry.slug;
106
+ if (registry.has(slug)) {
107
+ slug = resolveSlugCollision(slug, registry);
108
+ mutated = true;
109
+ }
98
110
  try {
99
- registry.register({
100
- slug: entry.slug,
101
- folderPath: entry.folderPath,
102
- sessionId,
103
- cardMetadata: entry.cardMetadata,
104
- });
111
+ registry.register({ slug, folderPath: entry.folderPath, sessionId, cardMetadata: entry.cardMetadata });
112
+ replayed.push(slug === entry.slug ? entry : { ...entry, slug });
105
113
  }
106
114
  catch (err) {
107
- // Defensive: a slug conflict (e.g. two sessions persisted the same
108
- // slug, or another session reloaded earlier this boot) must not
109
- // abort the whole replay loop and leave the session partially
110
- // loaded. Skip the conflicting entry and continue.
111
- console.warn(`[static-host] session_start: skipping persisted entry ${entry.slug} for session ${sessionId}`, err);
115
+ // Couldn't register even after re-suffixing drop it from the file so
116
+ // it doesn't linger as a phantom on the next write.
117
+ mutated = true;
118
+ console.warn(`[static-host] session_start: dropping unregisterable entry ${entry.slug} for session ${sessionId}`, err);
112
119
  }
113
120
  }
121
+ // Persist the reconciled list only if something changed, so the common
122
+ // conflict-free replay performs no write.
123
+ if (mutated) {
124
+ await store.write(sessionId, { version: 1, entries: replayed });
125
+ }
114
126
  emitPanelCards(pi, sessionId);
115
127
  });
116
128
  pi.on('session_shutdown', async (_ev, ctx) => {
@@ -22,7 +22,16 @@ export class FileStaticHostStore {
22
22
  return undefined;
23
23
  throw err;
24
24
  }
25
- return JSON.parse(raw);
25
+ try {
26
+ return JSON.parse(raw);
27
+ }
28
+ catch (err) {
29
+ // A truncated/corrupt file must not reject out of the async session_start
30
+ // handler (which could break session load). Treat it as "no state" — the
31
+ // next write overwrites it atomically.
32
+ console.warn(`[static-host] ignoring corrupt store file ${path}:`, err.message ?? err);
33
+ return undefined;
34
+ }
26
35
  }
27
36
  async write(sessionId, file) {
28
37
  await mkdir(this.storeDir, { recursive: true });
@@ -1,5 +1,5 @@
1
1
  import { stat } from 'node:fs/promises';
2
- import { isAbsolute, join } from 'node:path';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
3
  /**
4
4
  * Validates and normalises a slug.
5
5
  *
@@ -56,6 +56,10 @@ export async function executeRegisterTool(input, deps) {
56
56
  if (typeof input.folder !== 'string' || !isAbsolute(input.folder)) {
57
57
  throw new Error(`folder must be an absolute path: ${JSON.stringify(input.folder)}`);
58
58
  }
59
+ // Normalize before stat/persist/register: a trailing slash or internal `..`
60
+ // segment would otherwise break the http-handler's containment check (which
61
+ // compares a resolved request path against `folderPath + path.sep`).
62
+ input.folder = resolve(input.folder);
59
63
  let folderStat;
60
64
  try {
61
65
  folderStat = await stat(input.folder);
@@ -19,7 +19,7 @@
19
19
  // place to handle them because it lives at the boundary between
20
20
  // "do we even have a connection" and "what should the agent do".)
21
21
  import { reduceLifecycle, applyLifecycleResult, bufferOrPassFrame } from './reducers/lifecycle.js';
22
- import { reduceStreaming } from './reducers/streaming.js';
22
+ import { reduceStreaming, currentStreamingSpeakId } from './reducers/streaming.js';
23
23
  import { reduceWalkback, applyWalkbackResult } from './reducers/walkback.js';
24
24
  export function reduce(prev, event, reducers) {
25
25
  let state = prev;
@@ -53,7 +53,19 @@ export function reduce(prev, event, reducers) {
53
53
  }
54
54
  }
55
55
  // ---- Walkback ----------------------------------------------------------
56
- const wb = reduceWalkback(state.walkback, state.lastEmittedSpeakId, event);
56
+ // Pass:
57
+ // - lifecycle kind, so abort/rollback frames arriving when no call is active
58
+ // are dropped (e.g. in flight during teardown) — a stray abort would
59
+ // otherwise abort a text-mode turn. (H3)
60
+ // - the in-flight speak id, so an interrupt targeting a still-streaming
61
+ // speak resolves correctly when the frame omits a speak_id. (gap 2)
62
+ // `state.message` is post-streaming here, so its blocks still hold the
63
+ // in-flight speak (ws:incoming doesn't clear them).
64
+ const wb = reduceWalkback(state.walkback, event, {
65
+ lastEmittedSpeakId: state.lastEmittedSpeakId,
66
+ currentStreamingSpeakId: currentStreamingSpeakId(state.message),
67
+ lifecycleKind: state.lifecycle.kind,
68
+ });
57
69
  state = applyWalkbackResult(state, wb);
58
70
  actions.push(...wb.actions);
59
71
  // Clear lastEmittedSpeakId on full deactivation so a subsequent call
@@ -38,7 +38,11 @@ export function reduceLifecycle(prev, event, ctx) {
38
38
  modelId: ctx.config.defaultInterpreterModel.modelId,
39
39
  });
40
40
  }
41
- actions.push({ kind: 'send_user_message', text: VOICE_CALL_STARTED_SENTINEL });
41
+ // Steer the start sentinel rather than aborting: if the agent is mid-task
42
+ // when the call binds, preserve that work. The executor injects into the
43
+ // running turn when busy, and sends normally (triggering the greeting)
44
+ // when idle. (M7)
45
+ actions.push({ kind: 'send_user_message', text: VOICE_CALL_STARTED_SENTINEL, deliverAs: 'steer' });
42
46
  actions.push({ kind: 'open_ws', url: event.msg.speechmuxWsUrl });
43
47
  return {
44
48
  next: {
@@ -98,21 +102,23 @@ export function reduceLifecycle(prev, event, ctx) {
98
102
  return { next: prev, interpreterAppliedNow: false, actions: [] };
99
103
  }
100
104
  // Drop any buffered frames; the shell will rebuild from scratch
101
- // on the next activate.
105
+ // on the next activate. Carry the sessionId on the action from the
106
+ // pre-transition state — after this we're dormant. (M1)
102
107
  return {
103
108
  next: { kind: 'dormant' },
104
109
  interpreterAppliedNow: false,
105
- actions: [{ kind: 'emit_deactivate_request' }],
110
+ actions: [{ kind: 'emit_deactivate_request', sessionId: prev.sessionId }],
106
111
  };
107
112
  }
108
113
  case 'ws:disconnected': {
109
114
  if (prev.kind === 'dormant') {
110
115
  return { next: prev, interpreterAppliedNow: false, actions: [] };
111
116
  }
117
+ // prev is activating|active here — both carry sessionId. (M1)
112
118
  return {
113
119
  next: { kind: 'dormant' },
114
120
  interpreterAppliedNow: false,
115
- actions: [{ kind: 'emit_deactivate_request' }],
121
+ actions: [{ kind: 'emit_deactivate_request', sessionId: prev.sessionId }],
116
122
  };
117
123
  }
118
124
  default:
@@ -44,15 +44,41 @@ const noFrames = (next) => ({
44
44
  export function reduceStreaming(prev, event) {
45
45
  switch (event.type) {
46
46
  case 'sdk:message_start':
47
- // Assistant message starts → wipe per-block state. (Filtering on
47
+ // Assistant message starts → wipe per-block state and clear the
48
+ // interrupt latch (a new turn can emit again). (Filtering on
48
49
  // role==='assistant' happens at the dispatcher.)
49
- return noFrames({ blocks: new Map() });
50
+ return noFrames({ blocks: new Map(), interrupted: false });
51
+ case 'ws:incoming':
52
+ // A barge-in latches `interrupted` so we stop feeding speechmux tokens
53
+ // for an utterance it already aborted. Reset on the next message_start.
54
+ if (event.frame.type === 'abort' || event.frame.type === 'rollback') {
55
+ return noFrames({ ...prev, interrupted: true });
56
+ }
57
+ return noFrames(prev);
50
58
  case 'sdk:toolcall_start':
59
+ if (prev.interrupted)
60
+ return noFrames(prev);
51
61
  return noFrames(setBlock(prev, event.contentIndex, blockFromPartial(event.contentIndex, event.partial)));
52
62
  case 'sdk:toolcall_delta':
63
+ if (prev.interrupted)
64
+ return noFrames(prev);
53
65
  return reduceDelta(prev, event.contentIndex, event.delta, event.partial);
54
66
  case 'sdk:toolcall_end':
67
+ if (prev.interrupted)
68
+ return noFrames(prev);
55
69
  return reduceEnd(prev, event.contentIndex, event.toolCall);
70
+ case 'sdk:turn_end':
71
+ // Release the floor for the turn's last spoken utterance. Routed as a
72
+ // frame so the lifecycle layer buffers it during `activating` and passes
73
+ // it during `active` — the same discipline as token/end frames. (M2)
74
+ return {
75
+ next: prev,
76
+ frames: [event.lastSpeakToolCallId ? { type: 'floor_released', speak_id: event.lastSpeakToolCallId } : { type: 'floor_released' }],
77
+ endedSpeakIds: [],
78
+ };
79
+ case 'sdk:agent_end':
80
+ // Surface a harness-side error to speechmux. (M2)
81
+ return event.error ? { next: prev, frames: [{ type: 'error', message: event.error }], endedSpeakIds: [] } : noFrames(prev);
56
82
  default:
57
83
  return noFrames(prev);
58
84
  }
@@ -165,7 +191,17 @@ function setBlock(state, idx, block) {
165
191
  return state;
166
192
  const blocks = new Map(state.blocks);
167
193
  blocks.set(idx, block);
168
- return { blocks };
194
+ return { ...state, blocks };
195
+ }
196
+ /** Toolcall id of the speak() block currently mid-stream (if any). The most
197
+ * likely walkback target when speechmux's frame omits a speak_id: an in-flight
198
+ * speak hasn't emitted its `end`, so it isn't in `lastEmittedSpeakId` yet. */
199
+ export function currentStreamingSpeakId(message) {
200
+ for (const block of message.blocks.values()) {
201
+ if (block.kind === 'speak_streaming' && block.toolCallId)
202
+ return block.toolCallId;
203
+ }
204
+ return null;
169
205
  }
170
206
  function partialBlock(partial, idx) {
171
207
  const c = partial?.content;
@@ -18,27 +18,30 @@
18
18
  // such ambiguity.
19
19
  import { VOICE_INTERRUPT_CUSTOM_TYPE } from '../../../../../shared/dist/index.js';
20
20
  import { walkBack } from '../../walk-back.js';
21
- /** Resolve which speak() id to walk back to. Prefers what speechmux
22
- * echoes; falls back to runtime-tracked latest. Returns null if neither
23
- * is available (we'll degrade gracefully — abort the agent but skip
24
- * the rewrite). */
25
- function resolveTarget(frameSpeakId, lastEmittedSpeakId) {
26
- if (frameSpeakId)
27
- return frameSpeakId;
28
- return lastEmittedSpeakId;
21
+ /** Resolve which speak() id to walk back to. Prefers what speechmux echoes;
22
+ * then the in-flight speak; then the last fully-emitted one. Returns null if
23
+ * none is available (degrade gracefully — abort the agent, skip the rewrite). */
24
+ function resolveTarget(frameSpeakId, ctx) {
25
+ return frameSpeakId ?? ctx.currentStreamingSpeakId ?? ctx.lastEmittedSpeakId;
29
26
  }
30
- export function reduceWalkback(prev, lastEmittedSpeakId, event) {
27
+ export function reduceWalkback(prev, event, ctx) {
31
28
  switch (event.type) {
32
29
  case 'ws:incoming': {
33
30
  const f = event.frame;
34
31
  if (f.type === 'user')
35
32
  return { next: prev, actions: [] };
33
+ // Only honour barge-in (abort/rollback) while a call is live. A frame
34
+ // arriving when dormant (e.g. in flight during teardown, or from a
35
+ // just-discarded client) must not abort an unrelated text-mode turn. (H3)
36
+ if (ctx.lifecycleKind !== 'active' && ctx.lifecycleKind !== 'activating') {
37
+ return { next: prev, actions: [] };
38
+ }
36
39
  const heardText = f.type === 'rollback' ? f.heard_text : '';
37
40
  const data = {
38
41
  heard_text: heardText,
39
42
  kind: f.type === 'rollback' ? 'rollback' : 'abort',
40
43
  };
41
- const target = resolveTarget(f.speak_id, lastEmittedSpeakId);
44
+ const target = resolveTarget(f.speak_id, ctx);
42
45
  const actions = [{ kind: 'abort_agent' }, { kind: 'append_custom_entry', customType: VOICE_INTERRUPT_CUSTOM_TYPE, data }];
43
46
  if (target === null) {
44
47
  // No target available → can't rewrite. Just abort + record the
@@ -13,7 +13,7 @@
13
13
  export function initialState() {
14
14
  return {
15
15
  lifecycle: { kind: 'dormant' },
16
- message: { blocks: new Map() },
16
+ message: { blocks: new Map(), interrupted: false },
17
17
  walkback: { kind: 'idle' },
18
18
  interpreterApplied: false,
19
19
  lastEmittedSpeakId: null,