@rotriz/pi-web-ui 1.0.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 +62 -0
- package/extension.mjs +107 -0
- package/index.html +2622 -0
- package/package.json +35 -0
- package/server.mjs +1398 -0
package/server.mjs
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
// pi-web-ui — a DeepSeek-Harness-style Web UI for the pi coding agent.
|
|
2
|
+
// Layout replicated from deepseek-harness `dsh web`:
|
|
3
|
+
// sidebar | conversation (view tabs, disclosure rows, stats dock) | details
|
|
4
|
+
// Run: node ~/pi-web-ui/server.mjs -> http://localhost:3123
|
|
5
|
+
//
|
|
6
|
+
// PARALLEL SESSIONS: Multiple sessions can run concurrently, each in its own
|
|
7
|
+
// "tab" identified by a unique tabId. SSE streams are per-tab. The UI manages
|
|
8
|
+
// tabs client-side and routes all API calls through ?tab=<tabId> or JSON body.
|
|
9
|
+
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { readFileSync, existsSync, readdirSync, renameSync, statSync } from "node:fs";
|
|
12
|
+
import { basename, join, dirname, relative, resolve } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
const PORT = process.env.PORT || 3123;
|
|
19
|
+
const CWD = process.cwd();
|
|
20
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
// Resolve the pi-coding-agent module: env override → bare specifier → relative to pi entry
|
|
23
|
+
async function resolvePiModule() {
|
|
24
|
+
const attempts = [];
|
|
25
|
+
if (process.env.PI_WEB_PI_MODULE) attempts.push(process.env.PI_WEB_PI_MODULE);
|
|
26
|
+
attempts.push("@earendil-works/pi-coding-agent");
|
|
27
|
+
const entry = process.argv[1];
|
|
28
|
+
if (entry) {
|
|
29
|
+
const dir = dirname(entry);
|
|
30
|
+
attempts.push(join(dir, "..", "dist", "index.js"));
|
|
31
|
+
attempts.push(join(dir, "index.js"));
|
|
32
|
+
}
|
|
33
|
+
// Try global node_modules as last resort
|
|
34
|
+
const globalPrefix = process.env.NODE_PATH?.split(":")[0];
|
|
35
|
+
if (globalPrefix) attempts.push(join(globalPrefix, "@earendil-works/pi-coding-agent/dist/index.js"));
|
|
36
|
+
for (const attempt of attempts) {
|
|
37
|
+
try { return await import(attempt); } catch {}
|
|
38
|
+
}
|
|
39
|
+
throw new Error("Could not resolve @earendil-works/pi-coding-agent. Install it globally or set PI_WEB_PI_MODULE.");
|
|
40
|
+
}
|
|
41
|
+
const pi = await resolvePiModule();
|
|
42
|
+
|
|
43
|
+
const {
|
|
44
|
+
createAgentSessionRuntime,
|
|
45
|
+
createAgentSessionFromServices,
|
|
46
|
+
createAgentSessionServices,
|
|
47
|
+
DefaultResourceLoader,
|
|
48
|
+
SessionManager,
|
|
49
|
+
SettingsManager,
|
|
50
|
+
getAgentDir,
|
|
51
|
+
} = pi;
|
|
52
|
+
|
|
53
|
+
const AGENT_DIR = getAgentDir();
|
|
54
|
+
|
|
55
|
+
const createRuntime = async ({ cwd, sessionManager, sessionStartEvent }) => {
|
|
56
|
+
const services = await createAgentSessionServices({ cwd });
|
|
57
|
+
return {
|
|
58
|
+
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
|
|
59
|
+
services,
|
|
60
|
+
diagnostics: services.diagnostics,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Shared workspace state (settings, loader) — common across all tabs
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const shared = {};
|
|
69
|
+
|
|
70
|
+
async function buildShared(cwd) {
|
|
71
|
+
const settingsManager = SettingsManager.create(cwd);
|
|
72
|
+
const loader = new DefaultResourceLoader({ cwd, agentDir: AGENT_DIR, settingsManager });
|
|
73
|
+
await loader.reload();
|
|
74
|
+
Object.assign(shared, { cwd, settingsManager, loader });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await buildShared(CWD);
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Tab / parallel session management
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
// Each tab is an independent session with its own runtime, stats, and SSE clients
|
|
84
|
+
const tabs = new Map(); // tabId -> TabState
|
|
85
|
+
|
|
86
|
+
class TabState {
|
|
87
|
+
constructor(id, runtime, tabName) {
|
|
88
|
+
this.id = id;
|
|
89
|
+
this.runtime = runtime;
|
|
90
|
+
this.tabName = tabName || null; // persistent tab name (null = use session subject)
|
|
91
|
+
this.clients = new Set(); // SSE response objects
|
|
92
|
+
this.stats = { turns: 0, steps: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
93
|
+
this.createdAt = Date.now();
|
|
94
|
+
this.bindEvents();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get session() { return this.runtime.session; }
|
|
98
|
+
|
|
99
|
+
broadcast(event) {
|
|
100
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
101
|
+
for (const res of this.clients) res.write(data);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
resetStats() {
|
|
105
|
+
this.stats = { turns: 0, steps: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
cachePct() {
|
|
109
|
+
const billed = this.stats.input + this.stats.cacheRead + this.stats.cacheWrite;
|
|
110
|
+
return billed ? Math.round((this.stats.cacheRead / billed) * 100) : 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
statsSnapshot() {
|
|
114
|
+
const context = this.session.getContextUsage();
|
|
115
|
+
const contextWindow = context?.contextWindow ?? this.session.model?.contextWindow ?? null;
|
|
116
|
+
const contextTokens = context?.tokens ?? null;
|
|
117
|
+
const contextPercent = context?.percent ?? null;
|
|
118
|
+
return {
|
|
119
|
+
...this.stats,
|
|
120
|
+
cachePct: this.cachePct(),
|
|
121
|
+
contextTokens,
|
|
122
|
+
contextWindow,
|
|
123
|
+
contextPercent,
|
|
124
|
+
contextRemaining: contextTokens == null || contextWindow == null
|
|
125
|
+
? null
|
|
126
|
+
: Math.max(0, contextWindow - contextTokens),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
bindEvents() {
|
|
131
|
+
const s = this.session;
|
|
132
|
+
s.subscribe((event) => {
|
|
133
|
+
switch (event.type) {
|
|
134
|
+
case "message_update": {
|
|
135
|
+
const update = event.assistantMessageEvent;
|
|
136
|
+
if (update.type === "text_delta" && typeof update.delta === "string" && update.delta.length > 0)
|
|
137
|
+
this.broadcast({ type: "delta", delta: update.delta });
|
|
138
|
+
if (update.type === "thinking_delta" && typeof update.delta === "string" && update.delta.length > 0)
|
|
139
|
+
this.broadcast({ type: "thinking_delta", delta: update.delta });
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case "message_end": {
|
|
143
|
+
const m = event.message;
|
|
144
|
+
if (m.role === "assistant") {
|
|
145
|
+
this.stats.steps += 1;
|
|
146
|
+
const u = m.usage ?? {};
|
|
147
|
+
this.stats.input += u.input ?? 0;
|
|
148
|
+
this.stats.output += u.output ?? 0;
|
|
149
|
+
this.stats.cacheRead += u.cacheRead ?? 0;
|
|
150
|
+
this.stats.cacheWrite += u.cacheWrite ?? 0;
|
|
151
|
+
this.stats.cost += u.cost?.total ?? 0;
|
|
152
|
+
const text = renderContent(m.content);
|
|
153
|
+
const thinking = renderThinking(m.content);
|
|
154
|
+
if (text.trim() || thinking.trim())
|
|
155
|
+
this.broadcast({
|
|
156
|
+
type: "message_end", role: "assistant", text, thinking,
|
|
157
|
+
usage: { input: u.input ?? 0, output: u.output ?? 0, cost: u.cost?.total ?? 0 },
|
|
158
|
+
});
|
|
159
|
+
this.broadcast({ type: "stats", stats: this.statsSnapshot() });
|
|
160
|
+
} else if (m.role === "user") {
|
|
161
|
+
const text = renderContent(m.content);
|
|
162
|
+
if (text.trim()) this.broadcast({ type: "message_end", role: "user", text });
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "turn_end":
|
|
167
|
+
this.stats.turns += 1;
|
|
168
|
+
this.broadcast({ type: "stats", stats: this.statsSnapshot() });
|
|
169
|
+
break;
|
|
170
|
+
case "tool_execution_start":
|
|
171
|
+
this.broadcast({
|
|
172
|
+
type: "tool_start", toolCallId: event.toolCallId,
|
|
173
|
+
toolName: event.toolName, args: event.args,
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
case "tool_execution_update":
|
|
177
|
+
this.broadcast({
|
|
178
|
+
type: "tool_output", toolCallId: event.toolCallId,
|
|
179
|
+
chunk: String(event.partialResult ?? "").slice(-2000),
|
|
180
|
+
});
|
|
181
|
+
break;
|
|
182
|
+
case "tool_execution_end": {
|
|
183
|
+
let text = "";
|
|
184
|
+
const r = event.result;
|
|
185
|
+
if (typeof r === "string") text = r;
|
|
186
|
+
else if (Array.isArray(r)) text = r.map((b) => b.text ?? `[${b.type}]`).join("\n");
|
|
187
|
+
else if (r?.content) text = r.content.map((b) => b.text ?? `[${b.type}]`).join("\n");
|
|
188
|
+
else text = JSON.stringify(r ?? {});
|
|
189
|
+
this.broadcast({
|
|
190
|
+
type: "tool_end", toolCallId: event.toolCallId,
|
|
191
|
+
isError: !!event.isError, output: String(text).slice(0, 50000),
|
|
192
|
+
});
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case "agent_start": this.broadcast({ type: "status", status: "running" }); break;
|
|
196
|
+
case "agent_settled": this.broadcast({ type: "status", status: "idle" }); break;
|
|
197
|
+
case "auto_retry_start": this.broadcast({ type: "status", status: "retrying" }); break;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async replaceSession(setup) {
|
|
203
|
+
await setup();
|
|
204
|
+
this.resetStats();
|
|
205
|
+
this.bindEvents();
|
|
206
|
+
this.broadcast({
|
|
207
|
+
type: "init",
|
|
208
|
+
messages: transcriptSnapshot(this.session),
|
|
209
|
+
cwd: shared.cwd,
|
|
210
|
+
sessionFile: this.session.sessionFile,
|
|
211
|
+
sessionName: sessionSubject(this.session),
|
|
212
|
+
stats: this.statsSnapshot(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
initPayload() {
|
|
217
|
+
return {
|
|
218
|
+
type: "init",
|
|
219
|
+
tabId: this.id,
|
|
220
|
+
messages: transcriptSnapshot(this.session),
|
|
221
|
+
cwd: shared.cwd,
|
|
222
|
+
sessionFile: this.session.sessionFile,
|
|
223
|
+
sessionName: sessionSubject(this.session),
|
|
224
|
+
stats: this.statsSnapshot(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function createTab(cwd, { resumeSessionFile } = {}) {
|
|
230
|
+
cwd = cwd || shared.cwd;
|
|
231
|
+
const id = randomUUID().slice(0, 8);
|
|
232
|
+
const sessionManager = SessionManager.create(cwd);
|
|
233
|
+
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
234
|
+
cwd,
|
|
235
|
+
agentDir: AGENT_DIR,
|
|
236
|
+
sessionManager,
|
|
237
|
+
});
|
|
238
|
+
// If a session file was provided, resume it after the runtime is ready
|
|
239
|
+
if (resumeSessionFile && existsSync(resumeSessionFile)) {
|
|
240
|
+
try {
|
|
241
|
+
await runtime.switchSession(resumeSessionFile);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.warn(`[pi-web-ui] Could not resume session ${resumeSessionFile}: ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const tabName = `Tab ${tabs.size + 1}`;
|
|
247
|
+
const tab = new TabState(id, runtime, tabName);
|
|
248
|
+
tabs.set(id, tab);
|
|
249
|
+
// Broadcast to global listeners that a new tab exists
|
|
250
|
+
broadcastGlobal({ type: "tabs_changed", tabs: listTabs() });
|
|
251
|
+
return tab;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function removeTab(id) {
|
|
255
|
+
const tab = tabs.get(id);
|
|
256
|
+
if (!tab) return false;
|
|
257
|
+
// Close all SSE connections for this tab
|
|
258
|
+
for (const res of tab.clients) {
|
|
259
|
+
res.write(`data: ${JSON.stringify({ type: "tab_closed" })}\n\n`);
|
|
260
|
+
res.end();
|
|
261
|
+
}
|
|
262
|
+
tab.clients.clear();
|
|
263
|
+
tabs.delete(id);
|
|
264
|
+
broadcastGlobal({ type: "tabs_changed", tabs: listTabs() });
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function listTabs() {
|
|
269
|
+
return [...tabs.values()].map((tab) => ({
|
|
270
|
+
id: tab.id,
|
|
271
|
+
sessionFile: tab.session.sessionFile,
|
|
272
|
+
name: tab.tabName || `Tab ${[...tabs.keys()].indexOf(tab.id) + 1}`,
|
|
273
|
+
sessionName: sessionSubject(tab.session),
|
|
274
|
+
streaming: tab.session.isStreaming,
|
|
275
|
+
cwd: shared.cwd,
|
|
276
|
+
createdAt: tab.createdAt,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Global SSE clients (for tab list updates, etc.)
|
|
281
|
+
const globalClients = new Set();
|
|
282
|
+
function broadcastGlobal(event) {
|
|
283
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
284
|
+
for (const res of globalClients) res.write(data);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Create initial default tab — resume the most recent session if one exists
|
|
288
|
+
let lastSessionFile = null;
|
|
289
|
+
try {
|
|
290
|
+
const allSessions = await SessionManager.listAll().catch(() => []);
|
|
291
|
+
const projectSessions = await SessionManager.list(CWD).catch(() => []);
|
|
292
|
+
const candidates = [...projectSessions, ...allSessions]
|
|
293
|
+
.map((entry) => typeof entry === "string" ? { file: entry } : { file: entry.file ?? entry.path, modified: entry.modified ?? entry.mtime })
|
|
294
|
+
.filter((s) => s.file && existsSync(s.file));
|
|
295
|
+
// Prefer the most recently modified session
|
|
296
|
+
if (candidates.length > 0) {
|
|
297
|
+
candidates.sort((a, b) => {
|
|
298
|
+
const ma = a.modified ?? statSync(a.file).mtimeMs;
|
|
299
|
+
const mb = b.modified ?? statSync(b.file).mtimeMs;
|
|
300
|
+
return mb - ma;
|
|
301
|
+
});
|
|
302
|
+
lastSessionFile = candidates[0].file;
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
console.warn(`[pi-web-ui] Could not find last session: ${err.message}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Switch workspace to match the resumed session's workspace
|
|
309
|
+
let startupCwd = CWD;
|
|
310
|
+
if (lastSessionFile) {
|
|
311
|
+
const sessionWs = extractWorkspacePath(lastSessionFile);
|
|
312
|
+
if (sessionWs && existsSync(sessionWs)) {
|
|
313
|
+
startupCwd = sessionWs;
|
|
314
|
+
if (startupCwd !== CWD) await buildShared(startupCwd);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const defaultTab = await createTab(startupCwd, { resumeSessionFile: lastSessionFile });
|
|
319
|
+
|
|
320
|
+
console.log(`[pi-web-ui] cwd: ${shared.cwd}`);
|
|
321
|
+
console.log(`[pi-web-ui] default tab: ${defaultTab.id}`);
|
|
322
|
+
console.log(`[pi-web-ui] session: ${defaultTab.session.sessionFile ?? "in-memory"}`);
|
|
323
|
+
console.log(`[pi-web-ui] model: ${defaultTab.session.model ? `${defaultTab.session.model.provider}/${defaultTab.session.model.id}` : "(none)"}`);
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Helpers (content rendering, session naming, transcript)
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
function renderContent(content) {
|
|
330
|
+
if (typeof content === "string") return content;
|
|
331
|
+
if (!Array.isArray(content)) return "";
|
|
332
|
+
return content
|
|
333
|
+
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
|
334
|
+
.map((block) => block.text)
|
|
335
|
+
.join("\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderThinking(content) {
|
|
339
|
+
if (!Array.isArray(content)) return "";
|
|
340
|
+
return content
|
|
341
|
+
.filter((b) => b.type === "thinking")
|
|
342
|
+
.map((b) => b.thinking ?? b.text ?? "")
|
|
343
|
+
.filter((text) => String(text).trim())
|
|
344
|
+
.join("\n\n");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function deriveSubject(text, maxLength = 64) {
|
|
348
|
+
const clean = String(text ?? "")
|
|
349
|
+
.replace(/```[\s\S]*?```/g, " code ")
|
|
350
|
+
.replace(/[`*_#>~[\]()]/g, " ")
|
|
351
|
+
.replace(/^\s*(?:[-+]|\d+[.)])\s+/, "")
|
|
352
|
+
.replace(/\s+/g, " ")
|
|
353
|
+
.trim();
|
|
354
|
+
if (!clean) return "New session";
|
|
355
|
+
const firstSentence = clean.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim();
|
|
356
|
+
const subject = firstSentence && firstSentence.length >= 12 ? firstSentence : clean;
|
|
357
|
+
if (subject.length <= maxLength) return subject.replace(/[.!?]+$/, "");
|
|
358
|
+
const clipped = subject.slice(0, maxLength - 1);
|
|
359
|
+
const boundary = clipped.lastIndexOf(" ");
|
|
360
|
+
return `${clipped.slice(0, boundary >= 24 ? boundary : clipped.length).trimEnd()}…`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function persistedSessionName(s) {
|
|
364
|
+
const file = s.sessionFile;
|
|
365
|
+
if (!file || !existsSync(file)) return null;
|
|
366
|
+
let name = null;
|
|
367
|
+
try {
|
|
368
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
369
|
+
if (!line) continue;
|
|
370
|
+
const entry = JSON.parse(line);
|
|
371
|
+
if (entry.type === "session_info" && typeof entry.name === "string" && entry.name.trim()) {
|
|
372
|
+
name = entry.name.trim();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} catch {}
|
|
376
|
+
return name;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function sessionSubject(s) {
|
|
380
|
+
const explicitName = persistedSessionName(s);
|
|
381
|
+
if (explicitName) return explicitName;
|
|
382
|
+
const firstUserMessage = s.messages.find((message) => message.role === "user");
|
|
383
|
+
return firstUserMessage ? deriveSubject(renderContent(firstUserMessage.content)) : "New session";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function assignInitialSessionSubject(s, text) {
|
|
387
|
+
const hasUserMessage = s.messages.some((message) => message.role === "user");
|
|
388
|
+
if (hasUserMessage || persistedSessionName(s)) return null;
|
|
389
|
+
const name = deriveSubject(text);
|
|
390
|
+
s.setSessionName(name);
|
|
391
|
+
return name;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function transcriptSnapshot(s) {
|
|
395
|
+
const items = [];
|
|
396
|
+
const toolsById = new Map();
|
|
397
|
+
|
|
398
|
+
const pushMessage = (role, text) => {
|
|
399
|
+
if (typeof text === "string" && text.trim()) items.push({ type: "message", role, text });
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
for (const message of s.messages) {
|
|
403
|
+
if (message.role === "user") {
|
|
404
|
+
pushMessage("user", renderContent(message.content));
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (message.role === "assistant") {
|
|
409
|
+
if (typeof message.content === "string") {
|
|
410
|
+
pushMessage("assistant", message.content);
|
|
411
|
+
} else if (Array.isArray(message.content)) {
|
|
412
|
+
for (const block of message.content) {
|
|
413
|
+
if (block?.type === "thinking") {
|
|
414
|
+
const text = block.thinking ?? block.text ?? "";
|
|
415
|
+
if (String(text).trim()) items.push({ type: "thinking", text: String(text) });
|
|
416
|
+
} else if (block?.type === "text") {
|
|
417
|
+
pushMessage("assistant", block.text);
|
|
418
|
+
} else if (["toolCall", "tool_call", "tool_use"].includes(block?.type)) {
|
|
419
|
+
const toolCallId = block.id ?? block.toolCallId ?? "";
|
|
420
|
+
const tool = {
|
|
421
|
+
type: "tool",
|
|
422
|
+
toolCallId,
|
|
423
|
+
toolName: block.name ?? block.toolName ?? "tool",
|
|
424
|
+
args: block.arguments ?? block.input ?? {},
|
|
425
|
+
output: null,
|
|
426
|
+
isError: false,
|
|
427
|
+
};
|
|
428
|
+
items.push(tool);
|
|
429
|
+
if (toolCallId) toolsById.set(toolCallId, tool);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (message.stopReason === "error" && message.errorMessage) {
|
|
434
|
+
items.push({ type: "error", text: message.errorMessage });
|
|
435
|
+
}
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (message.role === "toolResult") {
|
|
440
|
+
const toolCallId = message.toolCallId ?? "";
|
|
441
|
+
const output = renderContent(message.content);
|
|
442
|
+
const existing = toolsById.get(toolCallId);
|
|
443
|
+
if (existing) {
|
|
444
|
+
existing.output = output;
|
|
445
|
+
existing.isError = !!message.isError;
|
|
446
|
+
existing.toolName ||= message.toolName ?? "tool";
|
|
447
|
+
} else {
|
|
448
|
+
items.push({
|
|
449
|
+
type: "tool",
|
|
450
|
+
toolCallId,
|
|
451
|
+
toolName: message.toolName ?? "tool",
|
|
452
|
+
args: {},
|
|
453
|
+
output,
|
|
454
|
+
isError: !!message.isError,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return items;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// Tab data / utilities
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
const readJsonSafe = (path) => {
|
|
467
|
+
try {
|
|
468
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
|
|
469
|
+
} catch {
|
|
470
|
+
return { error: "could not parse file" };
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
async function logsData(url, s) {
|
|
475
|
+
const lines = Math.min(parseInt(url.searchParams.get("lines") ?? "200", 10) || 200, 2000);
|
|
476
|
+
const file = s.sessionFile;
|
|
477
|
+
if (!file || !existsSync(file)) return { file, entries: [] };
|
|
478
|
+
const raw = readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
479
|
+
const entries = raw.slice(-lines).map((line) => {
|
|
480
|
+
try {
|
|
481
|
+
const e = JSON.parse(line);
|
|
482
|
+
const m = e.message ?? {};
|
|
483
|
+
const contentText = typeof m.content === "string" ? m.content
|
|
484
|
+
: Array.isArray(m.content)
|
|
485
|
+
? m.content.map((b) =>
|
|
486
|
+
b.text ?? (b.thinking ? "[thinking] " + b.thinking : b.name ? `[${b.type}: ${b.name}]` : `[${b.type}]`)).join("\n")
|
|
487
|
+
: e.summary ?? e.customType ?? "";
|
|
488
|
+
return {
|
|
489
|
+
id: e.id?.slice(0, 8), type: e.type,
|
|
490
|
+
ts: e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : undefined,
|
|
491
|
+
role: m.role ?? m.type,
|
|
492
|
+
preview: contentText.slice(0, 800),
|
|
493
|
+
usage: m.usage ? `${m.usage.input ?? 0}/${m.usage.output ?? 0}` : undefined,
|
|
494
|
+
};
|
|
495
|
+
} catch {
|
|
496
|
+
return { type: "unparsed", preview: line.slice(0, 200) };
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
return { file, totalLines: raw.length, entries };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function skillsData() {
|
|
503
|
+
const skillsResult = shared.loader.getSkills();
|
|
504
|
+
const promptsResult = shared.loader.getPrompts();
|
|
505
|
+
const extResult = shared.loader.getExtensions();
|
|
506
|
+
return {
|
|
507
|
+
skills: skillsResult.skills ?? [],
|
|
508
|
+
prompts: promptsResult.prompts ?? [],
|
|
509
|
+
extensions: extResult.extensions ?? [],
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function configData() {
|
|
514
|
+
const cwd = shared.cwd;
|
|
515
|
+
const projectSettings = join(cwd, ".pi", "settings.json");
|
|
516
|
+
return {
|
|
517
|
+
agentDir: AGENT_DIR,
|
|
518
|
+
files: [
|
|
519
|
+
{ label: `Global (${join(AGENT_DIR, "settings.json")})`, data: readJsonSafe(join(AGENT_DIR, "settings.json")) },
|
|
520
|
+
{ label: `Project (${projectSettings})`, data: readJsonSafe(projectSettings) },
|
|
521
|
+
{ label: `Custom models (${join(AGENT_DIR, "models.json")})`, data: readJsonSafe(join(AGENT_DIR, "models.json")) },
|
|
522
|
+
],
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function availableModels(tab) {
|
|
527
|
+
const runtime = tab.runtime.services.modelRuntime;
|
|
528
|
+
await runtime.refresh({ allowNetwork: false });
|
|
529
|
+
return runtime.getAvailable();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function modelsData(tab) {
|
|
533
|
+
const available = await availableModels(tab);
|
|
534
|
+
return {
|
|
535
|
+
current: tab.session.model
|
|
536
|
+
? { provider: tab.session.model.provider, id: tab.session.model.id, contextWindow: tab.session.model.contextWindow }
|
|
537
|
+
: null,
|
|
538
|
+
thinkingLevel: tab.session.thinkingLevel,
|
|
539
|
+
available: available.map((m) => ({
|
|
540
|
+
provider: m.provider, id: m.id, name: m.name,
|
|
541
|
+
reasoning: !!m.reasoning, contextWindow: m.contextWindow,
|
|
542
|
+
})),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function extractWorkspacePath(filePath) {
|
|
547
|
+
if (!filePath) return null;
|
|
548
|
+
// Decode the full workspace path from the session directory name
|
|
549
|
+
const sessionsMatch = filePath.match(/\/sessions\/(--.*?--)(?:\/|$)/);
|
|
550
|
+
if (sessionsMatch) {
|
|
551
|
+
const encoded = sessionsMatch[1];
|
|
552
|
+
const inner = encoded.slice(2, -2); // e.g. "Users-username-Documents-Projects-Time"
|
|
553
|
+
const home = homedir().replace(/\//g, "-").replace(/^-/, "");
|
|
554
|
+
if (inner === home) return homedir();
|
|
555
|
+
if (inner.startsWith(home + "-")) {
|
|
556
|
+
const rest = inner.slice(home.length + 1);
|
|
557
|
+
const homePath = homedir();
|
|
558
|
+
const parts = rest.split("-");
|
|
559
|
+
let resolved = homePath;
|
|
560
|
+
for (let i = 0; i < parts.length; i++) {
|
|
561
|
+
let found = false;
|
|
562
|
+
for (let j = parts.length; j > i; j--) {
|
|
563
|
+
const candidate = parts.slice(i, j).join("-");
|
|
564
|
+
const testPath = join(resolved, candidate);
|
|
565
|
+
if (existsSync(testPath) && statSync(testPath).isDirectory()) {
|
|
566
|
+
resolved = testPath;
|
|
567
|
+
i = j - 1;
|
|
568
|
+
found = true;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (!found) break;
|
|
573
|
+
}
|
|
574
|
+
return resolved;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function extractWorkspace(filePath) {
|
|
581
|
+
if (!filePath) return null;
|
|
582
|
+
// Session files are stored under ~/.pi/agent/sessions/--<encoded-path>--/<file>.jsonl
|
|
583
|
+
// The encoded path replaces / with - (e.g., /Users/name/Documents/Projects/Time → --Users-name-Documents-Projects-Time--)
|
|
584
|
+
const sessionsMatch = filePath.match(/\/sessions\/(--.*?--)(?:\/|$)/);
|
|
585
|
+
if (sessionsMatch) {
|
|
586
|
+
const encoded = sessionsMatch[1]; // "--Users-name-Documents-Projects-Time--"
|
|
587
|
+
const inner = encoded.slice(2, -2); // "Users-name-Documents-Projects-Time"
|
|
588
|
+
// Strip the home dir prefix to get the project-relative portion
|
|
589
|
+
const home = homedir().replace(/\//g, "-").replace(/^-/, ""); // "Users-name"
|
|
590
|
+
if (inner === home) return "Home";
|
|
591
|
+
if (inner.startsWith(home + "-")) {
|
|
592
|
+
const rest = inner.slice(home.length + 1); // "Documents-Projects-Time"
|
|
593
|
+
// Try to resolve against actual filesystem to get the deepest valid directory
|
|
594
|
+
const homePath = homedir();
|
|
595
|
+
const parts = rest.split("-");
|
|
596
|
+
let resolved = homePath;
|
|
597
|
+
let lastValid = "Home";
|
|
598
|
+
for (let i = 0; i < parts.length; i++) {
|
|
599
|
+
// Try progressively joining segments (handles multi-word dir names with dashes)
|
|
600
|
+
let found = false;
|
|
601
|
+
for (let j = parts.length; j > i; j--) {
|
|
602
|
+
const candidate = parts.slice(i, j).join("-");
|
|
603
|
+
const testPath = join(resolved, candidate);
|
|
604
|
+
if (existsSync(testPath) && statSync(testPath).isDirectory()) {
|
|
605
|
+
resolved = testPath;
|
|
606
|
+
lastValid = candidate;
|
|
607
|
+
i = j - 1; // skip the consumed segments
|
|
608
|
+
found = true;
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (!found) {
|
|
613
|
+
// Can't resolve further; use remaining as-is
|
|
614
|
+
lastValid = parts.slice(i).join("-");
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return lastValid;
|
|
619
|
+
}
|
|
620
|
+
// Non-home path: just use the last segment
|
|
621
|
+
const parts = inner.split("-");
|
|
622
|
+
return parts[parts.length - 1] || inner;
|
|
623
|
+
}
|
|
624
|
+
// Fallback: look for /.pi/ in the path (project-local sessions)
|
|
625
|
+
const piIndex = filePath.indexOf("/.pi/");
|
|
626
|
+
if (piIndex > 0) {
|
|
627
|
+
const projectDir = filePath.slice(0, piIndex);
|
|
628
|
+
return basename(projectDir);
|
|
629
|
+
}
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function sessionsData(tab) {
|
|
634
|
+
const [projectSessions, allSessions] = await Promise.all([
|
|
635
|
+
SessionManager.list(shared.cwd).catch(() => []),
|
|
636
|
+
SessionManager.listAll().catch(() => []),
|
|
637
|
+
]);
|
|
638
|
+
const fmt = (entry) => {
|
|
639
|
+
if (typeof entry === "string") {
|
|
640
|
+
return { file: entry, subject: deriveSubject(entry.split("/").pop()), workspace: extractWorkspace(entry) };
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
file: entry.file ?? entry.path,
|
|
644
|
+
name: entry.name,
|
|
645
|
+
firstMessage: entry.firstMessage,
|
|
646
|
+
subject: entry.name?.trim() || deriveSubject(entry.firstMessage),
|
|
647
|
+
created: entry.created,
|
|
648
|
+
modified: entry.modified ?? entry.mtime,
|
|
649
|
+
messageCount: entry.messageCount,
|
|
650
|
+
workspace: extractWorkspace(entry.file ?? entry.path),
|
|
651
|
+
};
|
|
652
|
+
};
|
|
653
|
+
const activeTab = tab || defaultTab;
|
|
654
|
+
return {
|
|
655
|
+
current: activeTab.session.sessionFile,
|
|
656
|
+
currentName: sessionSubject(activeTab.session),
|
|
657
|
+
project: projectSessions.map(fmt),
|
|
658
|
+
all: allSessions.map(fmt),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function moveSessionToTrash(file, tab) {
|
|
663
|
+
if (typeof file !== "string" || !file.trim()) throw new Error("session file is required");
|
|
664
|
+
const target = resolve(file);
|
|
665
|
+
const indexed = await sessionsData();
|
|
666
|
+
const allowed = new Set(
|
|
667
|
+
[...indexed.project, ...indexed.all]
|
|
668
|
+
.map((item) => item.file)
|
|
669
|
+
.filter(Boolean)
|
|
670
|
+
.map((item) => resolve(item)),
|
|
671
|
+
);
|
|
672
|
+
if (!allowed.has(target)) throw new Error("session not found");
|
|
673
|
+
if (!existsSync(target) || !statSync(target).isFile()) throw new Error("session file is unavailable");
|
|
674
|
+
|
|
675
|
+
const activeFile = tab.session.sessionFile ? resolve(tab.session.sessionFile) : null;
|
|
676
|
+
const wasActive = activeFile === target;
|
|
677
|
+
if (wasActive) {
|
|
678
|
+
if (tab.session.isStreaming) throw new Error("stop the active response before deleting this session");
|
|
679
|
+
await tab.replaceSession(async () => {
|
|
680
|
+
const result = await tab.runtime.newSession();
|
|
681
|
+
if (result?.cancelled) throw new Error("new session was cancelled; nothing was deleted");
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const trashDir = join(homedir(), ".Trash");
|
|
686
|
+
if (!existsSync(trashDir) || !statSync(trashDir).isDirectory()) {
|
|
687
|
+
throw new Error("macOS Trash is unavailable");
|
|
688
|
+
}
|
|
689
|
+
const originalName = basename(target);
|
|
690
|
+
let destination = join(trashDir, originalName);
|
|
691
|
+
if (existsSync(destination)) {
|
|
692
|
+
const stem = originalName.endsWith(".jsonl") ? originalName.slice(0, -6) : originalName;
|
|
693
|
+
let suffix = Date.now();
|
|
694
|
+
do destination = join(trashDir, `${stem}-${suffix++}.jsonl`);
|
|
695
|
+
while (existsSync(destination));
|
|
696
|
+
}
|
|
697
|
+
renameSync(target, destination);
|
|
698
|
+
tab.broadcast({ type: "sessions_changed" });
|
|
699
|
+
return { ok: true, wasActive, trashedAs: basename(destination) };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const WEB_COMMANDS = [
|
|
703
|
+
{ name: "settings", description: "Open Web UI settings", action: "settings" },
|
|
704
|
+
{ name: "model", description: "Search or switch models", argumentHint: "<provider/model>", action: "model" },
|
|
705
|
+
{ name: "new", description: "Start a new session", action: "new" },
|
|
706
|
+
{ name: "resume", description: "Browse saved sessions", action: "resume" },
|
|
707
|
+
{ name: "name", description: "Set the current session name", argumentHint: "<name>", action: "server" },
|
|
708
|
+
{ name: "session", description: "Show current session statistics", action: "server" },
|
|
709
|
+
{ name: "compact", description: "Compact the current context", argumentHint: "[instructions]", action: "server" },
|
|
710
|
+
{ name: "reload", description: "Reload extensions, skills, prompts, and context", action: "server" },
|
|
711
|
+
{ name: "newtab", description: "Open a new parallel session tab", action: "newtab" },
|
|
712
|
+
];
|
|
713
|
+
|
|
714
|
+
function commandsData(tab) {
|
|
715
|
+
const prompts = shared.loader.getPrompts().prompts ?? [];
|
|
716
|
+
const skills = shared.loader.getSkills().skills ?? [];
|
|
717
|
+
let extensions = [];
|
|
718
|
+
try {
|
|
719
|
+
extensions = tab.session.extensionRunner.getRegisteredCommands()
|
|
720
|
+
.filter((command) => !WEB_COMMANDS.some((builtin) => builtin.name === command.name))
|
|
721
|
+
.map((command) => ({
|
|
722
|
+
name: command.invocationName ?? command.name,
|
|
723
|
+
description: command.description ?? "Extension command",
|
|
724
|
+
source: "extension",
|
|
725
|
+
}));
|
|
726
|
+
} catch {}
|
|
727
|
+
return {
|
|
728
|
+
commands: [
|
|
729
|
+
...WEB_COMMANDS.map((command) => ({ ...command, source: "builtin" })),
|
|
730
|
+
...prompts.map((prompt) => ({
|
|
731
|
+
name: prompt.name, description: prompt.description ?? "Prompt template",
|
|
732
|
+
argumentHint: prompt.argumentHint, source: "prompt",
|
|
733
|
+
})),
|
|
734
|
+
...extensions,
|
|
735
|
+
...skills.map((skill) => ({
|
|
736
|
+
name: `skill:${skill.name}`, description: skill.description ?? "Skill", source: "skill",
|
|
737
|
+
})),
|
|
738
|
+
],
|
|
739
|
+
skills: skills.map((skill) => ({ name: skill.name, description: skill.description ?? "", command: `/skill:${skill.name}` })),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function workspaceSearch(url) {
|
|
744
|
+
const query = (url.searchParams.get("q") ?? "").trim().toLowerCase();
|
|
745
|
+
const limit = Math.min(Math.max(parseInt(url.searchParams.get("limit") ?? "80", 10) || 80, 1), 200);
|
|
746
|
+
const maxDepth = 7;
|
|
747
|
+
const maxVisited = 6000;
|
|
748
|
+
const skipped = new Set([".git", "node_modules", ".next", ".cache", "dist", "build", "coverage"]);
|
|
749
|
+
const items = [];
|
|
750
|
+
let visited = 0;
|
|
751
|
+
|
|
752
|
+
const walk = (directory, depth) => {
|
|
753
|
+
if (depth > maxDepth || visited >= maxVisited || items.length >= limit) return;
|
|
754
|
+
let entries;
|
|
755
|
+
try {
|
|
756
|
+
entries = readdirSync(directory, { withFileTypes: true })
|
|
757
|
+
.filter((entry) => !entry.isSymbolicLink())
|
|
758
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
759
|
+
} catch { return; }
|
|
760
|
+
|
|
761
|
+
for (const entry of entries) {
|
|
762
|
+
if (visited++ >= maxVisited || items.length >= limit) break;
|
|
763
|
+
if (entry.isDirectory() && skipped.has(entry.name)) continue;
|
|
764
|
+
if (entry.name.startsWith(".") && !query.startsWith(".")) continue;
|
|
765
|
+
const absolute = join(directory, entry.name);
|
|
766
|
+
const path = relative(shared.cwd, absolute).split("\\").join("/");
|
|
767
|
+
if (!query || path.toLowerCase().includes(query)) {
|
|
768
|
+
items.push({ path, name: entry.name, type: entry.isDirectory() ? "directory" : "file" });
|
|
769
|
+
}
|
|
770
|
+
if (entry.isDirectory()) walk(absolute, depth + 1);
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
walk(shared.cwd, 0);
|
|
775
|
+
return { cwd: shared.cwd, query, items };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function runWebCommand(command, args, tab) {
|
|
779
|
+
switch (command) {
|
|
780
|
+
case "new":
|
|
781
|
+
await tab.replaceSession(() => tab.runtime.newSession());
|
|
782
|
+
return { ok: true, action: "new", sessionFile: tab.session.sessionFile };
|
|
783
|
+
case "newtab": {
|
|
784
|
+
const newTab = await createTab();
|
|
785
|
+
return { ok: true, action: "newtab", tabId: newTab.id, tabs: listTabs() };
|
|
786
|
+
}
|
|
787
|
+
case "settings": return { ok: true, action: "settings" };
|
|
788
|
+
case "resume": return { ok: true, action: "resume" };
|
|
789
|
+
case "model": {
|
|
790
|
+
if (!args) return { ok: true, action: "model" };
|
|
791
|
+
const available = await availableModels(tab);
|
|
792
|
+
const normalized = args.toLowerCase();
|
|
793
|
+
const exact = available.find((model) =>
|
|
794
|
+
`${model.provider}/${model.id}`.toLowerCase() === normalized || model.id.toLowerCase() === normalized);
|
|
795
|
+
if (!exact) return { ok: true, action: "model", query: args };
|
|
796
|
+
await tab.session.setModel(exact);
|
|
797
|
+
tab.broadcast({ type: "info", text: `model switched to ${exact.provider}/${exact.id}` });
|
|
798
|
+
tab.broadcast({ type: "stats", stats: tab.statsSnapshot() });
|
|
799
|
+
return { ok: true, action: "model-set", model: `${exact.provider}/${exact.id}` };
|
|
800
|
+
}
|
|
801
|
+
case "name":
|
|
802
|
+
if (!args) throw new Error("usage: /name <name>");
|
|
803
|
+
tab.session.setSessionName(args);
|
|
804
|
+
tab.broadcast({ type: "session_meta", name: args });
|
|
805
|
+
tab.broadcast({ type: "info", text: `session named ${args}` });
|
|
806
|
+
broadcastGlobal({ type: "tabs_changed", tabs: listTabs() });
|
|
807
|
+
return { ok: true, action: "named", name: args };
|
|
808
|
+
case "session": return { ok: true, action: "session-info", info: tab.session.getSessionStats() };
|
|
809
|
+
case "compact":
|
|
810
|
+
if (tab.session.isStreaming) throw new Error("wait for the current response before compacting");
|
|
811
|
+
await tab.session.compact(args || undefined);
|
|
812
|
+
tab.broadcast({ type: "info", text: "context compacted" });
|
|
813
|
+
return { ok: true, action: "compacted" };
|
|
814
|
+
case "reload":
|
|
815
|
+
if (tab.session.isStreaming) throw new Error("wait for the current response before reloading");
|
|
816
|
+
await tab.session.reload();
|
|
817
|
+
tab.broadcast({ type: "info", text: "extensions, skills, prompts, and context reloaded" });
|
|
818
|
+
return { ok: true, action: "reloaded" };
|
|
819
|
+
default: throw new Error(`unsupported Web UI command: /${command}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
// Resolve tab from request (query param ?tab=<id> or JSON body .tabId)
|
|
825
|
+
// Falls back to the first tab.
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
|
|
828
|
+
function resolveTab(tabId) {
|
|
829
|
+
if (tabId && tabs.has(tabId)) return tabs.get(tabId);
|
|
830
|
+
// Fallback to first tab
|
|
831
|
+
return tabs.values().next().value;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function getTabIdFromUrl(url) {
|
|
835
|
+
return url.searchParams.get("tab") ?? null;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---------------------------------------------------------------------------
|
|
839
|
+
// HTTP server
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
function gitRun(cwd, args, { timeout = 10_000 } = {}) {
|
|
843
|
+
return new Promise((resolve) => {
|
|
844
|
+
execFile("git", args, { cwd, timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
845
|
+
resolve({ err, stdout: stdout ?? "", stderr: stderr ?? "" });
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const json = (res, code, body) => {
|
|
851
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
852
|
+
res.end(JSON.stringify(body));
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
function readBody(req) {
|
|
856
|
+
return new Promise((resolve) => {
|
|
857
|
+
let body = "";
|
|
858
|
+
req.on("data", (c) => (body += c));
|
|
859
|
+
req.on("end", () => resolve(body));
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const server = createServer(async (req, res) => {
|
|
864
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
865
|
+
const path = url.pathname;
|
|
866
|
+
|
|
867
|
+
try {
|
|
868
|
+
if (path === "/vendor/marked.js") {
|
|
869
|
+
res.writeHead(200, {
|
|
870
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
871
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
872
|
+
});
|
|
873
|
+
res.end(readFileSync(join(HERE, "node_modules", "marked", "lib", "marked.umd.js"), "utf8"));
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (path === "/vendor/purify.js") {
|
|
878
|
+
res.writeHead(200, {
|
|
879
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
880
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
881
|
+
});
|
|
882
|
+
res.end(readFileSync(join(HERE, "node_modules", "dompurify", "dist", "purify.min.js"), "utf8"));
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (path === "/") {
|
|
887
|
+
res.writeHead(200, {
|
|
888
|
+
"content-type": "text/html; charset=utf-8",
|
|
889
|
+
"cache-control": "no-store",
|
|
890
|
+
});
|
|
891
|
+
res.end(readFileSync(join(HERE, "index.html"), "utf8"));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// --- Tab management APIs ---
|
|
896
|
+
|
|
897
|
+
if (path === "/api/tabs") {
|
|
898
|
+
json(res, 200, { tabs: listTabs(), defaultTab: defaultTab.id });
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (path === "/api/tabs/create" && req.method === "POST") {
|
|
903
|
+
const body = await readBody(req);
|
|
904
|
+
const { cwd: tabCwd } = JSON.parse(body || "{}");
|
|
905
|
+
const tab = await createTab(tabCwd || shared.cwd);
|
|
906
|
+
json(res, 200, { ok: true, tabId: tab.id, tabs: listTabs() });
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (path === "/api/tabs/close" && req.method === "POST") {
|
|
911
|
+
const body = await readBody(req);
|
|
912
|
+
const { tabId } = JSON.parse(body);
|
|
913
|
+
if (!tabId || !tabs.has(tabId)) {
|
|
914
|
+
json(res, 400, { ok: false, error: "tab not found" });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (tabs.size <= 1) {
|
|
918
|
+
json(res, 400, { ok: false, error: "cannot close the last tab" });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const tab = tabs.get(tabId);
|
|
922
|
+
if (tab.session.isStreaming) {
|
|
923
|
+
json(res, 400, { ok: false, error: "stop the active response before closing this tab" });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
removeTab(tabId);
|
|
927
|
+
json(res, 200, { ok: true, tabs: listTabs() });
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// --- Global SSE (for tab list updates) ---
|
|
932
|
+
if (path === "/api/events/global") {
|
|
933
|
+
res.writeHead(200, {
|
|
934
|
+
"content-type": "text/event-stream",
|
|
935
|
+
"cache-control": "no-cache",
|
|
936
|
+
connection: "keep-alive",
|
|
937
|
+
});
|
|
938
|
+
res.write(`data: ${JSON.stringify({ type: "tabs_changed", tabs: listTabs() })}\n\n`);
|
|
939
|
+
globalClients.add(res);
|
|
940
|
+
req.on("close", () => globalClients.delete(res));
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// --- Per-tab SSE ---
|
|
945
|
+
if (path === "/api/events") {
|
|
946
|
+
const tabId = getTabIdFromUrl(url);
|
|
947
|
+
const tab = resolveTab(tabId);
|
|
948
|
+
if (!tab) { json(res, 400, { error: "no active tab" }); return; }
|
|
949
|
+
res.writeHead(200, {
|
|
950
|
+
"content-type": "text/event-stream",
|
|
951
|
+
"cache-control": "no-cache",
|
|
952
|
+
connection: "keep-alive",
|
|
953
|
+
});
|
|
954
|
+
res.write(`data: ${JSON.stringify(tab.initPayload())}\n\n`);
|
|
955
|
+
tab.clients.add(res);
|
|
956
|
+
req.on("close", () => tab.clients.delete(res));
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// --- All other APIs resolve a tab from ?tab= or body.tabId ---
|
|
961
|
+
|
|
962
|
+
if (path === "/api/prompt" && req.method === "POST") {
|
|
963
|
+
const body = await readBody(req);
|
|
964
|
+
try {
|
|
965
|
+
const { text, mode, tabId } = JSON.parse(body);
|
|
966
|
+
const tab = resolveTab(tabId ?? getTabIdFromUrl(url));
|
|
967
|
+
if (!tab) throw new Error("tab not found");
|
|
968
|
+
if (!text?.trim()) throw new Error("empty prompt");
|
|
969
|
+
const name = assignInitialSessionSubject(tab.session, text);
|
|
970
|
+
if (name) {
|
|
971
|
+
tab.broadcast({ type: "session_meta", name });
|
|
972
|
+
broadcastGlobal({ type: "tabs_changed", tabs: listTabs() });
|
|
973
|
+
}
|
|
974
|
+
const opts = {};
|
|
975
|
+
if (mode === "steer") opts.streamingBehavior = "steer";
|
|
976
|
+
else if (tab.session.isStreaming) opts.streamingBehavior = "followUp";
|
|
977
|
+
tab.session.prompt(text, opts).catch((err) =>
|
|
978
|
+
tab.broadcast({ type: "error", error: String(err?.message ?? err) }));
|
|
979
|
+
json(res, 200, { ok: true });
|
|
980
|
+
} catch (err) {
|
|
981
|
+
json(res, 400, { ok: false, error: String(err.message) });
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (path === "/api/abort" && req.method === "POST") {
|
|
987
|
+
const body = await readBody(req);
|
|
988
|
+
const { tabId } = JSON.parse(body || "{}");
|
|
989
|
+
const tab = resolveTab(tabId ?? getTabIdFromUrl(url));
|
|
990
|
+
if (tab) await tab.session.abort();
|
|
991
|
+
json(res, 200, { ok: true });
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (path === "/api/info") {
|
|
996
|
+
const tab = resolveTab(getTabIdFromUrl(url));
|
|
997
|
+
if (!tab) { json(res, 400, { error: "no active tab" }); return; }
|
|
998
|
+
json(res, 200, {
|
|
999
|
+
cwd: shared.cwd, sessionFile: tab.session.sessionFile,
|
|
1000
|
+
model: tab.session.model ? `${tab.session.model.provider}/${tab.session.model.id}` : null,
|
|
1001
|
+
streaming: tab.session.isStreaming,
|
|
1002
|
+
contextUsage: tab.session.getContextUsage(),
|
|
1003
|
+
tabId: tab.id,
|
|
1004
|
+
});
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// --- Git APIs (run in the shared workspace cwd; UI hides itself when
|
|
1009
|
+
// the workspace is not a repository or these routes are absent) ---
|
|
1010
|
+
|
|
1011
|
+
if (path === "/api/git/status") {
|
|
1012
|
+
const root = await gitRun(shared.cwd, ["rev-parse", "--show-toplevel"]);
|
|
1013
|
+
if (root.err) { json(res, 200, { ok: true, isRepo: false }); return; }
|
|
1014
|
+
const repoRoot = root.stdout.trim();
|
|
1015
|
+
const [branchRes, porcelain, numstat] = await Promise.all([
|
|
1016
|
+
gitRun(repoRoot, ["branch", "--show-current"]),
|
|
1017
|
+
gitRun(repoRoot, ["status", "--porcelain"]),
|
|
1018
|
+
gitRun(repoRoot, ["diff", "HEAD", "--numstat"]),
|
|
1019
|
+
]);
|
|
1020
|
+
const numstatMap = new Map();
|
|
1021
|
+
let totalAdditions = 0;
|
|
1022
|
+
let totalDeletions = 0;
|
|
1023
|
+
for (const line of numstat.stdout.split("\n")) {
|
|
1024
|
+
if (!line.trim()) continue;
|
|
1025
|
+
const parts = line.split("\t");
|
|
1026
|
+
let file = parts.slice(2).join("\t");
|
|
1027
|
+
if (!file) continue;
|
|
1028
|
+
if (file.includes(" -> ")) file = file.split(" -> ").pop(); // renames: keep new path
|
|
1029
|
+
const additions = parseInt(parts[0], 10) || 0;
|
|
1030
|
+
const deletions = parseInt(parts[1], 10) || 0;
|
|
1031
|
+
numstatMap.set(file.replace(/^\"|\"$/g, ""), { additions, deletions });
|
|
1032
|
+
totalAdditions += additions;
|
|
1033
|
+
totalDeletions += deletions;
|
|
1034
|
+
}
|
|
1035
|
+
const changes = porcelain.stdout.split("\n").filter(Boolean).map((line) => {
|
|
1036
|
+
const status = line.slice(0, 2).trim() || "M";
|
|
1037
|
+
let file = line.slice(3);
|
|
1038
|
+
if (file.startsWith('"') && file.endsWith('"')) file = file.slice(1, -1);
|
|
1039
|
+
if (file.includes(" -> ")) file = file.split(" -> ").pop().replace(/\"/g, "");
|
|
1040
|
+
const counts = numstatMap.get(file) ?? {};
|
|
1041
|
+
return { path: file, status, additions: counts.additions ?? 0, deletions: counts.deletions ?? 0 };
|
|
1042
|
+
});
|
|
1043
|
+
const branch = branchRes.stdout.trim() || "HEAD";
|
|
1044
|
+
let ahead = null, behind = null;
|
|
1045
|
+
if (branch && branch !== "HEAD") {
|
|
1046
|
+
const revList = await gitRun(repoRoot, ["rev-list", "--left-right", "--count", `${branch}...@{upstream}`]);
|
|
1047
|
+
if (!revList.err && revList.stdout.trim()) {
|
|
1048
|
+
const parts = revList.stdout.trim().split(/\s+/).map(Number);
|
|
1049
|
+
ahead = parts[0] ?? 0;
|
|
1050
|
+
behind = parts[1] ?? 0;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
json(res, 200, {
|
|
1054
|
+
ok: true,
|
|
1055
|
+
isRepo: true,
|
|
1056
|
+
repo: basename(repoRoot),
|
|
1057
|
+
branch,
|
|
1058
|
+
changes,
|
|
1059
|
+
totalAdditions,
|
|
1060
|
+
totalDeletions,
|
|
1061
|
+
ahead,
|
|
1062
|
+
behind,
|
|
1063
|
+
});
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (path === "/api/git/branches") {
|
|
1068
|
+
const root = await gitRun(shared.cwd, ["rev-parse", "--show-toplevel"]);
|
|
1069
|
+
if (root.err) { json(res, 200, { ok: false, error: "not a git repository" }); return; }
|
|
1070
|
+
const repoRoot = root.stdout.trim();
|
|
1071
|
+
const [list, current] = await Promise.all([
|
|
1072
|
+
gitRun(repoRoot, ["for-each-ref", "refs/heads", "--format=%(refname:short)"]),
|
|
1073
|
+
gitRun(repoRoot, ["branch", "--show-current"]),
|
|
1074
|
+
]);
|
|
1075
|
+
const cur = current.stdout.trim();
|
|
1076
|
+
json(res, 200, {
|
|
1077
|
+
ok: true,
|
|
1078
|
+
branches: list.stdout.split("\n").filter(Boolean).map((name) => ({ name, current: name === cur })),
|
|
1079
|
+
});
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (path === "/api/git/checkout" && req.method === "POST") {
|
|
1084
|
+
const body = await readBody(req);
|
|
1085
|
+
const branch = String(JSON.parse(body || "{}").branch ?? "").trim();
|
|
1086
|
+
if (!/^[\w./-]+$/.test(branch)) { json(res, 400, { ok: false, error: "invalid branch name" }); return; }
|
|
1087
|
+
const result = await gitRun(shared.cwd, ["checkout", branch]);
|
|
1088
|
+
if (result.err) { json(res, 400, { ok: false, error: (result.stderr || result.err.message).trim() }); return; }
|
|
1089
|
+
json(res, 200, { ok: true });
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (path === "/api/git/stage" && req.method === "POST") {
|
|
1094
|
+
const body = await readBody(req);
|
|
1095
|
+
const { files, action: stageAction } = JSON.parse(body || "{}");
|
|
1096
|
+
if (!Array.isArray(files) || files.length === 0) { json(res, 400, { ok: false, error: "files array is required" }); return; }
|
|
1097
|
+
if (stageAction === "unstage") {
|
|
1098
|
+
const result = await gitRun(shared.cwd, ["reset", "HEAD", "--", ...files]);
|
|
1099
|
+
if (result.err) { json(res, 400, { ok: false, error: (result.stderr || result.err.message).trim() }); return; }
|
|
1100
|
+
} else {
|
|
1101
|
+
const result = await gitRun(shared.cwd, ["add", "--", ...files]);
|
|
1102
|
+
if (result.err) { json(res, 400, { ok: false, error: (result.stderr || result.err.message).trim() }); return; }
|
|
1103
|
+
}
|
|
1104
|
+
json(res, 200, { ok: true });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (path === "/api/git/commit" && req.method === "POST") {
|
|
1109
|
+
const body = await readBody(req);
|
|
1110
|
+
const { message: msg, stageAll } = JSON.parse(body || "{}");
|
|
1111
|
+
const message = String(msg ?? "").trim();
|
|
1112
|
+
if (!message) { json(res, 400, { ok: false, error: "commit message is required" }); return; }
|
|
1113
|
+
if (stageAll !== false) {
|
|
1114
|
+
const add = await gitRun(shared.cwd, ["add", "-A"]);
|
|
1115
|
+
if (add.err) { json(res, 400, { ok: false, error: (add.stderr || add.err.message).trim() }); return; }
|
|
1116
|
+
}
|
|
1117
|
+
const commit = await gitRun(shared.cwd, ["commit", "-m", message]);
|
|
1118
|
+
if (commit.err) { json(res, 400, { ok: false, error: (commit.stderr || commit.err.message).trim() }); return; }
|
|
1119
|
+
const hash = await gitRun(shared.cwd, ["rev-parse", "--short", "HEAD"]);
|
|
1120
|
+
json(res, 200, { ok: true, hash: hash.stdout.trim() });
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (path === "/api/git/branches/create" && req.method === "POST") {
|
|
1125
|
+
const body = await readBody(req);
|
|
1126
|
+
const { name, checkout } = JSON.parse(body || "{}");
|
|
1127
|
+
const branch = String(name ?? "").trim();
|
|
1128
|
+
if (!branch || !/^[\w./-]+$/.test(branch)) { json(res, 400, { ok: false, error: "invalid branch name" }); return; }
|
|
1129
|
+
const args = checkout ? ["checkout", "-b", branch] : ["branch", branch];
|
|
1130
|
+
const result = await gitRun(shared.cwd, args);
|
|
1131
|
+
if (result.err) { json(res, 400, { ok: false, error: (result.stderr || result.err.message).trim() }); return; }
|
|
1132
|
+
json(res, 200, { ok: true, branch });
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (path === "/api/git/branches/delete" && req.method === "POST") {
|
|
1137
|
+
const body = await readBody(req);
|
|
1138
|
+
const { name } = JSON.parse(body || "{}");
|
|
1139
|
+
const branch = String(name ?? "").trim();
|
|
1140
|
+
if (!branch || !/^[\w./-]+$/.test(branch)) { json(res, 400, { ok: false, error: "invalid branch name" }); return; }
|
|
1141
|
+
const result = await gitRun(shared.cwd, ["branch", "-d", branch]);
|
|
1142
|
+
if (result.err) {
|
|
1143
|
+
// If not fully merged, try force delete only if stderr mentions it
|
|
1144
|
+
if (result.stderr && result.stderr.includes("not fully merged")) {
|
|
1145
|
+
json(res, 400, { ok: false, error: `Branch "${branch}" is not fully merged. Use git branch -D to force delete.` });
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
json(res, 400, { ok: false, error: (result.stderr || result.err.message).trim() });
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
json(res, 200, { ok: true });
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (path === "/api/git/diff") {
|
|
1156
|
+
const file = (url.searchParams.get("file") ?? "").trim();
|
|
1157
|
+
if (!file) { json(res, 400, { ok: false, error: "file parameter is required" }); return; }
|
|
1158
|
+
const result = await gitRun(shared.cwd, ["diff", "HEAD", "--", file]);
|
|
1159
|
+
if (result.err) {
|
|
1160
|
+
// If the file is untracked, show it as a full new-file diff
|
|
1161
|
+
const untracked = await gitRun(shared.cwd, ["diff", "--no-index", "/dev/null", file]);
|
|
1162
|
+
if (untracked.stdout) { json(res, 200, { ok: true, diff: untracked.stdout }); return; }
|
|
1163
|
+
json(res, 400, { ok: false, error: (result.stderr || result.err.message).trim() }); return;
|
|
1164
|
+
}
|
|
1165
|
+
json(res, 200, { ok: true, diff: result.stdout });
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (path === "/api/git/commit-diff") {
|
|
1170
|
+
const hash = (url.searchParams.get("hash") ?? "").trim();
|
|
1171
|
+
if (!hash || !/^[\da-f]+$/i.test(hash)) { json(res, 400, { ok: false, error: "invalid commit hash" }); return; }
|
|
1172
|
+
const result = await gitRun(shared.cwd, ["show", "--format=", "--patch", hash]);
|
|
1173
|
+
if (result.err) { json(res, 400, { ok: false, error: (result.stderr || result.err.message).trim() }); return; }
|
|
1174
|
+
json(res, 200, { ok: true, diff: result.stdout });
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (path === "/api/git/sync" && req.method === "POST") {
|
|
1179
|
+
const body = await readBody(req);
|
|
1180
|
+
const { action: syncAction } = JSON.parse(body || "{}");
|
|
1181
|
+
const root = await gitRun(shared.cwd, ["rev-parse", "--show-toplevel"]);
|
|
1182
|
+
if (root.err) { json(res, 400, { ok: false, error: "not a git repository" }); return; }
|
|
1183
|
+
const repoRoot = root.stdout.trim();
|
|
1184
|
+
if (syncAction === "push") {
|
|
1185
|
+
const result = await gitRun(repoRoot, ["push"], { timeout: 60_000 });
|
|
1186
|
+
if (result.err) {
|
|
1187
|
+
// Try push with --set-upstream for new branches
|
|
1188
|
+
const branch = (await gitRun(repoRoot, ["branch", "--show-current"])).stdout.trim();
|
|
1189
|
+
if (branch) {
|
|
1190
|
+
const retry = await gitRun(repoRoot, ["push", "-u", "origin", branch], { timeout: 60_000 });
|
|
1191
|
+
if (retry.err) { json(res, 400, { ok: false, error: (retry.stderr || retry.err.message).trim() }); return; }
|
|
1192
|
+
json(res, 200, { ok: true, output: (retry.stderr || retry.stdout || "Pushed with upstream set.").trim() });
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
json(res, 400, { ok: false, error: (result.stderr || result.err.message).trim() });
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
json(res, 200, { ok: true, output: (result.stderr || result.stdout || "Push complete.").trim() });
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
if (syncAction === "pull") {
|
|
1202
|
+
const result = await gitRun(repoRoot, ["pull", "--rebase"], { timeout: 60_000 });
|
|
1203
|
+
if (result.err) { json(res, 400, { ok: false, error: (result.stderr || result.err.message).trim() }); return; }
|
|
1204
|
+
json(res, 200, { ok: true, output: (result.stdout || result.stderr || "Pull complete.").trim() });
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
json(res, 400, { ok: false, error: "action must be 'push' or 'pull'" });
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// --- Sessions ---
|
|
1212
|
+
if (path === "/api/session/new" && req.method === "POST") {
|
|
1213
|
+
const body = await readBody(req);
|
|
1214
|
+
const { tabId } = JSON.parse(body || "{}");
|
|
1215
|
+
const tab = resolveTab(tabId ?? getTabIdFromUrl(url));
|
|
1216
|
+
if (!tab) { json(res, 400, { error: "tab not found" }); return; }
|
|
1217
|
+
await tab.replaceSession(() => tab.runtime.newSession());
|
|
1218
|
+
broadcastGlobal({ type: "tabs_changed", tabs: listTabs() });
|
|
1219
|
+
json(res, 200, { ok: true, sessionFile: tab.session.sessionFile });
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (path === "/api/session/switch" && req.method === "POST") {
|
|
1223
|
+
const body = await readBody(req);
|
|
1224
|
+
try {
|
|
1225
|
+
const { file, tabId } = JSON.parse(body);
|
|
1226
|
+
const tab = resolveTab(tabId ?? getTabIdFromUrl(url));
|
|
1227
|
+
if (!tab) throw new Error("tab not found");
|
|
1228
|
+
// If the session belongs to a different workspace, switch workspace + rebuild runtime
|
|
1229
|
+
const sessionWs = extractWorkspacePath(file);
|
|
1230
|
+
if (sessionWs && sessionWs !== shared.cwd && existsSync(sessionWs)) {
|
|
1231
|
+
await buildShared(sessionWs);
|
|
1232
|
+
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
1233
|
+
cwd: sessionWs,
|
|
1234
|
+
agentDir: AGENT_DIR,
|
|
1235
|
+
sessionManager: SessionManager.create(sessionWs),
|
|
1236
|
+
});
|
|
1237
|
+
tab.runtime = runtime;
|
|
1238
|
+
}
|
|
1239
|
+
await tab.replaceSession(() => tab.runtime.switchSession(file));
|
|
1240
|
+
broadcastGlobal({ type: "tabs_changed", tabs: listTabs() });
|
|
1241
|
+
json(res, 200, { ok: true, sessionFile: tab.session.sessionFile });
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
json(res, 400, { ok: false, error: String(err.message) });
|
|
1244
|
+
}
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
if (path === "/api/session/delete" && req.method === "POST") {
|
|
1248
|
+
const body = await readBody(req);
|
|
1249
|
+
try {
|
|
1250
|
+
const { file, tabId } = JSON.parse(body);
|
|
1251
|
+
const tab = resolveTab(tabId ?? getTabIdFromUrl(url));
|
|
1252
|
+
if (!tab) throw new Error("tab not found");
|
|
1253
|
+
json(res, 200, await moveSessionToTrash(file, tab));
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
json(res, 400, { ok: false, error: String(err?.message ?? err) });
|
|
1256
|
+
}
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (path === "/api/session/logs") {
|
|
1261
|
+
const tab = resolveTab(getTabIdFromUrl(url));
|
|
1262
|
+
if (!tab) { json(res, 400, { error: "tab not found" }); return; }
|
|
1263
|
+
return void json(res, 200, await logsData(url, tab.session));
|
|
1264
|
+
}
|
|
1265
|
+
if (path === "/api/logs") {
|
|
1266
|
+
const tab = resolveTab(getTabIdFromUrl(url));
|
|
1267
|
+
if (!tab) { json(res, 400, { error: "tab not found" }); return; }
|
|
1268
|
+
return void json(res, 200, await logsData(url, tab.session));
|
|
1269
|
+
}
|
|
1270
|
+
if (path === "/api/skills") return void json(res, 200, skillsData());
|
|
1271
|
+
if (path === "/api/commands") {
|
|
1272
|
+
const tab = resolveTab(getTabIdFromUrl(url));
|
|
1273
|
+
if (!tab) { json(res, 400, { error: "tab not found" }); return; }
|
|
1274
|
+
return void json(res, 200, commandsData(tab));
|
|
1275
|
+
}
|
|
1276
|
+
if (path === "/api/context/search") return void json(res, 200, workspaceSearch(url));
|
|
1277
|
+
if (path === "/api/command" && req.method === "POST") {
|
|
1278
|
+
const body = await readBody(req);
|
|
1279
|
+
try {
|
|
1280
|
+
const input = JSON.parse(body);
|
|
1281
|
+
const command = String(input.command ?? "").replace(/^\//, "").trim();
|
|
1282
|
+
if (!command) throw new Error("command is required");
|
|
1283
|
+
const tab = resolveTab(input.tabId ?? getTabIdFromUrl(url));
|
|
1284
|
+
if (!tab) throw new Error("tab not found");
|
|
1285
|
+
json(res, 200, await runWebCommand(command, String(input.args ?? "").trim(), tab));
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
json(res, 400, { ok: false, error: String(err?.message ?? err) });
|
|
1288
|
+
}
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
if (path === "/api/config") return void json(res, 200, configData());
|
|
1292
|
+
if (path === "/api/models") {
|
|
1293
|
+
const tab = resolveTab(getTabIdFromUrl(url));
|
|
1294
|
+
if (!tab) { json(res, 400, { error: "tab not found" }); return; }
|
|
1295
|
+
return void json(res, 200, await modelsData(tab));
|
|
1296
|
+
}
|
|
1297
|
+
if (path === "/api/sessions") {
|
|
1298
|
+
const tab = resolveTab(getTabIdFromUrl(url));
|
|
1299
|
+
return void json(res, 200, await sessionsData(tab));
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// --- Workspace ---
|
|
1303
|
+
if (path === "/api/workspace/list") {
|
|
1304
|
+
const reqPath = resolve((url.searchParams.get("path") ?? "").trim() || shared.cwd);
|
|
1305
|
+
if (!existsSync(reqPath) || !statSync(reqPath).isDirectory()) {
|
|
1306
|
+
json(res, 400, { error: "directory-unreadable", path: reqPath });
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
let entries = [];
|
|
1310
|
+
try {
|
|
1311
|
+
entries = readdirSync(reqPath)
|
|
1312
|
+
.filter((name) => {
|
|
1313
|
+
try { return statSync(join(reqPath, name)).isDirectory(); } catch { return false; }
|
|
1314
|
+
})
|
|
1315
|
+
.sort((a, b) => a.localeCompare(b))
|
|
1316
|
+
.map((name) => ({ name, hidden: name.startsWith("."), path: join(reqPath, name) }));
|
|
1317
|
+
} catch {}
|
|
1318
|
+
const ancestry = [];
|
|
1319
|
+
for (let p = reqPath; ; p = dirname(p)) {
|
|
1320
|
+
ancestry.unshift({ name: p === "/" ? "/" : p.split("/").pop(), path: p });
|
|
1321
|
+
if (p === "/") break;
|
|
1322
|
+
}
|
|
1323
|
+
json(res, 200, { current: reqPath, home: homedir(), ancestry, entries });
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
if (path === "/api/workspace/set" && req.method === "POST") {
|
|
1328
|
+
const body = await readBody(req);
|
|
1329
|
+
try {
|
|
1330
|
+
const wsPath = resolve(JSON.parse(body).path);
|
|
1331
|
+
if (!existsSync(wsPath) || !statSync(wsPath).isDirectory())
|
|
1332
|
+
throw new Error("directory-unreadable");
|
|
1333
|
+
if (wsPath === shared.cwd) {
|
|
1334
|
+
json(res, 200, { ok: true, cwd: wsPath });
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
await buildShared(wsPath);
|
|
1338
|
+
// Rebuild all tabs for new workspace
|
|
1339
|
+
for (const tab of tabs.values()) {
|
|
1340
|
+
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
1341
|
+
cwd: wsPath,
|
|
1342
|
+
agentDir: AGENT_DIR,
|
|
1343
|
+
sessionManager: SessionManager.create(wsPath),
|
|
1344
|
+
});
|
|
1345
|
+
tab.runtime = runtime;
|
|
1346
|
+
tab.resetStats();
|
|
1347
|
+
tab.bindEvents();
|
|
1348
|
+
tab.broadcast(tab.initPayload());
|
|
1349
|
+
}
|
|
1350
|
+
broadcastGlobal({ type: "tabs_changed", tabs: listTabs() });
|
|
1351
|
+
broadcastGlobal({ type: "info", text: `workspace switched to ${wsPath}` });
|
|
1352
|
+
json(res, 200, { ok: true, cwd: wsPath });
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
json(res, 400, { ok: false, error: String(err.message) });
|
|
1355
|
+
}
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (path === "/api/model/set" && req.method === "POST") {
|
|
1360
|
+
const body = await readBody(req);
|
|
1361
|
+
try {
|
|
1362
|
+
const { provider, id, tabId } = JSON.parse(body);
|
|
1363
|
+
const tab = resolveTab(tabId ?? getTabIdFromUrl(url));
|
|
1364
|
+
if (!tab) throw new Error("tab not found");
|
|
1365
|
+
const model = (await availableModels(tab)).find((item) => item.provider === provider && item.id === id);
|
|
1366
|
+
if (!model) throw new Error(`model not found or unavailable: ${provider}/${id}`);
|
|
1367
|
+
await tab.session.setModel(model);
|
|
1368
|
+
tab.broadcast({ type: "info", text: `model switched to ${provider}/${id}` });
|
|
1369
|
+
tab.broadcast({ type: "stats", stats: tab.statsSnapshot() });
|
|
1370
|
+
json(res, 200, { ok: true });
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
json(res, 400, { ok: false, error: String(err.message) });
|
|
1373
|
+
}
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
if (path === "/api/thinking/set" && req.method === "POST") {
|
|
1378
|
+
const body = await readBody(req);
|
|
1379
|
+
try {
|
|
1380
|
+
const { level, tabId } = JSON.parse(body);
|
|
1381
|
+
const tab = resolveTab(tabId ?? getTabIdFromUrl(url));
|
|
1382
|
+
if (!tab) throw new Error("tab not found");
|
|
1383
|
+
tab.session.setThinkingLevel(level);
|
|
1384
|
+
json(res, 200, { ok: true, level: tab.session.thinkingLevel });
|
|
1385
|
+
} catch (err) {
|
|
1386
|
+
json(res, 400, { ok: false, error: String(err.message) });
|
|
1387
|
+
}
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
res.writeHead(404);
|
|
1392
|
+
res.end("not found");
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
json(res, 500, { error: String(err?.stack ?? err) });
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
server.listen(PORT, () => console.log(`[pi-web-ui] listening on http://localhost:${PORT}`));
|