handmux 0.24.0 → 0.25.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 +29 -6
- package/README.zh-CN.md +29 -6
- package/bin/handmux.js +15 -3
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +200 -35
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +45 -3
- package/dist/hooks/handmux-write.cjs +175 -10
- package/dist/package.json +10 -9
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +22 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +52 -7
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +27 -9
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +17 -3
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +11 -2
- package/dist/src/claudeEvents.js +35 -9
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +49 -3
- package/dist/src/cli/codexManaged.js +6 -3
- package/dist/src/cli/hookScaffold.js +4 -1
- package/dist/src/cli/i18n/en.js +30 -4
- package/dist/src/cli/i18n/zh.js +30 -4
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/service.js +19 -9
- package/dist/src/cli/state.js +3 -0
- package/dist/src/cli/statusLine.js +19 -11
- package/dist/src/codexAppServer.js +1296 -159
- package/dist/src/codexQueueProtocol.js +41 -0
- package/dist/src/codexTranscriptParse.js +256 -145
- package/dist/src/codexUsageSnapshot.js +29 -6
- package/dist/src/git.js +13 -1
- package/dist/src/httpApi.js +35 -7
- package/dist/src/orphans.js +13 -3
- package/dist/src/paneInput.js +42 -0
- package/dist/src/privateStateStore.js +35 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/git.js +11 -0
- package/dist/src/routes/sessions.js +16 -4
- package/dist/src/routes/system.js +69 -5
- package/dist/src/routes/terminal.js +22 -16
- package/dist/src/server.js +109 -6
- package/dist/src/tmux/commands.js +29 -1
- package/dist/src/transcriptParse.js +22 -1
- package/dist/src/transcriptReader.js +100 -4
- package/dist/src/uploadTypes.js +2 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +103 -6
- package/dist/src/workspace/restore.js +51 -2
- package/dist/src/workspace/tmuxAdapter.js +10 -0
- package/package.json +6 -5
- package/dist/public/assets/index-CicdY9E9.css +0 -32
- package/dist/public/assets/index-D4VP5t8h.js +0 -337
- package/dist/src/routes/codex.js +0 -606
- package/dist/src/routes/transcript.js +0 -197
- package/dist/src/usage.js +0 -260
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { promises as fsp } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { PiBridgeClient } from './bridgeClient.js';
|
|
7
|
+
import { normalizePiCommandTool, projectPiLiveItems, } from '../../src/agents/piConversationHistory.js';
|
|
8
|
+
// Increment when a running Pi Extension must be reloaded to satisfy the Connector contract.
|
|
9
|
+
// The Server treats an absent version as the legacy v1 implementation and surfaces one actionable
|
|
10
|
+
// reload notice instead of silently continuing with stale message/inbox behavior.
|
|
11
|
+
const PI_CONNECTOR_IMPLEMENTATION_VERSION = 6;
|
|
12
|
+
const PI_THINKING_LEVELS = [
|
|
13
|
+
'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max',
|
|
14
|
+
];
|
|
15
|
+
const GLOBAL_ATTACHMENT = Symbol.for('handmux.pi-extension.attachment-id');
|
|
16
|
+
const GLOBAL_GENERATIONS = Symbol.for('handmux.pi-extension.generations');
|
|
17
|
+
const GLOBAL_HANDOFFS = Symbol.for('handmux.pi-extension.handoffs');
|
|
18
|
+
const processGlobal = globalThis;
|
|
19
|
+
const attachmentId = processGlobal[GLOBAL_ATTACHMENT] ??= crypto.randomUUID();
|
|
20
|
+
const generations = processGlobal[GLOBAL_GENERATIONS] ??= new Map();
|
|
21
|
+
const handoffs = processGlobal[GLOBAL_HANDOFFS] ??= new Map();
|
|
22
|
+
function record(value) {
|
|
23
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
24
|
+
? value : null;
|
|
25
|
+
}
|
|
26
|
+
function modelReference(model) {
|
|
27
|
+
return `${model.provider}/${model.id}`;
|
|
28
|
+
}
|
|
29
|
+
function supportedThinkingLevels(model) {
|
|
30
|
+
if (!model.reasoning)
|
|
31
|
+
return ['off'];
|
|
32
|
+
return PI_THINKING_LEVELS.filter((level) => {
|
|
33
|
+
const mapped = model.thinkingLevelMap?.[level];
|
|
34
|
+
if (mapped === null)
|
|
35
|
+
return false;
|
|
36
|
+
return level !== 'xhigh' && level !== 'max' || mapped !== undefined;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function usableModels(context) {
|
|
40
|
+
if (!context.modelRegistry || !context.scopedModels) {
|
|
41
|
+
throw new Error('Pi model registry is unavailable');
|
|
42
|
+
}
|
|
43
|
+
const candidates = context.scopedModels.length
|
|
44
|
+
? context.scopedModels.map((entry) => entry.model)
|
|
45
|
+
: context.modelRegistry.getAvailable();
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
return candidates.filter((model) => {
|
|
48
|
+
const key = modelReference(model);
|
|
49
|
+
if (seen.has(key))
|
|
50
|
+
return false;
|
|
51
|
+
seen.add(key);
|
|
52
|
+
return true;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function modelControlSnapshot(pi, context) {
|
|
56
|
+
if (!pi.getThinkingLevel)
|
|
57
|
+
throw new Error('Pi thinking control is unavailable');
|
|
58
|
+
return {
|
|
59
|
+
models: usableModels(context).map((model) => ({
|
|
60
|
+
id: modelReference(model),
|
|
61
|
+
label: model.name || model.id,
|
|
62
|
+
efforts: supportedThinkingLevels(model).map((id) => ({ id, label: id })),
|
|
63
|
+
})),
|
|
64
|
+
selected: {
|
|
65
|
+
model: context.model ? modelReference(context.model) : null,
|
|
66
|
+
effort: pi.getThinkingLevel(),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function boundedText(value, max = 1024 * 1024) {
|
|
71
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
72
|
+
}
|
|
73
|
+
function leaf(context) {
|
|
74
|
+
const value = context.sessionManager.getLeafId();
|
|
75
|
+
return typeof value === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(value) ? value : 'root';
|
|
76
|
+
}
|
|
77
|
+
function eventId(...parts) {
|
|
78
|
+
return `pi:${crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex')}`;
|
|
79
|
+
}
|
|
80
|
+
function inboxText(value) {
|
|
81
|
+
if (typeof value !== 'string')
|
|
82
|
+
return undefined;
|
|
83
|
+
const normalized = value.trim();
|
|
84
|
+
if (!normalized)
|
|
85
|
+
return undefined;
|
|
86
|
+
return Array.from(normalized).slice(0, 4096).join('');
|
|
87
|
+
}
|
|
88
|
+
function rawMessageText(message) {
|
|
89
|
+
if (typeof message.content === 'string')
|
|
90
|
+
return message.content || undefined;
|
|
91
|
+
if (!Array.isArray(message.content))
|
|
92
|
+
return undefined;
|
|
93
|
+
const text = message.content.flatMap((candidate) => {
|
|
94
|
+
const block = record(candidate);
|
|
95
|
+
return block?.type === 'text' && typeof block.text === 'string' ? [block.text] : [];
|
|
96
|
+
}).join('\n\n');
|
|
97
|
+
return text || undefined;
|
|
98
|
+
}
|
|
99
|
+
function messageText(message) {
|
|
100
|
+
return inboxText(rawMessageText(message));
|
|
101
|
+
}
|
|
102
|
+
function lastUserText(context) {
|
|
103
|
+
const branch = context.sessionManager.getBranch();
|
|
104
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
105
|
+
const entry = record(branch[index]);
|
|
106
|
+
const message = record(entry?.message);
|
|
107
|
+
if (message?.role === 'user')
|
|
108
|
+
return messageText(message);
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
function setInbox(active, current) {
|
|
113
|
+
active.inboxCurrent = current;
|
|
114
|
+
active.client.setSnapshot('inbox', {
|
|
115
|
+
availability: 'ready',
|
|
116
|
+
...(current === undefined ? {} : { current }),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function pendingUserItem(active, request) {
|
|
120
|
+
return {
|
|
121
|
+
id: provisionalUserId(request.id),
|
|
122
|
+
sessionId: active.sessionId,
|
|
123
|
+
status: 'complete',
|
|
124
|
+
kind: 'message',
|
|
125
|
+
role: 'user',
|
|
126
|
+
content: [{ type: 'text', text: request.text }],
|
|
127
|
+
correlationId: request.id,
|
|
128
|
+
...(request.nativeTimestamp === undefined ? {} : { sourceCreatedAt: request.nativeTimestamp }),
|
|
129
|
+
extensions: {
|
|
130
|
+
'pi.live': true,
|
|
131
|
+
'pi.pendingClientRequestId': request.id,
|
|
132
|
+
...(request.nativeText === undefined || request.nativeText === request.text
|
|
133
|
+
? {} : { 'pi.pendingNativeText': request.nativeText }),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function pendingUserItems(active) {
|
|
138
|
+
// Steer text is a live-only optimistic occurrence. Once native Pi starts it, canonical history
|
|
139
|
+
// becomes the sole durable source; never copy guide text into the Connector snapshot.
|
|
140
|
+
return active.requests.filter((request) => request.nativeStarted && request.origin !== 'steer')
|
|
141
|
+
.map((request) => pendingUserItem(active, request));
|
|
142
|
+
}
|
|
143
|
+
function activeToolSnapshots(active) {
|
|
144
|
+
return [...active.tools.values()].map((tool) => ({
|
|
145
|
+
provisionalId: tool.provisionalId,
|
|
146
|
+
draft: {
|
|
147
|
+
kind: 'tool_call',
|
|
148
|
+
callId: `pi:${tool.id}`,
|
|
149
|
+
name: tool.name,
|
|
150
|
+
input: tool.input,
|
|
151
|
+
},
|
|
152
|
+
...(tool.settlement === undefined ? {} : { settlement: tool.settlement }),
|
|
153
|
+
...(tool.committedLeafId === undefined ? {} : { committedLeafId: tool.committedLeafId }),
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
function unsettledTools(active) {
|
|
157
|
+
return [...active.tools.values()].flatMap((tool) => (tool.settlement && !tool.committedLeafId ? [{ tool, settlement: tool.settlement }] : []));
|
|
158
|
+
}
|
|
159
|
+
function commitSettledTools(active, candidates, expectedLeaf) {
|
|
160
|
+
for (const { tool, settlement } of candidates) {
|
|
161
|
+
if (active.tools.get(tool.id) !== tool || tool.settlement !== settlement)
|
|
162
|
+
continue;
|
|
163
|
+
tool.committedLeafId = expectedLeaf;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function removeCommittedTools(active) {
|
|
167
|
+
let changed = false;
|
|
168
|
+
for (const [id, tool] of active.tools) {
|
|
169
|
+
if (!tool.committedLeafId)
|
|
170
|
+
continue;
|
|
171
|
+
active.tools.delete(id);
|
|
172
|
+
changed = true;
|
|
173
|
+
}
|
|
174
|
+
return changed;
|
|
175
|
+
}
|
|
176
|
+
function provisionalUserId(clientRequestId) {
|
|
177
|
+
return `pi-user:${crypto.createHash('sha256').update(clientRequestId).digest('hex')}`;
|
|
178
|
+
}
|
|
179
|
+
function removeRequest(active, request) {
|
|
180
|
+
active.requests = active.requests.filter((candidate) => candidate !== request);
|
|
181
|
+
if (active.currentRequest === request)
|
|
182
|
+
active.currentRequest = undefined;
|
|
183
|
+
}
|
|
184
|
+
function removeRequests(active, requests) {
|
|
185
|
+
active.requests = active.requests.filter((request) => !requests.has(request));
|
|
186
|
+
if (active.currentRequest && requests.has(active.currentRequest))
|
|
187
|
+
active.currentRequest = undefined;
|
|
188
|
+
}
|
|
189
|
+
function cancelNativeDispatch(active) {
|
|
190
|
+
active.nativeDispatch?.cancel();
|
|
191
|
+
}
|
|
192
|
+
function enqueueDrain(paneId, client) {
|
|
193
|
+
const previous = handoffs.get(paneId) ?? Promise.resolve();
|
|
194
|
+
const pending = previous.then(async () => {
|
|
195
|
+
await client.waitForDurableDrain();
|
|
196
|
+
client.closeAndRemoveIfDrained();
|
|
197
|
+
});
|
|
198
|
+
handoffs.set(paneId, pending);
|
|
199
|
+
void pending.finally(() => {
|
|
200
|
+
if (handoffs.get(paneId) === pending)
|
|
201
|
+
handoffs.delete(paneId);
|
|
202
|
+
}).catch(() => { });
|
|
203
|
+
return pending;
|
|
204
|
+
}
|
|
205
|
+
function waitForNativeConfirmation(active, request, context) {
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
let settled = false;
|
|
208
|
+
let timer;
|
|
209
|
+
const finish = (confirmed) => {
|
|
210
|
+
if (settled)
|
|
211
|
+
return;
|
|
212
|
+
settled = true;
|
|
213
|
+
if (timer)
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
context.signal.removeEventListener('abort', onAbort);
|
|
216
|
+
if (active.nativeDispatch === dispatch)
|
|
217
|
+
active.nativeDispatch = undefined;
|
|
218
|
+
resolve(confirmed);
|
|
219
|
+
};
|
|
220
|
+
const onAbort = () => finish(false);
|
|
221
|
+
const dispatch = {
|
|
222
|
+
request,
|
|
223
|
+
confirm: () => finish(true),
|
|
224
|
+
cancel: () => finish(false),
|
|
225
|
+
};
|
|
226
|
+
active.nativeDispatch = dispatch;
|
|
227
|
+
const remaining = context.deadlineAt - Date.now();
|
|
228
|
+
if (context.signal.aborted || remaining <= 0) {
|
|
229
|
+
finish(false);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
context.signal.addEventListener('abort', onAbort, { once: true });
|
|
233
|
+
timer = setTimeout(() => finish(false), remaining);
|
|
234
|
+
timer.unref?.();
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async function waitForPendingQueue(active, context, stop) {
|
|
238
|
+
while (!context.signal.aborted && !stop.aborted && Date.now() < context.deadlineAt) {
|
|
239
|
+
try {
|
|
240
|
+
if (active.context.hasPendingMessages())
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
catch { /* transient native context failure; retry until the Bridge deadline */ }
|
|
244
|
+
const remaining = context.deadlineAt - Date.now();
|
|
245
|
+
if (remaining <= 0)
|
|
246
|
+
break;
|
|
247
|
+
await new Promise((resolve) => {
|
|
248
|
+
let timer;
|
|
249
|
+
const finish = () => {
|
|
250
|
+
if (timer)
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
context.signal.removeEventListener('abort', finish);
|
|
253
|
+
stop.removeEventListener('abort', finish);
|
|
254
|
+
resolve();
|
|
255
|
+
};
|
|
256
|
+
context.signal.addEventListener('abort', finish, { once: true });
|
|
257
|
+
stop.addEventListener('abort', finish, { once: true });
|
|
258
|
+
timer = setTimeout(finish, Math.min(25, remaining));
|
|
259
|
+
timer.unref?.();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
function runtimeDirectory() {
|
|
265
|
+
return process.env.HANDMUX_AGENT_RUNTIME_DIR
|
|
266
|
+
? path.resolve(process.env.HANDMUX_AGENT_RUNTIME_DIR)
|
|
267
|
+
: path.join(os.homedir(), '.handmux', 'agent-runtime');
|
|
268
|
+
}
|
|
269
|
+
function safePane(paneId) {
|
|
270
|
+
return paneId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128);
|
|
271
|
+
}
|
|
272
|
+
function textDelta(event) {
|
|
273
|
+
const update = record(event.assistantMessageEvent) ?? record(event.update) ?? event;
|
|
274
|
+
if (update.type === 'thinking_delta' || update.type === 'reasoning_delta')
|
|
275
|
+
return null;
|
|
276
|
+
if (update.type !== undefined && update.type !== 'text_delta' && update.type !== 'text')
|
|
277
|
+
return null;
|
|
278
|
+
const value = update.delta ?? update.text;
|
|
279
|
+
return typeof value === 'string' && value ? value : null;
|
|
280
|
+
}
|
|
281
|
+
function toolIdentity(event) {
|
|
282
|
+
const id = event.toolCallId ?? event.toolCallID ?? event.id;
|
|
283
|
+
const name = event.toolName ?? event.name;
|
|
284
|
+
if (!boundedText(id, 256) || !boundedText(name, 256))
|
|
285
|
+
return null;
|
|
286
|
+
return { id, ...normalizePiCommandTool(name, event.args ?? event.arguments ?? {}) };
|
|
287
|
+
}
|
|
288
|
+
function updateSnapshots(active) {
|
|
289
|
+
const sessionFile = active.context.sessionManager.getSessionFile();
|
|
290
|
+
const fallbackItems = !sessionFile || !existsSync(sessionFile)
|
|
291
|
+
? projectPiLiveItems(active.context.sessionManager.getBranch(), active.sessionId)
|
|
292
|
+
: [];
|
|
293
|
+
const pendingItems = [...fallbackItems, ...pendingUserItems(active)];
|
|
294
|
+
active.client.setSnapshot('conversation', {
|
|
295
|
+
implementationVersion: PI_CONNECTOR_IMPLEMENTATION_VERSION,
|
|
296
|
+
sessionId: active.sessionId,
|
|
297
|
+
leafId: active.leafId,
|
|
298
|
+
viewId: active.viewId,
|
|
299
|
+
...(sessionFile ? { sessionFile } : {}),
|
|
300
|
+
// An explicit empty list is a tombstone for previously published pending items. Omitting it
|
|
301
|
+
// means "unchanged" to the Adapter so reconnect snapshots can be applied incrementally.
|
|
302
|
+
pendingItems,
|
|
303
|
+
// Running and just-settled tools stay in this Pi-private snapshot through their JSONL handoff.
|
|
304
|
+
// The Adapter restores them through the public provisional lifecycle; a committed marker keeps
|
|
305
|
+
// fresh observers from recreating a tool that is already present in native history.
|
|
306
|
+
activeTools: activeToolSnapshots(active),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function liveEntryId(provisionalId) {
|
|
310
|
+
return `live_${crypto.createHash('sha256').update(provisionalId).digest('hex')}`;
|
|
311
|
+
}
|
|
312
|
+
function settledAssistantItem(active, provisionalId, message) {
|
|
313
|
+
const item = projectPiLiveItems([{
|
|
314
|
+
type: 'message', id: liveEntryId(provisionalId), parentId: null,
|
|
315
|
+
message,
|
|
316
|
+
}], active.sessionId).find((item) => item.kind === 'message' && item.role === 'assistant');
|
|
317
|
+
return item ? {
|
|
318
|
+
...item,
|
|
319
|
+
extensions: { ...item.extensions, 'pi.live': true },
|
|
320
|
+
} : undefined;
|
|
321
|
+
}
|
|
322
|
+
async function waitUntilLeafIsDurable(active, beforePublish) {
|
|
323
|
+
const file = active.context.sessionManager.getSessionFile();
|
|
324
|
+
const expectedLeaf = leaf(active.context);
|
|
325
|
+
const expectedView = active.viewId;
|
|
326
|
+
active.leafId = expectedLeaf;
|
|
327
|
+
if (!file || expectedLeaf === 'root') {
|
|
328
|
+
active.client.publishEphemeral('conversation', { type: 'stream.gap' });
|
|
329
|
+
updateSnapshots(active);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
333
|
+
try {
|
|
334
|
+
const lines = (await fsp.readFile(file, 'utf8')).trim().split('\n');
|
|
335
|
+
const readable = lines.some((line) => {
|
|
336
|
+
try {
|
|
337
|
+
return record(JSON.parse(line))?.id === expectedLeaf;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
if (readable) {
|
|
344
|
+
if (active.viewId !== expectedView || leaf(active.context) !== expectedLeaf) {
|
|
345
|
+
updateSnapshots(active);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// The callback may mutate recoverable ownership, so it must run only after both identity
|
|
349
|
+
// guards pass. It receives the captured leaf instead of reading mutable session state.
|
|
350
|
+
beforePublish?.(expectedLeaf);
|
|
351
|
+
active.client.publishEphemeral('conversation', {
|
|
352
|
+
type: 'history.changed', leafId: expectedLeaf, viewId: expectedView,
|
|
353
|
+
});
|
|
354
|
+
updateSnapshots(active);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch { /* JSONL may not have been flushed yet */ }
|
|
359
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(200, 20 * (attempt + 1))));
|
|
360
|
+
}
|
|
361
|
+
if (active.viewId !== expectedView || leaf(active.context) !== expectedLeaf)
|
|
362
|
+
return;
|
|
363
|
+
active.client.publishEphemeral('conversation', { type: 'stream.gap' });
|
|
364
|
+
updateSnapshots(active);
|
|
365
|
+
}
|
|
366
|
+
export default function handmuxPiExtension(pi) {
|
|
367
|
+
let active;
|
|
368
|
+
const clients = new Set();
|
|
369
|
+
const attach = (context, replacement) => {
|
|
370
|
+
const previous = active;
|
|
371
|
+
const paneId = process.env.TMUX_PANE;
|
|
372
|
+
const sessionId = context.sessionManager.getSessionId();
|
|
373
|
+
if (!paneId || !boundedText(sessionId, 1024))
|
|
374
|
+
return;
|
|
375
|
+
const inheritedGeneration = generations.get(paneId);
|
|
376
|
+
// Pi `/reload` evaluates this module again inside the same process. The new instance has no local
|
|
377
|
+
// `active`, while the process-global generation still identifies the loaded predecessor. Replace
|
|
378
|
+
// that generation even when Pi reports the next session_start as ordinary startup.
|
|
379
|
+
const replacing = replacement || (previous !== undefined && previous.sessionId !== sessionId)
|
|
380
|
+
|| (previous === undefined && inheritedGeneration !== undefined);
|
|
381
|
+
const root = runtimeDirectory();
|
|
382
|
+
const generationId = replacing
|
|
383
|
+
? crypto.randomUUID()
|
|
384
|
+
: inheritedGeneration ?? attachmentId;
|
|
385
|
+
generations.set(paneId, generationId);
|
|
386
|
+
const client = new PiBridgeClient({
|
|
387
|
+
socketPath: path.join(root, 'bridge', 'agent.sock'),
|
|
388
|
+
credentialFile: path.join(root, 'bridge-credential.json'),
|
|
389
|
+
stateFile: path.join(root, 'connectors', `pi-${safePane(paneId)}-${generationId}.json`),
|
|
390
|
+
candidate: {
|
|
391
|
+
paneId, attachmentId, sessionId,
|
|
392
|
+
implementationVersion: PI_CONNECTOR_IMPLEMENTATION_VERSION,
|
|
393
|
+
process: { pid: process.pid },
|
|
394
|
+
},
|
|
395
|
+
...(replacing ? { generation: { id: generationId, replace: true } } : {}),
|
|
396
|
+
});
|
|
397
|
+
clients.add(client);
|
|
398
|
+
active = {
|
|
399
|
+
client, context, sessionId, leafId: leaf(context),
|
|
400
|
+
viewId: `pi-view:${crypto.randomUUID()}`, turn: 0,
|
|
401
|
+
assistantId: undefined, tools: new Map(), currentRequest: undefined,
|
|
402
|
+
requests: [], lastAssistant: undefined, inboxCurrent: undefined, nativeDispatch: undefined,
|
|
403
|
+
};
|
|
404
|
+
const session = active;
|
|
405
|
+
setInbox(session);
|
|
406
|
+
updateSnapshots(session);
|
|
407
|
+
client.handle('conversation', 'activity', () => {
|
|
408
|
+
const idle = session.context.isIdle();
|
|
409
|
+
return {
|
|
410
|
+
activity: idle ? 'idle' : 'working',
|
|
411
|
+
activeTurn: idle
|
|
412
|
+
? { state: 'none' }
|
|
413
|
+
: { state: 'active', nativeTurnId: `pi-turn:${session.turn}` },
|
|
414
|
+
...(idle ? { completionToken: `pi-completed:${session.turn}` } : {}),
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
client.handle('conversation', 'send', async (payload, requestContext) => {
|
|
418
|
+
const request = record(payload);
|
|
419
|
+
if (!request || !boundedText(request.text, 256 * 1024)
|
|
420
|
+
|| Buffer.byteLength(request.text) > 256 * 1024
|
|
421
|
+
|| typeof request.clientRequestId !== 'string'
|
|
422
|
+
|| request.clientRequestId.length === 0 || request.clientRequestId.length > 256) {
|
|
423
|
+
return { status: 'rejected', reason: 'invalid_request' };
|
|
424
|
+
}
|
|
425
|
+
const delivery = request.delivery;
|
|
426
|
+
if (delivery !== 'prompt' && delivery !== 'steer' && delivery !== 'followUp') {
|
|
427
|
+
return { status: 'rejected', reason: 'unsupported_delivery' };
|
|
428
|
+
}
|
|
429
|
+
const plan = record(request.plan);
|
|
430
|
+
if (request.origin !== undefined && request.origin !== 'steer') {
|
|
431
|
+
return { status: 'rejected', reason: 'invalid_request' };
|
|
432
|
+
}
|
|
433
|
+
if (plan?.kind === 'steer-active-turn'
|
|
434
|
+
&& (delivery !== 'steer' || plan.nativeTurnId !== `pi-turn:${session.turn}`
|
|
435
|
+
|| session.context.isIdle())) {
|
|
436
|
+
return { status: 'rejected', reason: 'agent_busy' };
|
|
437
|
+
}
|
|
438
|
+
if (plan?.kind === 'start-turn-fallback' && (delivery !== 'prompt' || !session.context.isIdle())) {
|
|
439
|
+
return { status: 'rejected', reason: 'agent_busy' };
|
|
440
|
+
}
|
|
441
|
+
if (delivery === 'prompt' && !session.context.isIdle()) {
|
|
442
|
+
return { status: 'rejected', reason: 'agent_busy' };
|
|
443
|
+
}
|
|
444
|
+
const wasIdle = session.context.isIdle();
|
|
445
|
+
if (session.nativeDispatch) {
|
|
446
|
+
return { status: 'rejected', reason: 'agent_busy' };
|
|
447
|
+
}
|
|
448
|
+
if (requestContext.signal.aborted || requestContext.deadlineAt <= Date.now()) {
|
|
449
|
+
return { status: 'unknown', reason: 'native_delivery_unconfirmed' };
|
|
450
|
+
}
|
|
451
|
+
let hadPendingMessages = false;
|
|
452
|
+
let hadTrackedPendingMessages = false;
|
|
453
|
+
if (!wasIdle) {
|
|
454
|
+
try {
|
|
455
|
+
hadPendingMessages = session.context.hasPendingMessages();
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return { status: 'unknown', reason: 'native_delivery_unconfirmed' };
|
|
459
|
+
}
|
|
460
|
+
hadTrackedPendingMessages = hadPendingMessages
|
|
461
|
+
&& session.requests.some((candidate) => !candidate.nativeStarted);
|
|
462
|
+
}
|
|
463
|
+
const userRequest = {
|
|
464
|
+
id: request.clientRequestId,
|
|
465
|
+
text: request.text,
|
|
466
|
+
delivery,
|
|
467
|
+
origin: request.origin === 'steer' || delivery === 'steer'
|
|
468
|
+
|| plan?.kind === 'steer-active-turn' || plan?.kind === 'start-turn-fallback'
|
|
469
|
+
? 'steer' : 'ordinary',
|
|
470
|
+
};
|
|
471
|
+
const previousInbox = session.inboxCurrent;
|
|
472
|
+
session.requests.push(userRequest);
|
|
473
|
+
updateSnapshots(session);
|
|
474
|
+
const inboxRequest = wasIdle ? userRequest : session.currentRequest;
|
|
475
|
+
const workingMessage = inboxRequest?.origin === 'steer'
|
|
476
|
+
? undefined : inboxText(inboxRequest?.text);
|
|
477
|
+
if (inboxRequest) {
|
|
478
|
+
setInbox(session, {
|
|
479
|
+
state: 'working', correlationId: inboxRequest.id,
|
|
480
|
+
...(workingMessage === undefined ? {} : { message: workingMessage }),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const nativeConfirmation = wasIdle || !hadPendingMessages
|
|
484
|
+
? waitForNativeConfirmation(session, userRequest, requestContext)
|
|
485
|
+
: undefined;
|
|
486
|
+
const cancelUnconfirmed = () => {
|
|
487
|
+
removeRequest(session, userRequest);
|
|
488
|
+
updateSnapshots(session);
|
|
489
|
+
setInbox(session, previousInbox);
|
|
490
|
+
return { status: 'unknown', reason: 'native_delivery_unconfirmed' };
|
|
491
|
+
};
|
|
492
|
+
try {
|
|
493
|
+
if (delivery === 'prompt')
|
|
494
|
+
pi.sendUserMessage(request.text);
|
|
495
|
+
else
|
|
496
|
+
pi.sendUserMessage(request.text, { deliverAs: delivery });
|
|
497
|
+
if (wasIdle && nativeConfirmation) {
|
|
498
|
+
return await nativeConfirmation ? { status: 'accepted' } : cancelUnconfirmed();
|
|
499
|
+
}
|
|
500
|
+
if (!hadPendingMessages && nativeConfirmation) {
|
|
501
|
+
const stopQueueWait = new AbortController();
|
|
502
|
+
const proof = await Promise.race([
|
|
503
|
+
nativeConfirmation.then((confirmed) => confirmed ? 'started' : 'unknown'),
|
|
504
|
+
waitForPendingQueue(session, requestContext, stopQueueWait.signal)
|
|
505
|
+
.then((queued) => queued ? 'queued' : 'unknown'),
|
|
506
|
+
]);
|
|
507
|
+
stopQueueWait.abort();
|
|
508
|
+
if (proof === 'started')
|
|
509
|
+
return { status: 'accepted' };
|
|
510
|
+
if (proof === 'queued') {
|
|
511
|
+
cancelNativeDispatch(session);
|
|
512
|
+
return { status: 'queued' };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// Pi exposes only a boolean native queue. Once this Connector already owns an item in that
|
|
516
|
+
// queue, a later synchronous send that does not throw is the only per-item acknowledgement
|
|
517
|
+
// available; the pre-existing tracked request prevents an unrelated external queue from
|
|
518
|
+
// being mistaken for proof of this delivery.
|
|
519
|
+
if (hadTrackedPendingMessages)
|
|
520
|
+
return { status: 'queued' };
|
|
521
|
+
return cancelUnconfirmed();
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
cancelNativeDispatch(session);
|
|
525
|
+
removeRequest(session, userRequest);
|
|
526
|
+
updateSnapshots(session);
|
|
527
|
+
setInbox(session, previousInbox);
|
|
528
|
+
return { status: 'rejected', reason: 'provider_rejected' };
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
client.handle('conversation', 'interrupt', () => {
|
|
532
|
+
try {
|
|
533
|
+
session.context.abort();
|
|
534
|
+
return { status: 'accepted' };
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return { status: 'rejected', reason: 'temporarily_unavailable' };
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
client.handle('session-control', 'read', async (payload) => {
|
|
541
|
+
const request = record(payload);
|
|
542
|
+
if (request?.refresh === true) {
|
|
543
|
+
if (!session.context.modelRegistry)
|
|
544
|
+
throw new Error('Pi model registry is unavailable');
|
|
545
|
+
await session.context.modelRegistry.refresh({ force: true });
|
|
546
|
+
}
|
|
547
|
+
return modelControlSnapshot(pi, session.context);
|
|
548
|
+
});
|
|
549
|
+
client.handle('session-control', 'update', async (payload) => {
|
|
550
|
+
const patch = record(payload);
|
|
551
|
+
if (!patch || !Object.keys(patch).length
|
|
552
|
+
|| Object.keys(patch).some((key) => key !== 'model' && key !== 'effort')) {
|
|
553
|
+
throw new Error('Invalid Session Control update');
|
|
554
|
+
}
|
|
555
|
+
if (patch.model !== undefined) {
|
|
556
|
+
if (typeof patch.model !== 'string')
|
|
557
|
+
throw new Error('Invalid model');
|
|
558
|
+
const model = usableModels(session.context)
|
|
559
|
+
.find((candidate) => modelReference(candidate) === patch.model);
|
|
560
|
+
if (!model)
|
|
561
|
+
throw new Error('Model is unavailable');
|
|
562
|
+
if (!pi.setModel)
|
|
563
|
+
throw new Error('Pi model control is unavailable');
|
|
564
|
+
if (!await pi.setModel(model))
|
|
565
|
+
throw new Error('Model credentials are unavailable');
|
|
566
|
+
}
|
|
567
|
+
if (patch.effort !== undefined) {
|
|
568
|
+
if (typeof patch.effort !== 'string'
|
|
569
|
+
|| !PI_THINKING_LEVELS.includes(patch.effort)) {
|
|
570
|
+
throw new Error('Invalid thinking level');
|
|
571
|
+
}
|
|
572
|
+
if (!pi.setThinkingLevel)
|
|
573
|
+
throw new Error('Pi thinking control is unavailable');
|
|
574
|
+
pi.setThinkingLevel(patch.effort);
|
|
575
|
+
}
|
|
576
|
+
return modelControlSnapshot(pi, session.context);
|
|
577
|
+
});
|
|
578
|
+
let predecessor = handoffs.get(paneId);
|
|
579
|
+
if (previous) {
|
|
580
|
+
previous.nativeDispatch?.cancel();
|
|
581
|
+
predecessor = enqueueDrain(paneId, previous.client);
|
|
582
|
+
void predecessor.finally(() => clients.delete(previous.client)).catch(() => { });
|
|
583
|
+
}
|
|
584
|
+
if (!predecessor) {
|
|
585
|
+
client.start();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// A replacement must not revoke the old run before its locally durable terminal facts have entered
|
|
589
|
+
// the Server-owned spool. The global handoff survives Pi reloading the Extension instance between
|
|
590
|
+
// session_shutdown and the replacement session_start.
|
|
591
|
+
void predecessor.then(() => client.start()).catch(() => { });
|
|
592
|
+
};
|
|
593
|
+
pi.on('session_start', (event, context) => {
|
|
594
|
+
attach(context, event.reason === 'new' || event.reason === 'resume'
|
|
595
|
+
|| event.reason === 'fork' || event.reason === 'reload');
|
|
596
|
+
});
|
|
597
|
+
pi.on('session_shutdown', () => {
|
|
598
|
+
const outgoing = active;
|
|
599
|
+
active = undefined;
|
|
600
|
+
if (!outgoing)
|
|
601
|
+
return;
|
|
602
|
+
outgoing.nativeDispatch?.cancel();
|
|
603
|
+
const paneId = process.env.TMUX_PANE;
|
|
604
|
+
if (!paneId) {
|
|
605
|
+
outgoing.client.close();
|
|
606
|
+
clients.delete(outgoing.client);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const drained = enqueueDrain(paneId, outgoing.client);
|
|
610
|
+
void drained.finally(() => clients.delete(outgoing.client)).catch(() => { });
|
|
611
|
+
});
|
|
612
|
+
pi.on('before_agent_start', (event, context) => {
|
|
613
|
+
if (!active)
|
|
614
|
+
return;
|
|
615
|
+
active.context = context;
|
|
616
|
+
const dispatch = active.nativeDispatch;
|
|
617
|
+
if (!dispatch || typeof event.prompt !== 'string' || !event.prompt)
|
|
618
|
+
return;
|
|
619
|
+
dispatch.request.nativeText = event.prompt;
|
|
620
|
+
active.currentRequest = dispatch.request;
|
|
621
|
+
updateSnapshots(active);
|
|
622
|
+
dispatch.confirm();
|
|
623
|
+
});
|
|
624
|
+
pi.on('agent_start', (_event, context) => {
|
|
625
|
+
if (!active)
|
|
626
|
+
return;
|
|
627
|
+
active.context = context;
|
|
628
|
+
if (removeCommittedTools(active))
|
|
629
|
+
updateSnapshots(active);
|
|
630
|
+
active.turn += 1;
|
|
631
|
+
active.lastAssistant = undefined;
|
|
632
|
+
const existing = active.inboxCurrent?.state === 'working' ? active.inboxCurrent : undefined;
|
|
633
|
+
const currentText = active.currentRequest?.origin === 'steer'
|
|
634
|
+
? undefined : existing?.message ?? lastUserText(context);
|
|
635
|
+
setInbox(active, {
|
|
636
|
+
state: 'working',
|
|
637
|
+
...(currentText === undefined ? {} : { message: currentText }),
|
|
638
|
+
...(existing?.correlationId === undefined ? {} : { correlationId: existing.correlationId }),
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
pi.on('agent_end', (event, context) => {
|
|
642
|
+
if (!active)
|
|
643
|
+
return;
|
|
644
|
+
active.context = context;
|
|
645
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
646
|
+
const assistant = messages.map(record).filter((message) => message?.role === 'assistant').at(-1);
|
|
647
|
+
if (!assistant)
|
|
648
|
+
return;
|
|
649
|
+
active.lastAssistant = assistant;
|
|
650
|
+
});
|
|
651
|
+
pi.on('agent_settled', (_event, context) => {
|
|
652
|
+
if (!active)
|
|
653
|
+
return;
|
|
654
|
+
const session = active;
|
|
655
|
+
session.context = context;
|
|
656
|
+
const assistant = session.lastAssistant;
|
|
657
|
+
const settledCurrent = session.currentRequest;
|
|
658
|
+
const unsettledDispatch = session.nativeDispatch?.request;
|
|
659
|
+
const settledRequests = new Set(session.requests.filter((request) => request !== unsettledDispatch));
|
|
660
|
+
const settledTools = unsettledTools(session);
|
|
661
|
+
if (session.currentRequest === settledCurrent)
|
|
662
|
+
session.currentRequest = undefined;
|
|
663
|
+
if (assistant?.stopReason === 'aborted') {
|
|
664
|
+
session.client.publishEphemeral('inbox', { kind: 'clear' });
|
|
665
|
+
setInbox(session);
|
|
666
|
+
void waitUntilLeafIsDurable(session, (expectedLeaf) => {
|
|
667
|
+
removeRequests(session, settledRequests);
|
|
668
|
+
commitSettledTools(session, settledTools, expectedLeaf);
|
|
669
|
+
updateSnapshots(session);
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const outcome = assistant?.stopReason === 'error' ? 'error' : 'done';
|
|
674
|
+
const correlationId = settledCurrent?.id;
|
|
675
|
+
const id = eventId(session.sessionId, session.turn, leaf(context), 'agent_settled', outcome);
|
|
676
|
+
const completedText = outcome === 'done' && assistant ? messageText(assistant) : undefined;
|
|
677
|
+
const current = {
|
|
678
|
+
state: outcome,
|
|
679
|
+
eventId: id,
|
|
680
|
+
...(correlationId === undefined ? {} : { correlationId }),
|
|
681
|
+
...(completedText === undefined ? {} : { message: completedText }),
|
|
682
|
+
...(outcome === 'error' ? { reason: 'provider_error' } : {}),
|
|
683
|
+
...(outcome === 'error' ? { message: 'Pi generation failed' } : {}),
|
|
684
|
+
};
|
|
685
|
+
const durable = session.client.publishDurable('inbox', id, { kind: 'set', ...current });
|
|
686
|
+
session.inboxCurrent = current;
|
|
687
|
+
session.client.setSnapshot('inbox', durable
|
|
688
|
+
? { availability: 'ready', current }
|
|
689
|
+
: {
|
|
690
|
+
availability: 'degraded',
|
|
691
|
+
message: 'Terminal event could not be saved for offline delivery',
|
|
692
|
+
current,
|
|
693
|
+
});
|
|
694
|
+
void waitUntilLeafIsDurable(session, (expectedLeaf) => {
|
|
695
|
+
removeRequests(session, settledRequests);
|
|
696
|
+
commitSettledTools(session, settledTools, expectedLeaf);
|
|
697
|
+
updateSnapshots(session);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
pi.on('session_tree', (_event, context) => {
|
|
701
|
+
if (!active)
|
|
702
|
+
return;
|
|
703
|
+
active.context = context;
|
|
704
|
+
// A tree switch creates a new Conversation view. All provisional tool ownership belongs to the
|
|
705
|
+
// old view and is invalidated by its stream gap; carrying any of it into the replacement snapshot
|
|
706
|
+
// would resurrect stale running or uncommitted tools on a fresh observer.
|
|
707
|
+
active.tools.clear();
|
|
708
|
+
active.leafId = leaf(context);
|
|
709
|
+
active.viewId = `pi-view:${crypto.randomUUID()}`;
|
|
710
|
+
void waitUntilLeafIsDurable(active);
|
|
711
|
+
});
|
|
712
|
+
pi.on('message_start', (event, context) => {
|
|
713
|
+
if (!active)
|
|
714
|
+
return;
|
|
715
|
+
const message = record(event.message);
|
|
716
|
+
if (!message)
|
|
717
|
+
return;
|
|
718
|
+
active.context = context;
|
|
719
|
+
if (message.role === 'user') {
|
|
720
|
+
const rawText = rawMessageText(message);
|
|
721
|
+
const dispatchRequest = active.nativeDispatch?.request;
|
|
722
|
+
const matchedDispatch = rawText !== undefined && dispatchRequest
|
|
723
|
+
&& (dispatchRequest.nativeText ?? dispatchRequest.text) === rawText
|
|
724
|
+
? dispatchRequest : undefined;
|
|
725
|
+
const request = matchedDispatch ?? (rawText === undefined ? undefined : active.requests.find((candidate) => (candidate.nativeTimestamp === undefined && (candidate.nativeText ?? candidate.text) === rawText)));
|
|
726
|
+
const text = request?.origin === 'steer' ? undefined : inboxText(request?.text ?? rawText);
|
|
727
|
+
if (request && typeof message.timestamp === 'number' && Number.isFinite(message.timestamp)) {
|
|
728
|
+
request.nativeTimestamp = message.timestamp;
|
|
729
|
+
}
|
|
730
|
+
if (request)
|
|
731
|
+
request.nativeStarted = true;
|
|
732
|
+
active.currentRequest = request;
|
|
733
|
+
if (request && active.nativeDispatch?.request === request)
|
|
734
|
+
active.nativeDispatch.confirm();
|
|
735
|
+
if (request && !request.liveSettled) {
|
|
736
|
+
request.liveSettled = true;
|
|
737
|
+
const item = pendingUserItem(active, request);
|
|
738
|
+
const provisionalId = provisionalUserId(request.id);
|
|
739
|
+
active.client.publishEphemeral('conversation', {
|
|
740
|
+
type: 'item.opened', provisionalId,
|
|
741
|
+
draft: {
|
|
742
|
+
kind: item.kind, role: item.role, content: item.content,
|
|
743
|
+
correlationId: item.correlationId,
|
|
744
|
+
...(item.sourceCreatedAt === undefined ? {} : { sourceCreatedAt: item.sourceCreatedAt }),
|
|
745
|
+
extensions: item.extensions,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
active.client.publishEphemeral('conversation', {
|
|
749
|
+
type: 'item.settled', provisionalId,
|
|
750
|
+
durableItemId: item.id, item,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
updateSnapshots(active);
|
|
754
|
+
setInbox(active, {
|
|
755
|
+
state: 'working',
|
|
756
|
+
...(text === undefined ? {} : { message: text }),
|
|
757
|
+
...(request === undefined ? {} : { correlationId: request.id }),
|
|
758
|
+
});
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (message.role !== 'assistant')
|
|
762
|
+
return;
|
|
763
|
+
active.assistantId = `assistant:${active.turn}:${crypto.randomUUID()}`;
|
|
764
|
+
active.client.publishEphemeral('conversation', {
|
|
765
|
+
type: 'item.opened', provisionalId: active.assistantId,
|
|
766
|
+
draft: { kind: 'message', role: 'assistant', content: [{ type: 'text', text: '' }] },
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
pi.on('message_update', (event, context) => {
|
|
770
|
+
if (!active?.assistantId)
|
|
771
|
+
return;
|
|
772
|
+
active.context = context;
|
|
773
|
+
const text = textDelta(event);
|
|
774
|
+
if (!text)
|
|
775
|
+
return;
|
|
776
|
+
active.client.publishEphemeral('conversation', {
|
|
777
|
+
type: 'item.delta', provisionalId: active.assistantId,
|
|
778
|
+
delta: { op: 'text.append', target: 'message.content', text },
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
pi.on('message_end', (event, context) => {
|
|
782
|
+
if (!active?.assistantId)
|
|
783
|
+
return;
|
|
784
|
+
const message = record(event.message);
|
|
785
|
+
if (message?.role !== 'assistant')
|
|
786
|
+
return;
|
|
787
|
+
active.context = context;
|
|
788
|
+
const provisionalId = active.assistantId;
|
|
789
|
+
active.assistantId = undefined;
|
|
790
|
+
const item = settledAssistantItem(active, provisionalId, message);
|
|
791
|
+
if (item) {
|
|
792
|
+
active.client.publishEphemeral('conversation', {
|
|
793
|
+
type: 'item.settled', provisionalId, durableItemId: item.id, item,
|
|
794
|
+
});
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
// A tool-only assistant message opened an empty text draft. Its tool calls have their own live
|
|
798
|
+
// rows, so remove only that genuinely invisible placeholder.
|
|
799
|
+
active.client.publishEphemeral('conversation', {
|
|
800
|
+
type: 'item.cancelled', provisionalId, reason: 'superseded',
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
pi.on('tool_execution_start', (event, context) => {
|
|
804
|
+
if (!active)
|
|
805
|
+
return;
|
|
806
|
+
active.context = context;
|
|
807
|
+
const tool = toolIdentity(event);
|
|
808
|
+
if (!tool)
|
|
809
|
+
return;
|
|
810
|
+
const provisionalId = `tool:${crypto.randomUUID()}`;
|
|
811
|
+
active.tools.set(tool.id, { ...tool, provisionalId });
|
|
812
|
+
// Persist the recoverable owner before publishing the lossy live event. The Adapter suppresses
|
|
813
|
+
// the matching item.opened when both reach an uninterrupted subscription.
|
|
814
|
+
updateSnapshots(active);
|
|
815
|
+
active.client.publishEphemeral('conversation', {
|
|
816
|
+
type: 'item.opened', provisionalId,
|
|
817
|
+
draft: { kind: 'tool_call', callId: `pi:${tool.id}`, name: tool.name, input: tool.input },
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
pi.on('tool_execution_end', (event, context) => {
|
|
821
|
+
if (!active)
|
|
822
|
+
return;
|
|
823
|
+
active.context = context;
|
|
824
|
+
const tool = toolIdentity(event);
|
|
825
|
+
if (!tool)
|
|
826
|
+
return;
|
|
827
|
+
const running = active.tools.get(tool.id);
|
|
828
|
+
if (!running)
|
|
829
|
+
return;
|
|
830
|
+
const provisionalId = running.provisionalId;
|
|
831
|
+
const durableItemId = `pi:${liveEntryId(provisionalId)}:tool`;
|
|
832
|
+
const item = {
|
|
833
|
+
id: durableItemId,
|
|
834
|
+
sessionId: active.sessionId,
|
|
835
|
+
status: 'complete',
|
|
836
|
+
kind: 'tool_call',
|
|
837
|
+
callId: `pi:${running.id}`,
|
|
838
|
+
name: running.name,
|
|
839
|
+
input: running.input,
|
|
840
|
+
extensions: { 'pi.live': true },
|
|
841
|
+
};
|
|
842
|
+
running.settlement = { durableItemId, item };
|
|
843
|
+
// A completed snapshot makes settlement recoverable if the ephemeral event is lost. Once native
|
|
844
|
+
// history is durable it becomes a committed tombstone, then the next native turn removes it.
|
|
845
|
+
updateSnapshots(active);
|
|
846
|
+
active.client.publishEphemeral('conversation', {
|
|
847
|
+
type: 'item.settled', provisionalId,
|
|
848
|
+
durableItemId, item,
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
}
|