@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.
- package/README.md +4 -1
- package/client/build/_app/immutable/assets/0.DmHGeVyH.css +2 -0
- package/client/build/_app/immutable/assets/{2.bfMycywk.css → 2.BtlPyuHL.css} +1 -1
- package/client/build/_app/immutable/chunks/BjlKVpoO.js +1 -0
- package/client/build/_app/immutable/chunks/Blm_TLGW.js +1 -0
- package/client/build/_app/immutable/chunks/COcpV1OD.js +1 -0
- package/client/build/_app/immutable/chunks/DMWd5mk8.js +1 -0
- package/client/build/_app/immutable/chunks/{DNqQZw5U.js → Daen0SYI.js} +2 -2
- package/client/build/_app/immutable/entry/{app.DZYoujEP.js → app.DW3BNxC_.js} +2 -2
- package/client/build/_app/immutable/entry/start.DUfrZpFg.js +1 -0
- package/client/build/_app/immutable/nodes/0.DzBRsuZ_.js +10 -0
- package/client/build/_app/immutable/nodes/{1.B5qlqMFD.js → 1.y-VB1JIj.js} +1 -1
- package/client/build/_app/immutable/nodes/2.Bz9KycIe.js +55 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +7 -7
- package/package.json +2 -2
- package/server/dist/config.js +5 -2
- package/server/dist/event-buffer.js +9 -0
- package/server/dist/extension-ui-bridge.js +26 -10
- package/server/dist/file-references.js +123 -0
- package/server/dist/git-branch.js +12 -9
- package/server/dist/login-orchestrator.js +114 -0
- package/server/dist/push-infrastructure.js +13 -2
- package/server/dist/push-notification.js +18 -11
- package/server/dist/server.js +25 -2
- package/server/dist/session-cost.js +26 -2
- package/server/dist/session-manager.js +109 -6
- package/server/dist/static-host/gc.js +13 -0
- package/server/dist/static-host/http-handler.js +27 -1
- package/server/dist/static-host/index.js +24 -12
- package/server/dist/static-host/store.js +10 -1
- package/server/dist/static-host/tools.js +5 -1
- package/server/dist/voice/fsm/reducer.js +14 -2
- package/server/dist/voice/fsm/reducers/lifecycle.js +10 -4
- package/server/dist/voice/fsm/reducers/streaming.js +39 -3
- package/server/dist/voice/fsm/reducers/walkback.js +13 -10
- package/server/dist/voice/fsm/state.js +1 -1
- package/server/dist/voice/index.js +97 -41
- package/server/dist/voice/walk-back.js +94 -26
- package/server/dist/voice-orchestrator-boot.js +22 -5
- package/server/dist/voice-orchestrator.js +38 -1
- package/server/dist/ws-handler.js +195 -63
- package/shared/dist/protocol.d.ts +99 -2
- package/client/build/_app/immutable/assets/0.Dh2gYJ1J.css +0 -2
- package/client/build/_app/immutable/chunks/Czpnrh9t.js +0 -1
- package/client/build/_app/immutable/chunks/D1mCuOEu.js +0 -1
- package/client/build/_app/immutable/chunks/DHiuV2ft.js +0 -1
- package/client/build/_app/immutable/chunks/DegHYiTr.js +0 -1
- package/client/build/_app/immutable/entry/start.BNnDRfmt.js +0 -1
- package/client/build/_app/immutable/nodes/0.B20DMuGn.js +0 -10
- 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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
23
|
-
* is available (
|
|
24
|
-
|
|
25
|
-
|
|
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,
|
|
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,
|
|
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,
|