pi-tau-web-server 1.0.8
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 +303 -0
- package/bin/auth.js +96 -0
- package/bin/config.js +99 -0
- package/bin/model-utils.js +134 -0
- package/bin/server-main.js +1189 -0
- package/bin/sessions.js +492 -0
- package/bin/tau.js +8 -0
- package/bin/tree.js +248 -0
- package/bin/types.js +2 -0
- package/package.json +74 -0
- package/public/app-main.js +2025 -0
- package/public/app-types.js +1 -0
- package/public/app.js +1 -0
- package/public/command-palette.js +42 -0
- package/public/dialogs.js +199 -0
- package/public/file-browser.js +196 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/tau-192.png +0 -0
- package/public/icons/tau-512.png +0 -0
- package/public/icons/tau-logo.png +0 -0
- package/public/icons/tau-logo.svg +13 -0
- package/public/icons/tau-maskable-512.png +0 -0
- package/public/icons/tau-new.png +0 -0
- package/public/index.html +264 -0
- package/public/launcher-panel.js +54 -0
- package/public/launcher.js +84 -0
- package/public/manifest.json +28 -0
- package/public/markdown.js +336 -0
- package/public/message-renderer.js +268 -0
- package/public/model-picker.js +478 -0
- package/public/session-sidebar.js +460 -0
- package/public/session-stats-card.js +123 -0
- package/public/state.js +81 -0
- package/public/style.css +3864 -0
- package/public/sw.js +66 -0
- package/public/themes.js +72 -0
- package/public/tool-card.js +317 -0
- package/public/tree-view.js +474 -0
- package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/public/vendor/katex/katex.min.css +1 -0
- package/public/vendor/katex/katex.min.js +1 -0
- package/public/voice-input.js +74 -0
- package/public/websocket-client.js +156 -0
- package/scripts/copy-katex.mjs +32 -0
- package/src/pi-extension/tau-tree.ts +71 -0
- package/src/public/app-main.ts +2190 -0
- package/src/public/app-types.ts +96 -0
- package/src/public/app.ts +1 -0
- package/src/public/command-palette.ts +53 -0
- package/src/public/dialogs.ts +251 -0
- package/src/public/file-browser.ts +224 -0
- package/src/public/launcher-panel.ts +68 -0
- package/src/public/launcher.ts +101 -0
- package/src/public/legacy-dom.d.ts +29 -0
- package/src/public/markdown.ts +372 -0
- package/src/public/message-renderer.ts +311 -0
- package/src/public/model-picker.ts +500 -0
- package/src/public/session-sidebar.ts +522 -0
- package/src/public/session-stats-card.ts +176 -0
- package/src/public/state.ts +96 -0
- package/src/public/sw.ts +79 -0
- package/src/public/themes.ts +73 -0
- package/src/public/tool-card.ts +375 -0
- package/src/public/tree-view.ts +527 -0
- package/src/public/voice-input.ts +98 -0
- package/src/public/websocket-client.ts +165 -0
- package/src/server/auth.ts +88 -0
- package/src/server/config.ts +88 -0
- package/src/server/model-utils.ts +122 -0
- package/src/server/server-main.ts +1004 -0
- package/src/server/sessions.ts +481 -0
- package/src/server/tau.ts +9 -0
- package/src/server/tree.ts +288 -0
- package/src/server/types.ts +68 -0
- package/tsconfig.json +3 -0
- package/tsconfig.public.json +15 -0
- package/tsconfig.server.json +16 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const { WebSocket } = require('ws');
|
|
5
|
+
|
|
6
|
+
import type { ChildProcess } from 'node:child_process';
|
|
7
|
+
import type { JsonRecord, LiveClient, ModelIdentity, PendingCommand, RpcCommand, RpcResponse } from './types.js';
|
|
8
|
+
import { expandHome } from './config.js';
|
|
9
|
+
import { modelLabel, normalizeModel, parseModelSpecToModel } from './model-utils.js';
|
|
10
|
+
import { NAVIGATE_COMMAND } from './tree.js';
|
|
11
|
+
|
|
12
|
+
type SpawnFn = (cmd: string, args: string[], opts: JsonRecord) => ChildProcess;
|
|
13
|
+
type PiMessageContent = string | Array<{ type: string; text?: string }>;
|
|
14
|
+
type PiMessage = { role?: string; content?: PiMessageContent; usage?: JsonRecord; model?: string };
|
|
15
|
+
type PiRpcPayload = {
|
|
16
|
+
command?: string;
|
|
17
|
+
id?: string;
|
|
18
|
+
provider?: string;
|
|
19
|
+
model?: unknown;
|
|
20
|
+
thinkingLevel?: string;
|
|
21
|
+
level?: string;
|
|
22
|
+
sessionFile?: string;
|
|
23
|
+
sessionName?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
contextUsage?: JsonRecord;
|
|
26
|
+
tokens?: JsonRecord;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
};
|
|
29
|
+
type PiRpcMessage = PiRpcPayload & {
|
|
30
|
+
type?: string;
|
|
31
|
+
success?: boolean;
|
|
32
|
+
data?: PiRpcPayload;
|
|
33
|
+
result?: PiRpcPayload;
|
|
34
|
+
message?: PiMessage;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function makeId() {
|
|
38
|
+
return `tau_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// tau's bundled pi extension (registers the /tau-tree-navigate command used
|
|
42
|
+
// by navigate_tree — see src/server/tree.ts). The compiled server runs from
|
|
43
|
+
// bin/, while the extension ships as source under src/pi-extension/ because
|
|
44
|
+
// pi loads extension .ts files directly.
|
|
45
|
+
const TAU_TREE_EXTENSION = path.resolve(__dirname, '..', 'src', 'pi-extension', 'tau-tree.ts');
|
|
46
|
+
|
|
47
|
+
export function isGenericSessionName(name: unknown) {
|
|
48
|
+
const normalized = String(name || '').trim().toLowerCase();
|
|
49
|
+
return normalized === 'chat' || normalized === 'new chat' || normalized === 'untitled' || normalized === 'untitled chat' || normalized === 'session';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class PiRpcSession {
|
|
53
|
+
manager: LiveSessionManager;
|
|
54
|
+
id: string;
|
|
55
|
+
cwd: string;
|
|
56
|
+
modelSpec: string;
|
|
57
|
+
child: ChildProcess | null;
|
|
58
|
+
pid: number | null;
|
|
59
|
+
createdAt: string;
|
|
60
|
+
lastActiveAt: string;
|
|
61
|
+
isStreaming: boolean;
|
|
62
|
+
entries: JsonRecord[];
|
|
63
|
+
model: ModelIdentity | null;
|
|
64
|
+
thinkingLevel: string;
|
|
65
|
+
sessionFile: string | null;
|
|
66
|
+
sessionName: string | null;
|
|
67
|
+
contextUsage: JsonRecord | null;
|
|
68
|
+
pending: Map<string, PendingCommand>;
|
|
69
|
+
stdoutBuffer: string;
|
|
70
|
+
terminating: boolean;
|
|
71
|
+
exitCode: number | null;
|
|
72
|
+
titleSet: boolean;
|
|
73
|
+
userMessages: string[];
|
|
74
|
+
/** Last extension_error event from the child; navigate_tree reads this to explain a failed leaf move. */
|
|
75
|
+
lastExtensionError: string | null;
|
|
76
|
+
/** Whether navigateTree has confirmed this child loaded tau's tree extension (reset per spawn). */
|
|
77
|
+
navigateCommandChecked: boolean;
|
|
78
|
+
|
|
79
|
+
constructor(manager: LiveSessionManager, opts: { id?: string; cwd: string; modelSpec?: string; sessionFile?: string | null; entries?: JsonRecord[]; sessionName?: string | null }) {
|
|
80
|
+
this.manager = manager;
|
|
81
|
+
this.id = opts.id || makeId();
|
|
82
|
+
this.cwd = opts.cwd;
|
|
83
|
+
this.modelSpec = opts.modelSpec || '';
|
|
84
|
+
this.child = null;
|
|
85
|
+
this.pid = null;
|
|
86
|
+
this.createdAt = new Date().toISOString();
|
|
87
|
+
this.lastActiveAt = this.createdAt;
|
|
88
|
+
this.isStreaming = false;
|
|
89
|
+
this.entries = [];
|
|
90
|
+
const parsed = parseModelSpecToModel(this.modelSpec);
|
|
91
|
+
this.model = parsed.model;
|
|
92
|
+
this.thinkingLevel = parsed.level || 'off';
|
|
93
|
+
this.sessionFile = opts.sessionFile || null;
|
|
94
|
+
this.sessionName = opts.sessionName || null;
|
|
95
|
+
if (opts.entries && opts.entries.length) this.entries = opts.entries;
|
|
96
|
+
this.contextUsage = null;
|
|
97
|
+
this.pending = new Map();
|
|
98
|
+
this.stdoutBuffer = '';
|
|
99
|
+
this.terminating = false;
|
|
100
|
+
this.exitCode = null;
|
|
101
|
+
this.titleSet = false;
|
|
102
|
+
this.userMessages = [];
|
|
103
|
+
this.lastExtensionError = null;
|
|
104
|
+
this.navigateCommandChecked = false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
metadata() {
|
|
108
|
+
return {
|
|
109
|
+
id: this.id,
|
|
110
|
+
pid: this.pid,
|
|
111
|
+
cwd: this.cwd,
|
|
112
|
+
modelSpec: this.modelSpec,
|
|
113
|
+
model: this.model,
|
|
114
|
+
modelLabel: modelLabel(this.model, this.modelSpec),
|
|
115
|
+
thinkingLevel: this.thinkingLevel,
|
|
116
|
+
sessionFile: this.sessionFile,
|
|
117
|
+
sessionName: this.sessionName,
|
|
118
|
+
isStreaming: this.isStreaming,
|
|
119
|
+
createdAt: this.createdAt,
|
|
120
|
+
lastActiveAt: this.lastActiveAt,
|
|
121
|
+
contextUsage: this.contextUsage,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
snapshot() {
|
|
126
|
+
return {
|
|
127
|
+
session: this.metadata(),
|
|
128
|
+
entries: this.entries,
|
|
129
|
+
model: this.model,
|
|
130
|
+
thinkingLevel: this.thinkingLevel,
|
|
131
|
+
isStreaming: this.isStreaming,
|
|
132
|
+
sessionFile: this.sessionFile,
|
|
133
|
+
sessionName: this.sessionName,
|
|
134
|
+
contextUsage: this.contextUsage,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async start() {
|
|
139
|
+
if (!fs.existsSync(this.cwd) || !fs.statSync(this.cwd).isDirectory()) {
|
|
140
|
+
throw new Error(`Directory not found: ${this.cwd}`);
|
|
141
|
+
}
|
|
142
|
+
const args = ['--mode', 'rpc', '--extension', TAU_TREE_EXTENSION];
|
|
143
|
+
if (this.sessionFile) args.push('--session', this.sessionFile);
|
|
144
|
+
if (this.modelSpec) args.push('--model', this.modelSpec);
|
|
145
|
+
this.navigateCommandChecked = false;
|
|
146
|
+
const spawnFn: SpawnFn = _spawnPiForTest || spawn;
|
|
147
|
+
const child = spawnFn('pi', args, {
|
|
148
|
+
cwd: this.cwd,
|
|
149
|
+
env: { ...process.env, TAU_DISABLED: '1' },
|
|
150
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
151
|
+
});
|
|
152
|
+
this.child = child;
|
|
153
|
+
this.pid = child.pid || null;
|
|
154
|
+
|
|
155
|
+
child.stdout!.setEncoding('utf8');
|
|
156
|
+
child.stdout!.on('data', (chunk: string) => this.handleStdout(chunk));
|
|
157
|
+
child.stderr!.setEncoding('utf8');
|
|
158
|
+
child.stderr!.on('data', (chunk: string) => {
|
|
159
|
+
for (const line of chunk.split(/\r?\n/).filter(Boolean)) console.error(`[Pi ${this.id}] ${line}`);
|
|
160
|
+
});
|
|
161
|
+
child.on('error', (err: Error) => this.handleExit(null, null, err));
|
|
162
|
+
child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => this.handleExit(code, signal));
|
|
163
|
+
|
|
164
|
+
await new Promise<void>((resolve, reject) => {
|
|
165
|
+
let settled = false;
|
|
166
|
+
const cleanup = () => {
|
|
167
|
+
child.off('error', onError);
|
|
168
|
+
child.off('exit', onExit);
|
|
169
|
+
};
|
|
170
|
+
const onError = (err: Error) => {
|
|
171
|
+
if (settled) return;
|
|
172
|
+
settled = true;
|
|
173
|
+
cleanup();
|
|
174
|
+
reject(err);
|
|
175
|
+
};
|
|
176
|
+
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
177
|
+
if (settled) return;
|
|
178
|
+
settled = true;
|
|
179
|
+
cleanup();
|
|
180
|
+
reject(new Error(`Pi RPC process exited during startup (${signal || code})`));
|
|
181
|
+
};
|
|
182
|
+
child.once('error', onError);
|
|
183
|
+
child.once('exit', onExit);
|
|
184
|
+
setTimeout(() => {
|
|
185
|
+
if (settled) return;
|
|
186
|
+
settled = true;
|
|
187
|
+
cleanup();
|
|
188
|
+
resolve();
|
|
189
|
+
}, 100);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Give Pi a moment to enter RPC mode, then ask for stats. Do not fail session
|
|
193
|
+
// creation if this convenience command is unavailable.
|
|
194
|
+
setTimeout(() => {
|
|
195
|
+
this.send({ type: 'get_session_stats' }, { timeoutMs: 5000 }).catch(() => {});
|
|
196
|
+
}, 250);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
send(command: RpcCommand, opts: { timeoutMs?: number } = {}) {
|
|
200
|
+
const child = this.child;
|
|
201
|
+
if (!child || !child.stdin!.writable || this.terminating) {
|
|
202
|
+
return Promise.reject(new Error('Pi RPC session is not running'));
|
|
203
|
+
}
|
|
204
|
+
const id = command.id || `cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
205
|
+
const outbound = { ...command, id };
|
|
206
|
+
delete outbound.sessionId;
|
|
207
|
+
const timeoutMs = opts.timeoutMs ?? 60000;
|
|
208
|
+
return new Promise<RpcResponse>((resolve, reject) => {
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
this.pending.delete(id);
|
|
211
|
+
reject(new Error(`RPC command timed out: ${outbound.type}`));
|
|
212
|
+
}, timeoutMs);
|
|
213
|
+
this.pending.set(id, { resolve, reject, timer, command: outbound.type });
|
|
214
|
+
try {
|
|
215
|
+
child.stdin!.write(JSON.stringify(outbound) + '\n', (err: Error | null | undefined) => {
|
|
216
|
+
if (err) {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
this.pending.delete(id);
|
|
219
|
+
reject(err);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
} catch (e) {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
this.pending.delete(id);
|
|
225
|
+
reject(e);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
handleStdout(chunk: string) {
|
|
231
|
+
this.stdoutBuffer += chunk;
|
|
232
|
+
const lines = this.stdoutBuffer.split(/\r?\n/);
|
|
233
|
+
this.stdoutBuffer = lines.pop() || '';
|
|
234
|
+
for (const line of lines) this.handleLine(line);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
handleLine(line: string) {
|
|
238
|
+
const trimmed = line.trim();
|
|
239
|
+
if (!trimmed) return;
|
|
240
|
+
let msg;
|
|
241
|
+
try { msg = JSON.parse(trimmed); } catch { console.log(`[Pi ${this.id}] ${trimmed}`); return; }
|
|
242
|
+
if (msg.type === 'response') {
|
|
243
|
+
this.handleResponse(msg);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.handleEvent(msg);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
handleResponse(resp: PiRpcMessage) {
|
|
250
|
+
const id = resp.id;
|
|
251
|
+
if (id && this.pending.has(id)) {
|
|
252
|
+
const pending = this.pending.get(id);
|
|
253
|
+
if (pending) {
|
|
254
|
+
clearTimeout(pending.timer);
|
|
255
|
+
this.pending.delete(id);
|
|
256
|
+
pending.resolve(resp);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
this.updateStateFromResponse(resp);
|
|
260
|
+
this.manager.broadcast({ type: 'event', sessionId: this.id, event: resp });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
updateStateFromResponse(resp: PiRpcMessage) {
|
|
264
|
+
const data: PiRpcPayload = resp.data || resp.result || resp;
|
|
265
|
+
const command = resp.command || data.command;
|
|
266
|
+
if (data.sessionFile) this.sessionFile = data.sessionFile;
|
|
267
|
+
if (data.sessionName) this.setSessionName(data.sessionName);
|
|
268
|
+
if (data.contextUsage) this.contextUsage = data.contextUsage;
|
|
269
|
+
if (data.model) this.model = normalizeModel(data.model);
|
|
270
|
+
if (data.thinkingLevel) this.thinkingLevel = data.thinkingLevel;
|
|
271
|
+
if (data.level) this.thinkingLevel = data.level;
|
|
272
|
+
if (data.tokens) this.contextUsage = { ...(this.contextUsage || {}), tokens: data.tokens };
|
|
273
|
+
if (command === 'set_model' || command === 'cycle_model') {
|
|
274
|
+
if (data.model) this.model = normalizeModel(data.model);
|
|
275
|
+
else if (data.provider && data.id) this.model = normalizeModel(data);
|
|
276
|
+
}
|
|
277
|
+
this.touch(true);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
handleEvent(event: PiRpcMessage) {
|
|
281
|
+
this.touch(false);
|
|
282
|
+
const type = event.type;
|
|
283
|
+
if (type === 'agent_start' || type === 'turn_start') this.isStreaming = true;
|
|
284
|
+
if (type === 'agent_end' || type === 'turn_end') this.isStreaming = false;
|
|
285
|
+
// Extension command handlers report their errors as extension_error
|
|
286
|
+
// events while the triggering prompt still acks with success. Keep the
|
|
287
|
+
// last one so navigate_tree can explain WHY a leaf verification failed
|
|
288
|
+
// (stdout is ordered, so the event lands before the prompt's response).
|
|
289
|
+
// Only tau's own command qualifies — pi tags command errors with
|
|
290
|
+
// extensionPath `command:<name>` — so a failure is never blamed on an
|
|
291
|
+
// unrelated extension that happened to error in the same window.
|
|
292
|
+
if (type === 'extension_error' && event.error && event.extensionPath === `command:${NAVIGATE_COMMAND}`) {
|
|
293
|
+
this.lastExtensionError = String(event.error);
|
|
294
|
+
}
|
|
295
|
+
if (event.contextUsage) this.contextUsage = event.contextUsage;
|
|
296
|
+
if (event.sessionFile) this.sessionFile = event.sessionFile;
|
|
297
|
+
if (type === 'session_name' && event.name) {
|
|
298
|
+
if (!this.setSessionName(event.name)) return;
|
|
299
|
+
event.name = this.sessionName || event.name;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if ((type === 'message_start' || type === 'message_end') && event.message) {
|
|
303
|
+
this.trackMessage(event.message, type);
|
|
304
|
+
}
|
|
305
|
+
// NOTE: the assistant `message_end` event carries `event.message.model` as
|
|
306
|
+
// a bare id describing WHICH model produced that message, not a selection
|
|
307
|
+
// change. Overwriting `this.model` with it downgraded the canonical
|
|
308
|
+
// {provider,id} object to a bare string and propagated to all clients via
|
|
309
|
+
// broadcastUpdated. Do NOT touch model identity here — only record usage.
|
|
310
|
+
if (type === 'message_end' && event.message?.role === 'assistant') {
|
|
311
|
+
if (event.message.usage) this.contextUsage = { ...(this.contextUsage || {}), usage: event.message.usage };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
this.manager.broadcast({ type: 'event', sessionId: this.id, event });
|
|
315
|
+
this.manager.broadcastUpdated(this.id);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
trackMessage(message: PiMessage, eventType: string) {
|
|
319
|
+
if (message.role === 'user' && eventType === 'message_start') {
|
|
320
|
+
const text = this.messageText(message);
|
|
321
|
+
if (text) this.userMessages.push(text.slice(0, 300));
|
|
322
|
+
this.entries.push({ type: 'message', message });
|
|
323
|
+
this.maybeTitle();
|
|
324
|
+
} else if (message.role !== 'user' && eventType === 'message_end') {
|
|
325
|
+
this.entries.push({ type: 'message', message });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
messageText(message: PiMessage) {
|
|
330
|
+
if (typeof message.content === 'string') return message.content;
|
|
331
|
+
if (Array.isArray(message.content)) return message.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n');
|
|
332
|
+
return '';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
setSessionName(name: unknown) {
|
|
336
|
+
const trimmed = String(name || '').trim();
|
|
337
|
+
if (!trimmed || isGenericSessionName(trimmed)) return false;
|
|
338
|
+
this.sessionName = trimmed;
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
maybeTitle() {
|
|
343
|
+
if (this.titleSet || (this.sessionName && !isGenericSessionName(this.sessionName)) || this.userMessages.length < 1) return;
|
|
344
|
+
const msg = this.userMessages.find((m) => m.trim().length > 8) || this.userMessages[0];
|
|
345
|
+
if (!msg) return;
|
|
346
|
+
let title = msg.replace(/^(ok |okay |so |actually |hey |please |can you |could you |i want(ed)? to |i wanna |let'?s )/i, '').replace(/\n.*/s, '').trim();
|
|
347
|
+
const sentenceEnd = title.search(/[.!?]\s/);
|
|
348
|
+
if (sentenceEnd > 10 && sentenceEnd < 80) title = title.slice(0, sentenceEnd);
|
|
349
|
+
if (title.length > 60) title = title.slice(0, 57).replace(/\s+\S*$/, '') + '…';
|
|
350
|
+
title = title.charAt(0).toUpperCase() + title.slice(1);
|
|
351
|
+
this.sessionName = title || null;
|
|
352
|
+
this.titleSet = true;
|
|
353
|
+
this.manager.broadcast({ type: 'event', sessionId: this.id, event: { type: 'session_name', name: this.sessionName } });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
touch(broadcast: boolean) {
|
|
357
|
+
this.lastActiveAt = new Date().toISOString();
|
|
358
|
+
if (broadcast) this.manager.broadcastUpdated(this.id);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async terminate(reason = 'closed') {
|
|
362
|
+
if (this.terminating) return;
|
|
363
|
+
this.terminating = true;
|
|
364
|
+
for (const [, pending] of this.pending) {
|
|
365
|
+
clearTimeout(pending.timer);
|
|
366
|
+
pending.reject(new Error(`Session terminated: ${reason}`));
|
|
367
|
+
}
|
|
368
|
+
this.pending.clear();
|
|
369
|
+
if (!this.child || this.child.exitCode !== null) return;
|
|
370
|
+
try { this.child.kill('SIGTERM'); } catch {}
|
|
371
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
372
|
+
if (this.child && this.child.exitCode === null && this.child.signalCode === null) {
|
|
373
|
+
try { this.child.kill('SIGKILL'); } catch {}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
handleExit(code: number | null, signal: string | null, err?: { message?: string }) {
|
|
378
|
+
if (this.exitCode !== null) return;
|
|
379
|
+
this.exitCode = code;
|
|
380
|
+
for (const [, pending] of this.pending) {
|
|
381
|
+
clearTimeout(pending.timer);
|
|
382
|
+
pending.reject(err || new Error(`Pi process exited (${signal || code})`));
|
|
383
|
+
}
|
|
384
|
+
this.pending.clear();
|
|
385
|
+
this.manager.removeExited(this.id, err?.message || `process_exit:${signal || code}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export class LiveSessionManager {
|
|
390
|
+
sessions: Map<string, PiRpcSession>;
|
|
391
|
+
clients: Set<LiveClient>;
|
|
392
|
+
pendingResumes: Map<string, Promise<PiRpcSession>>;
|
|
393
|
+
terminatingResumes: Map<string, Promise<void>>;
|
|
394
|
+
|
|
395
|
+
constructor() {
|
|
396
|
+
this.sessions = new Map();
|
|
397
|
+
this.clients = new Set();
|
|
398
|
+
this.pendingResumes = new Map();
|
|
399
|
+
this.terminatingResumes = new Map();
|
|
400
|
+
}
|
|
401
|
+
addClient(ws: LiveClient) { this.clients.add(ws); }
|
|
402
|
+
removeClient(ws: LiveClient) { this.clients.delete(ws); }
|
|
403
|
+
broadcast(data: unknown) {
|
|
404
|
+
const payload = JSON.stringify(data);
|
|
405
|
+
for (const client of this.clients) {
|
|
406
|
+
if (client.readyState === WebSocket.OPEN) client.send(payload);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
broadcastUpdated(id: string) {
|
|
410
|
+
const s = this.sessions.get(id);
|
|
411
|
+
if (s) this.broadcast({ type: 'live_session_updated', session: s.metadata() });
|
|
412
|
+
}
|
|
413
|
+
list() { return Array.from(this.sessions.values()).map((s) => s.metadata()); }
|
|
414
|
+
get(id: string) { return this.sessions.get(id); }
|
|
415
|
+
findBySessionFile(sessionFile: string) {
|
|
416
|
+
const resolved = path.resolve(sessionFile);
|
|
417
|
+
return Array.from(this.sessions.values()).find((s) => s.sessionFile && path.resolve(s.sessionFile) === resolved);
|
|
418
|
+
}
|
|
419
|
+
hasPendingResume(sessionFile: string) { return this.pendingResumes.has(path.resolve(sessionFile)); }
|
|
420
|
+
hasTerminatingResume(sessionFile: string) { return this.terminatingResumes.has(path.resolve(sessionFile)); }
|
|
421
|
+
async create({ cwd, model }: { cwd?: string; model?: string }) {
|
|
422
|
+
const resolved = path.resolve(expandHome(cwd || process.cwd()));
|
|
423
|
+
const session = new PiRpcSession(this, { cwd: resolved, modelSpec: (model || '').trim() });
|
|
424
|
+
await session.start();
|
|
425
|
+
this.sessions.set(session.id, session);
|
|
426
|
+
this.broadcast({ type: 'live_session_created', session: session.metadata() });
|
|
427
|
+
return session;
|
|
428
|
+
}
|
|
429
|
+
async resume({ sessionFile, cwd, model, entries, sessionName }: { sessionFile: string; cwd: string; model?: string; entries?: JsonRecord[]; sessionName?: string | null }) {
|
|
430
|
+
const resolved = path.resolve(sessionFile);
|
|
431
|
+
const existing = this.findBySessionFile(resolved);
|
|
432
|
+
if (existing) return existing;
|
|
433
|
+
const pending = this.pendingResumes.get(resolved);
|
|
434
|
+
if (pending) return pending;
|
|
435
|
+
const resumePromise = (async () => {
|
|
436
|
+
try {
|
|
437
|
+
const terminating = this.terminatingResumes.get(resolved);
|
|
438
|
+
if (terminating) await terminating.catch(() => {});
|
|
439
|
+
const afterTerminationExisting = this.findBySessionFile(resolved);
|
|
440
|
+
if (afterTerminationExisting) return afterTerminationExisting;
|
|
441
|
+
const session = new PiRpcSession(this, { cwd, modelSpec: (model || '').trim(), sessionFile: resolved, entries, sessionName });
|
|
442
|
+
await session.start();
|
|
443
|
+
this.sessions.set(session.id, session);
|
|
444
|
+
this.broadcast({ type: 'live_session_created', session: session.metadata() });
|
|
445
|
+
return session;
|
|
446
|
+
} finally {
|
|
447
|
+
this.pendingResumes.delete(resolved);
|
|
448
|
+
}
|
|
449
|
+
})();
|
|
450
|
+
this.pendingResumes.set(resolved, resumePromise);
|
|
451
|
+
return resumePromise;
|
|
452
|
+
}
|
|
453
|
+
async delete(id: string, reason = 'closed_by_user') {
|
|
454
|
+
const session = this.sessions.get(id);
|
|
455
|
+
if (!session) return false;
|
|
456
|
+
const resolvedFile = session.sessionFile ? path.resolve(session.sessionFile) : null;
|
|
457
|
+
const termination = session.terminate(reason).then(() => undefined, () => undefined).finally(() => {
|
|
458
|
+
if (resolvedFile && this.terminatingResumes.get(resolvedFile) === termination) this.terminatingResumes.delete(resolvedFile);
|
|
459
|
+
});
|
|
460
|
+
if (resolvedFile) this.terminatingResumes.set(resolvedFile, termination);
|
|
461
|
+
this.sessions.delete(id);
|
|
462
|
+
this.broadcast({ type: 'live_session_closed', sessionId: id, reason });
|
|
463
|
+
await termination;
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
removeExited(id: string, reason: string) {
|
|
467
|
+
if (!this.sessions.has(id)) return;
|
|
468
|
+
this.sessions.delete(id);
|
|
469
|
+
this.broadcast({ type: 'live_session_closed', sessionId: id, reason });
|
|
470
|
+
}
|
|
471
|
+
async shutdown() {
|
|
472
|
+
const sessions = Array.from(this.sessions.values());
|
|
473
|
+
this.sessions.clear();
|
|
474
|
+
await Promise.allSettled(sessions.map((s) => s.terminate('server_shutdown')));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
export const liveManager = new LiveSessionManager();
|
|
480
|
+
let _spawnPiForTest: SpawnFn | null = null;
|
|
481
|
+
export function _setSpawnPiForTest(fn: SpawnFn | null | undefined) { _spawnPiForTest = fn || null; }
|