@pimote/pimote 0.2.0 → 0.3.1
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 +43 -16
- package/client/build/_app/immutable/assets/0.C7loWTOC.css +2 -0
- package/client/build/_app/immutable/assets/2.DwPXxSa-.css +1 -0
- package/client/build/_app/immutable/chunks/-Lc-U-GJ.js +1 -0
- package/client/build/_app/immutable/chunks/{CT6ckxpD.js → CO_BwWGt.js} +1 -1
- package/client/build/_app/immutable/chunks/CklMSqcv.js +1 -0
- package/client/build/_app/immutable/chunks/D1INvMB9.js +1 -0
- package/client/build/_app/immutable/chunks/D1vhgXpq.js +5 -0
- package/client/build/_app/immutable/entry/{app.CNzpBgAg.js → app.B-HFVtpC.js} +2 -2
- package/client/build/_app/immutable/entry/start.DJTQ8-sD.js +1 -0
- package/client/build/_app/immutable/nodes/0.CepAO4xf.js +10 -0
- package/client/build/_app/immutable/nodes/{1.B8zmHMre.js → 1.CmxFYjRm.js} +1 -1
- package/client/build/_app/immutable/nodes/2.DAtqfmki.js +54 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +7 -7
- package/package.json +7 -3
- package/server/dist/auto-drain-on-abort.js +49 -0
- package/server/dist/config.js +21 -0
- package/server/dist/extension-ui-bridge.js +14 -1
- package/server/dist/index.js +36 -1
- package/server/dist/message-mapper.js +38 -6
- package/server/dist/push-notification.js +11 -0
- package/server/dist/server.js +2 -2
- package/server/dist/session-manager.js +72 -4
- package/server/dist/voice/fsm/actions.js +6 -0
- package/server/dist/voice/fsm/events.js +7 -0
- package/server/dist/voice/fsm/reducer.js +74 -0
- package/server/dist/voice/fsm/reducers/lifecycle.js +158 -0
- package/server/dist/voice/fsm/reducers/streaming.js +220 -0
- package/server/dist/voice/fsm/reducers/walkback.js +73 -0
- package/server/dist/voice/fsm/state.js +21 -0
- package/server/dist/voice/fsm/text-extractor.js +128 -0
- package/server/dist/voice/index.js +336 -0
- package/server/dist/voice/interpreter-prompt.js +115 -0
- package/server/dist/voice/speechmux-client.js +153 -0
- package/server/dist/voice/state-machine.js +14 -0
- package/server/dist/voice/wait-for-idle.js +67 -0
- package/server/dist/voice/walk-back.js +198 -0
- package/server/dist/voice-orchestrator-boot.js +90 -0
- package/server/dist/voice-orchestrator.js +91 -0
- package/server/dist/ws-handler.js +112 -7
- package/shared/dist/index.d.ts +1 -0
- package/shared/dist/index.js +2 -0
- package/shared/dist/protocol.d.ts +614 -0
- package/shared/dist/protocol.js +30 -0
- package/client/build/_app/immutable/assets/0.DBrr7n4n.css +0 -2
- package/client/build/_app/immutable/assets/2.DE6k3bQj.css +0 -1
- package/client/build/_app/immutable/chunks/5vSSf6qG.js +0 -5
- package/client/build/_app/immutable/chunks/DlJOVoUQ.js +0 -1
- package/client/build/_app/immutable/chunks/YxmLwfhj.js +0 -1
- package/client/build/_app/immutable/chunks/yWVx3W2o.js +0 -1
- package/client/build/_app/immutable/entry/start.DYkTAHh1.js +0 -1
- package/client/build/_app/immutable/nodes/0.DNlQhEb_.js +0 -10
- package/client/build/_app/immutable/nodes/2.W9yV4-x2.js +0 -54
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pimote/pimote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Web client and embedded server for pi with multi-session browser access, streaming, and extension UI support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"bin/pimote.js",
|
|
30
30
|
"client/build/**",
|
|
31
31
|
"server/dist/**/*.js",
|
|
32
|
+
"shared/dist/**/*.js",
|
|
33
|
+
"shared/dist/**/*.d.ts",
|
|
32
34
|
"scripts/postinstall-patches.mjs",
|
|
33
35
|
"patches/@mariozechner+pi-coding-agent+0.67.6.patch",
|
|
34
36
|
"README.md",
|
|
@@ -38,13 +40,13 @@
|
|
|
38
40
|
"node": ">=22.0.0"
|
|
39
41
|
},
|
|
40
42
|
"workspaces": [
|
|
41
|
-
"shared",
|
|
42
43
|
"server",
|
|
43
44
|
"client",
|
|
44
45
|
"packages/panels"
|
|
45
46
|
],
|
|
46
47
|
"scripts": {
|
|
47
|
-
"build": "npm run build
|
|
48
|
+
"build": "npm run build:shared && npm run build --workspace=@pimote/server && npm run build --workspace=client",
|
|
49
|
+
"build:shared": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('shared/dist', { recursive: true, force: true }); rmSync('shared/tsconfig.tsbuildinfo', { force: true });\" && tsc -b shared --force",
|
|
48
50
|
"start": "node ./bin/pimote.js start",
|
|
49
51
|
"format": "prettier --write .",
|
|
50
52
|
"format:check": "prettier --check .",
|
|
@@ -74,6 +76,8 @@
|
|
|
74
76
|
"dependencies": {
|
|
75
77
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
|
76
78
|
"@mariozechner/pi-coding-agent": "0.67.6",
|
|
79
|
+
"@sinclair/typebox": "^0.34.49",
|
|
80
|
+
"@streamparser/json": "^0.0.22",
|
|
77
81
|
"patch-package": "^8.0.1",
|
|
78
82
|
"web-push": "^3.6.7",
|
|
79
83
|
"ws": "^8.20.0"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Auto-drain queued steering / follow-up messages after an aborted run.
|
|
2
|
+
//
|
|
3
|
+
// pi-agent-core's `runLoop` exits without polling the steering queue when
|
|
4
|
+
// an abort signal fires mid-stream (see `agent-loop.js` — the
|
|
5
|
+
// `stopReason === 'aborted'` branch returns immediately, before the
|
|
6
|
+
// trailing `getSteeringMessages` poll). Any messages queued before / during
|
|
7
|
+
// the abort would otherwise sit in the queue until something else calls
|
|
8
|
+
// `agent.prompt()` or `agent.continue()`.
|
|
9
|
+
//
|
|
10
|
+
// In pimote this surfaces as silently-dropped user messages whenever a
|
|
11
|
+
// queued steer races with an abort — most visibly during voice-mode
|
|
12
|
+
// barge-in, but also for typed-mode users who queue a follow-up while the
|
|
13
|
+
// agent is streaming and then hit the abort button.
|
|
14
|
+
//
|
|
15
|
+
// The fix is to call `agent.continue()` after the run settles: from the
|
|
16
|
+
// pi-agent-core source, `continue()` explicitly drains the steering queue
|
|
17
|
+
// (and falls back to follow-up if steering is empty) and replays the
|
|
18
|
+
// drained messages as a fresh prompt.
|
|
19
|
+
/**
|
|
20
|
+
* If `lastMessage` is the aborted-assistant synthetic that pi appends on
|
|
21
|
+
* abort and the session has queued messages, wait for the current run to
|
|
22
|
+
* settle and then drain the queue via `agent.continue()`.
|
|
23
|
+
*
|
|
24
|
+
* Idempotent: after `continue()` runs, the queue is empty so a re-entry
|
|
25
|
+
* (e.g. another agent_end) becomes a no-op. Errors from `continue()`
|
|
26
|
+
* (notably "Agent is already processing" when something else races us to
|
|
27
|
+
* a new prompt) are swallowed via `onError` — losing the auto-drain in
|
|
28
|
+
* that case is acceptable because the racing prompt itself will drain
|
|
29
|
+
* the queue inside `runLoop`'s initial `getSteeringMessages` poll.
|
|
30
|
+
*/
|
|
31
|
+
export async function autoDrainOnAbort(session, lastMessage, onError = (err) => console.warn('[pimote] auto-drain after abort failed', err)) {
|
|
32
|
+
if (!lastMessage || lastMessage.stopReason !== 'aborted') {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
// The `agent_end` listener fires before `finishRun()` clears the
|
|
37
|
+
// run's `activeRun` reference. `waitForIdle()` resolves on the same
|
|
38
|
+
// promise that `finishRun` resolves, so awaiting it parks us until
|
|
39
|
+
// `agent.continue()` is safe to call without throwing
|
|
40
|
+
// "Agent is already processing".
|
|
41
|
+
await session.agent.waitForIdle();
|
|
42
|
+
if (session.pendingMessageCount === 0)
|
|
43
|
+
return;
|
|
44
|
+
await session.agent.continue();
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
onError(err);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/server/dist/config.js
CHANGED
|
@@ -48,11 +48,32 @@ export async function loadConfig() {
|
|
|
48
48
|
defaultProvider: typeof obj.defaultProvider === 'string' ? obj.defaultProvider : undefined,
|
|
49
49
|
defaultModel: typeof obj.defaultModel === 'string' ? obj.defaultModel : undefined,
|
|
50
50
|
defaultThinkingLevel: typeof obj.defaultThinkingLevel === 'string' ? obj.defaultThinkingLevel : undefined,
|
|
51
|
+
defaultInterpreterModel: parseModelRef(obj.defaultInterpreterModel),
|
|
52
|
+
defaultWorkerModel: parseModelRef(obj.defaultWorkerModel),
|
|
53
|
+
voice: parseVoiceConfig(obj.voice),
|
|
51
54
|
vapidPublicKey: typeof obj.vapidPublicKey === 'string' ? obj.vapidPublicKey : undefined,
|
|
52
55
|
vapidPrivateKey: typeof obj.vapidPrivateKey === 'string' ? obj.vapidPrivateKey : undefined,
|
|
53
56
|
vapidEmail: typeof obj.vapidEmail === 'string' ? obj.vapidEmail : undefined,
|
|
54
57
|
};
|
|
55
58
|
}
|
|
59
|
+
function parseModelRef(v) {
|
|
60
|
+
if (!v || typeof v !== 'object')
|
|
61
|
+
return undefined;
|
|
62
|
+
const o = v;
|
|
63
|
+
if (typeof o.provider !== 'string' || typeof o.modelId !== 'string')
|
|
64
|
+
return undefined;
|
|
65
|
+
return { provider: o.provider, modelId: o.modelId };
|
|
66
|
+
}
|
|
67
|
+
function parseVoiceConfig(v) {
|
|
68
|
+
if (!v || typeof v !== 'object')
|
|
69
|
+
return undefined;
|
|
70
|
+
const o = v;
|
|
71
|
+
return {
|
|
72
|
+
speechmuxBinary: typeof o.speechmuxBinary === 'string' ? o.speechmuxBinary : undefined,
|
|
73
|
+
speechmuxSignalUrl: typeof o.speechmuxSignalUrl === 'string' ? o.speechmuxSignalUrl : undefined,
|
|
74
|
+
speechmuxLlmWsUrl: typeof o.speechmuxLlmWsUrl === 'string' ? o.speechmuxLlmWsUrl : undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
56
77
|
export async function ensureVapidKeys(config) {
|
|
57
78
|
if (config.vapidPublicKey && config.vapidPrivateKey) {
|
|
58
79
|
return config;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UI_BRIDGE_DISABLED_IN_VOICE_MODE } from '../../shared/dist/index.js';
|
|
1
2
|
import { sendSlotEvent, waitForSlotUiResponse } from './session-manager.js';
|
|
2
3
|
/**
|
|
3
4
|
* Creates an ExtensionUIContext implementation that bridges pi extension UI calls
|
|
@@ -12,7 +13,11 @@ import { sendSlotEvent, waitForSlotUiResponse } from './session-manager.js';
|
|
|
12
13
|
* is updated and the bridge automatically routes to the new connection.
|
|
13
14
|
* Pending UI promises survive reconnects and are replayed to the new client.
|
|
14
15
|
*/
|
|
15
|
-
export function createExtensionUIBridge(slot, pushNotificationService) {
|
|
16
|
+
export function createExtensionUIBridge(slot, pushNotificationService, options) {
|
|
17
|
+
const isVoice = () => options?.isVoiceModeActive?.() ?? false;
|
|
18
|
+
function voiceDisabledError() {
|
|
19
|
+
return new Error(UI_BRIDGE_DISABLED_IN_VOICE_MODE);
|
|
20
|
+
}
|
|
16
21
|
function notifyInteraction(method, fields) {
|
|
17
22
|
if (!pushNotificationService)
|
|
18
23
|
return;
|
|
@@ -72,24 +77,32 @@ export function createExtensionUIBridge(slot, pushNotificationService) {
|
|
|
72
77
|
const ui = {
|
|
73
78
|
// ---- Dialog methods (send + wait for response) ----
|
|
74
79
|
async select(title, options, opts) {
|
|
80
|
+
if (isVoice())
|
|
81
|
+
throw voiceDisabledError();
|
|
75
82
|
const requestId = crypto.randomUUID();
|
|
76
83
|
const event = sendRequest(requestId, { method: 'select', title, options });
|
|
77
84
|
notifyInteraction('select', { title, options });
|
|
78
85
|
return dialogWithTimeout(requestId, event, opts, undefined);
|
|
79
86
|
},
|
|
80
87
|
async confirm(title, message, opts) {
|
|
88
|
+
if (isVoice())
|
|
89
|
+
throw voiceDisabledError();
|
|
81
90
|
const requestId = crypto.randomUUID();
|
|
82
91
|
const event = sendRequest(requestId, { method: 'confirm', title, message });
|
|
83
92
|
notifyInteraction('confirm', { title, message });
|
|
84
93
|
return dialogWithTimeout(requestId, event, opts, false);
|
|
85
94
|
},
|
|
86
95
|
async input(title, placeholder, opts) {
|
|
96
|
+
if (isVoice())
|
|
97
|
+
throw voiceDisabledError();
|
|
87
98
|
const requestId = crypto.randomUUID();
|
|
88
99
|
const event = sendRequest(requestId, { method: 'input', title, placeholder });
|
|
89
100
|
notifyInteraction('input', { title });
|
|
90
101
|
return dialogWithTimeout(requestId, event, opts, undefined);
|
|
91
102
|
},
|
|
92
103
|
async editor(title, prefill) {
|
|
104
|
+
if (isVoice())
|
|
105
|
+
throw voiceDisabledError();
|
|
93
106
|
const requestId = crypto.randomUUID();
|
|
94
107
|
const event = sendRequest(requestId, { method: 'editor', title, prefill });
|
|
95
108
|
notifyInteraction('editor', { title });
|
package/server/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { PushNotificationService } from './push-notification.js';
|
|
|
8
8
|
import { FilePushSubscriptionStore, WebPushSender, migratePushSubscriptionStore } from './push-infrastructure.js';
|
|
9
9
|
import { LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_SESSION_METADATA_PATH } from './paths.js';
|
|
10
10
|
import { FileSessionMetadataStore } from './session-metadata.js';
|
|
11
|
+
import { buildVoiceOrchestrator } from './voice-orchestrator-boot.js';
|
|
11
12
|
export async function main(options = {}) {
|
|
12
13
|
let config = await loadConfig();
|
|
13
14
|
config = await ensureVapidKeys(config);
|
|
@@ -23,7 +24,40 @@ export async function main(options = {}) {
|
|
|
23
24
|
const sessionMetadataStore = new FileSessionMetadataStore(PIMOTE_SESSION_METADATA_PATH);
|
|
24
25
|
await sessionMetadataStore.initialize();
|
|
25
26
|
const sessionManager = new PimoteSessionManager(config, pushNotificationService);
|
|
26
|
-
|
|
27
|
+
// Build the voice orchestrator before createServer so each WsHandler can be
|
|
28
|
+
// handed a reference. The orchestrator needs a client-registry lookup, but
|
|
29
|
+
// the real registry is created inside createServer below — so we hand it a
|
|
30
|
+
// small forwarding shim whose backing map is swapped in after createServer
|
|
31
|
+
// returns (see review finding 6: previously a Proxy-over-Map).
|
|
32
|
+
const clientRegistryRef = { current: new Map() };
|
|
33
|
+
const voiceBoot = buildVoiceOrchestrator({
|
|
34
|
+
config,
|
|
35
|
+
sessionManager,
|
|
36
|
+
clientRegistry: {
|
|
37
|
+
get: (clientId) => clientRegistryRef.current.get(clientId),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const server = await createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceBoot.orchestrator);
|
|
41
|
+
clientRegistryRef.current = server.clientRegistry;
|
|
42
|
+
// Suppress push notifications for sessions currently owned by a voice call.
|
|
43
|
+
// The user is on the line — we don't need to ping their phone for idle
|
|
44
|
+
// signals or extension UI prompts. Pushes resume automatically once the
|
|
45
|
+
// call ends and `isCallActive` flips back to false.
|
|
46
|
+
pushNotificationService.setSuppressionPredicate((sessionId) => voiceBoot.orchestrator.isCallActive(sessionId));
|
|
47
|
+
// Tear down orchestrator bookkeeping when a session is being closed (idle
|
|
48
|
+
// reap, explicit close). Emits call_ended{server_ended} to the owner.
|
|
49
|
+
sessionManager.onBeforeSessionClose = async (sessionId) => {
|
|
50
|
+
if (!voiceBoot.orchestrator.isCallActive(sessionId))
|
|
51
|
+
return;
|
|
52
|
+
const slot = sessionManager.getSlot(sessionId);
|
|
53
|
+
const ownerClientId = slot?.connection?.connectedClientId;
|
|
54
|
+
await voiceBoot.orchestrator.endCall({ sessionId, reason: 'server_ended' });
|
|
55
|
+
if (ownerClientId) {
|
|
56
|
+
const handler = server.clientRegistry.get(ownerClientId);
|
|
57
|
+
handler?.sendCallEndedEvent(sessionId, 'server_ended');
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
await voiceBoot.orchestrator.start();
|
|
27
61
|
// Start idle session reaping with client connectivity check
|
|
28
62
|
sessionManager.startIdleCheck(config.idleTimeout, (clientId) => server.clientRegistry.has(clientId));
|
|
29
63
|
await server.start(port);
|
|
@@ -36,6 +70,7 @@ export async function main(options = {}) {
|
|
|
36
70
|
// Graceful shutdown
|
|
37
71
|
const shutdown = async () => {
|
|
38
72
|
console.log('\n[pimote] Shutting down...');
|
|
73
|
+
await voiceBoot.shutdown();
|
|
39
74
|
await sessionManager.dispose();
|
|
40
75
|
await server.close();
|
|
41
76
|
process.exit(0);
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
108
|
-
|
|
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 {
|
|
169
|
+
return {
|
|
170
|
+
role,
|
|
171
|
+
content,
|
|
172
|
+
...(msg.id ? { entryId: msg.id } : {}),
|
|
173
|
+
...(aborted ? { aborted: true } : {}),
|
|
174
|
+
};
|
|
143
175
|
}
|
|
@@ -2,10 +2,18 @@ export class PushNotificationService {
|
|
|
2
2
|
sender;
|
|
3
3
|
store;
|
|
4
4
|
subscriptions = [];
|
|
5
|
+
suppressionPredicate;
|
|
5
6
|
constructor(sender, store) {
|
|
6
7
|
this.sender = sender;
|
|
7
8
|
this.store = store;
|
|
8
9
|
}
|
|
10
|
+
/** Install a predicate that suppresses notifications for a given session.
|
|
11
|
+
* Used to silence pushes while a voice call owns the session — pushes
|
|
12
|
+
* resume automatically once the predicate stops returning true (call
|
|
13
|
+
* hangs up). Pass `undefined` to clear. */
|
|
14
|
+
setSuppressionPredicate(predicate) {
|
|
15
|
+
this.suppressionPredicate = predicate;
|
|
16
|
+
}
|
|
9
17
|
/** Load subscriptions from store on startup */
|
|
10
18
|
async initialize() {
|
|
11
19
|
this.subscriptions = await this.store.load();
|
|
@@ -35,6 +43,9 @@ export class PushNotificationService {
|
|
|
35
43
|
}
|
|
36
44
|
/** Send push notification to all subscriptions */
|
|
37
45
|
async notify(payload) {
|
|
46
|
+
if (this.suppressionPredicate?.(payload.sessionId)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
38
49
|
const expiredEndpoints = [];
|
|
39
50
|
const payloadStr = JSON.stringify(payload);
|
|
40
51
|
for (const sub of this.subscriptions) {
|
package/server/dist/server.js
CHANGED
|
@@ -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) {
|
|
@@ -53,7 +55,7 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
|
|
|
53
55
|
eventBuffer,
|
|
54
56
|
status: session.isStreaming ? 'working' : 'idle',
|
|
55
57
|
needsAttention: false,
|
|
56
|
-
|
|
58
|
+
idleSince: session.isStreaming ? null : Date.now(),
|
|
57
59
|
unsubscribe: () => { },
|
|
58
60
|
pendingUiResponses: new Map(),
|
|
59
61
|
extensionsBound: false,
|
|
@@ -66,14 +68,23 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
|
|
|
66
68
|
const unsubscribe = session.subscribe((event) => {
|
|
67
69
|
if (event.type === 'agent_start' && state.status !== 'working') {
|
|
68
70
|
state.status = 'working';
|
|
71
|
+
state.idleSince = null;
|
|
69
72
|
callbacks.onStatusChange?.(sessionId, folderPath);
|
|
70
73
|
}
|
|
71
74
|
else if (event.type === 'agent_end' && state.status !== 'idle') {
|
|
72
75
|
state.status = 'idle';
|
|
76
|
+
state.idleSince = Date.now();
|
|
73
77
|
state.needsAttention = true;
|
|
74
78
|
if (slotRef.slot)
|
|
75
79
|
callbacks.onAgentEnd?.(sessionId, slotRef.slot);
|
|
76
80
|
callbacks.onStatusChange?.(sessionId, folderPath);
|
|
81
|
+
// If the run ended via abort and there are queued steering /
|
|
82
|
+
// follow-up messages, drain them — pi-agent-core's runLoop skips
|
|
83
|
+
// its trailing queue poll on the abort exit path, so without this
|
|
84
|
+
// queued messages would sit until the next prompt() call. Universal
|
|
85
|
+
// across pimote (not voice-specific) so typed-mode users also
|
|
86
|
+
// benefit. See `auto-drain-on-abort.ts` for rationale.
|
|
87
|
+
void autoDrainOnAbort(session, event.messages[event.messages.length - 1]);
|
|
77
88
|
}
|
|
78
89
|
eventBuffer.onEvent(event, sessionId, (e) => callbacks.sendEvent(e), () => session.messages[session.messages.length - 1]);
|
|
79
90
|
});
|
|
@@ -132,18 +143,43 @@ export class PimoteSessionManager {
|
|
|
132
143
|
onStatusChange;
|
|
133
144
|
onSessionClosed;
|
|
134
145
|
onGitBranchChange;
|
|
146
|
+
/** Fired synchronously before a session's state is torn down (e.g. idle
|
|
147
|
+
* reap, explicit close). Consumers use this to drop external bookkeeping
|
|
148
|
+
* (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
|
|
149
|
+
onBeforeSessionClose;
|
|
135
150
|
constructor(config, pushNotificationService) {
|
|
136
151
|
this.config = config;
|
|
137
152
|
this.pushNotificationService = pushNotificationService;
|
|
138
153
|
this.authStorage = AuthStorage.create();
|
|
139
154
|
this.modelRegistry = ModelRegistry.create(this.authStorage);
|
|
140
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Build the voice extension factory for this server's config, if possible.
|
|
158
|
+
* Returns undefined (and logs a warning) when neither voice-specific model
|
|
159
|
+
* refs nor fallback defaultProvider/defaultModel are configured — existing
|
|
160
|
+
* non-voice deployments continue to work unchanged.
|
|
161
|
+
*/
|
|
162
|
+
buildVoiceExtensionFactory() {
|
|
163
|
+
const interpreter = this.config.defaultInterpreterModel ??
|
|
164
|
+
(this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
|
|
165
|
+
const worker = this.config.defaultWorkerModel ??
|
|
166
|
+
(this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
|
|
167
|
+
if (!interpreter || !worker) {
|
|
168
|
+
console.warn('[pimote] voice extension disabled: no defaultInterpreterModel/defaultWorkerModel or fallback defaultProvider/defaultModel in config');
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
return createVoiceExtension({
|
|
172
|
+
defaultInterpreterModel: interpreter,
|
|
173
|
+
defaultWorkerModel: worker,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
141
176
|
async openSession(folderPath, sessionFilePath) {
|
|
142
177
|
const eventBusRef = { current: null };
|
|
143
178
|
const sharedAuthStorage = this.authStorage;
|
|
144
179
|
const sharedModelRegistry = this.modelRegistry;
|
|
145
180
|
const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
|
|
146
181
|
const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
|
|
182
|
+
const voiceExtensionFactory = this.buildVoiceExtensionFactory();
|
|
147
183
|
const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
|
148
184
|
const eventBus = createEventBus();
|
|
149
185
|
eventBusRef.current = eventBus;
|
|
@@ -152,7 +188,10 @@ export class PimoteSessionManager {
|
|
|
152
188
|
agentDir,
|
|
153
189
|
authStorage: sharedAuthStorage,
|
|
154
190
|
modelRegistry: sharedModelRegistry,
|
|
155
|
-
resourceLoaderOptions: {
|
|
191
|
+
resourceLoaderOptions: {
|
|
192
|
+
eventBus,
|
|
193
|
+
...(voiceExtensionFactory ? { extensionFactories: [voiceExtensionFactory] } : {}),
|
|
194
|
+
},
|
|
156
195
|
});
|
|
157
196
|
return {
|
|
158
197
|
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
|
|
@@ -187,6 +226,12 @@ export class PimoteSessionManager {
|
|
|
187
226
|
session.setThinkingLevel(this.config.defaultThinkingLevel);
|
|
188
227
|
console.log(`[pimote] Set default thinking level: ${this.config.defaultThinkingLevel}`);
|
|
189
228
|
}
|
|
229
|
+
// Pimote convention: drain the entire steering / follow-up queue into a
|
|
230
|
+
// single consolidated run rather than one message per run. Matches the
|
|
231
|
+
// UX expectation that queued messages are delivered together when the
|
|
232
|
+
// agent next processes, not metered out across multiple turns.
|
|
233
|
+
session.setSteeringMode('all');
|
|
234
|
+
session.setFollowUpMode('all');
|
|
190
235
|
// Create the slot object. Use a slotRef so createSessionState callbacks can reference the slot.
|
|
191
236
|
const slotRef = { slot: null };
|
|
192
237
|
const sessionState = createSessionState(session, eventBusRef.current, this.config, {
|
|
@@ -264,6 +309,14 @@ export class PimoteSessionManager {
|
|
|
264
309
|
const slot = this.sessions.get(sessionId);
|
|
265
310
|
if (!slot)
|
|
266
311
|
return;
|
|
312
|
+
if (this.onBeforeSessionClose) {
|
|
313
|
+
try {
|
|
314
|
+
await this.onBeforeSessionClose(sessionId, slot.folderPath);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
console.warn('[pimote] onBeforeSessionClose threw:', err);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
267
320
|
teardownSessionState(slot.sessionState);
|
|
268
321
|
slot.eventBusRef.current?.clear();
|
|
269
322
|
const folderPath = slot.folderPath;
|
|
@@ -281,9 +334,15 @@ export class PimoteSessionManager {
|
|
|
281
334
|
this.lastKnownGitBranchBySession.set(newId, lastKnown);
|
|
282
335
|
}
|
|
283
336
|
/** Rebuild a slot's SessionState after session replacement.
|
|
284
|
-
* Tears down the old state and creates a new one from the current runtime.session.
|
|
337
|
+
* Tears down the old state and creates a new one from the current runtime.session.
|
|
338
|
+
* Also refreshes slot.folderPath from the new session's header cwd, since fork-from
|
|
339
|
+
* (e.g. the worktree extension) can rebind the slot to a session whose cwd differs
|
|
340
|
+
* from the original. */
|
|
285
341
|
rebuildSessionState(slot) {
|
|
286
342
|
teardownSessionState(slot.sessionState);
|
|
343
|
+
const newCwd = slot.runtime.session.sessionManager.getCwd();
|
|
344
|
+
if (newCwd)
|
|
345
|
+
slot.folderPath = newCwd;
|
|
287
346
|
const slotRef = { slot: slot };
|
|
288
347
|
slot.sessionState = createSessionState(slot.runtime.session, slot.eventBusRef.current, this.config, {
|
|
289
348
|
onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
|
|
@@ -291,6 +350,11 @@ export class PimoteSessionManager {
|
|
|
291
350
|
sendEvent: (e) => sendSlotEvent(slot, e),
|
|
292
351
|
}, slotRef, slot.folderPath);
|
|
293
352
|
}
|
|
353
|
+
/** Alias for getSession that returns null (not undefined) for consumers
|
|
354
|
+
* that expect nullable pointers (e.g. VoiceSessionBusResolver). */
|
|
355
|
+
getSlot(sessionId) {
|
|
356
|
+
return this.sessions.get(sessionId) ?? null;
|
|
357
|
+
}
|
|
294
358
|
getSession(sessionId) {
|
|
295
359
|
return this.sessions.get(sessionId);
|
|
296
360
|
}
|
|
@@ -306,7 +370,11 @@ export class PimoteSessionManager {
|
|
|
306
370
|
}
|
|
307
371
|
const clientId = slot.connection?.connectedClientId ?? null;
|
|
308
372
|
const hasConnectedClient = clientId !== null && (isClientConnected?.(clientId) ?? false);
|
|
309
|
-
|
|
373
|
+
// Only idle (non-streaming) sessions are eligible for reaping. `idleSince` is set on
|
|
374
|
+
// `agent_end` and cleared on `agent_start`, so a working session can never be reaped
|
|
375
|
+
// here, regardless of how long it's been since a client was connected.
|
|
376
|
+
const idleSince = slot.sessionState.idleSince;
|
|
377
|
+
if (!hasConnectedClient && idleSince !== null && Date.now() - idleSince > idleTimeout) {
|
|
310
378
|
this.closeSession(sessionId).catch(() => {
|
|
311
379
|
// Best-effort cleanup — swallow errors during idle reaping
|
|
312
380
|
});
|
|
@@ -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
|
+
}
|