pi-web-ui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1155 @@
1
+ /**
2
+ * AgentService — wraps the pi SDK (@earendil-works/pi-coding-agent) for the web
3
+ * frontend. Each browser client (identified by a persistent clientId) gets its
4
+ * own AgentSessionRuntime with a private session directory, so multiple users /
5
+ * tabs never share a transcript file.
6
+ *
7
+ * Streaming model: the SDK emits AgentSessionEvents; we forward lightweight
8
+ * `tool_delta` messages for live tool output and schedule throttled full-state
9
+ * snapshots. The frontend is snapshot-driven (server is the source of truth),
10
+ * so reconnects just re-request a snapshot.
11
+ */
12
+ import { existsSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
15
+ import { serializeMessage } from "./serialize.js";
16
+ import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
17
+ const SNAPSHOT_INTERVAL_MS = 60;
18
+ const WIDGET_REFRESH_MS = 2000;
19
+ const WIDGET_WIDTH = 80;
20
+ // ---------------------------------------------------------------------------
21
+ // Web UI context adapter — bridges extension UI calls (setWidget/notify) to the
22
+ // browser. Extensions like rpiv-todo render a TUI widget via
23
+ // `ui.setWidget(key, (tui, theme) => comp)`; we capture the component, render it
24
+ // with a mock theme to plain text lines, and push them to the client.
25
+ // ---------------------------------------------------------------------------
26
+ /** Mock theme: TUI color functions degrade to identity so widget text survives. */
27
+ const mockTheme = new Proxy({
28
+ fg: (_color, text) => text,
29
+ bold: (text) => text,
30
+ strikethrough: (text) => text,
31
+ dim: (text) => text,
32
+ }, {
33
+ get(target, prop) {
34
+ if (prop in target)
35
+ return target[prop];
36
+ // Unknown theme methods → no-op passthrough.
37
+ return (_arg, text) => text !== undefined ? text : "";
38
+ },
39
+ });
40
+ /** Mock TUI: any method call is a safe no-op. */
41
+ const mockTui = new Proxy({
42
+ requestRender: () => { },
43
+ render: () => { },
44
+ }, {
45
+ get(target, prop) {
46
+ if (prop in target)
47
+ return target[prop];
48
+ return () => { };
49
+ },
50
+ });
51
+ /**
52
+ * Implements the subset of ExtensionUIContext that makes sense for a web UI.
53
+ * TUI-only affordances (select/confirm/input dialogs, terminal input, custom
54
+ * footer) are inert: dialogs resolve to cancellation instead of blocking.
55
+ */
56
+ export class WebUIContext {
57
+ theme = mockTheme;
58
+ widgets = new Map();
59
+ lastLines = new Map();
60
+ emit;
61
+ constructor(emit) {
62
+ this.emit = emit;
63
+ }
64
+ // -- widgets -------------------------------------------------------------
65
+ /** Matches ExtensionUIContext's overloaded setWidget exactly. */
66
+ setWidget = (key, content, options) => {
67
+ void options;
68
+ if (content === undefined) {
69
+ this.widgets.delete(key);
70
+ this.lastLines.delete(key);
71
+ this.push();
72
+ return;
73
+ }
74
+ if (typeof content === "function") {
75
+ let comp;
76
+ try {
77
+ // Mock TUI/theme: extensions only read a handful of theme helpers;
78
+ // everything else is a no-op, so the widget renders to plain text.
79
+ comp = content(mockTui, mockTheme);
80
+ }
81
+ catch {
82
+ comp = undefined;
83
+ }
84
+ this.widgets.set(key, {
85
+ render: (w) => comp?.render?.(w),
86
+ dispose: comp?.dispose,
87
+ });
88
+ }
89
+ else {
90
+ this.widgets.set(key, { render: () => content });
91
+ }
92
+ this.push();
93
+ };
94
+ /** Re-render all widgets and push when content changed (polled + on demand). */
95
+ refresh() {
96
+ let changed = false;
97
+ for (const [key, w] of this.widgets) {
98
+ let lines;
99
+ try {
100
+ lines = w.render(WIDGET_WIDTH);
101
+ }
102
+ catch {
103
+ lines = undefined;
104
+ }
105
+ const prev = this.lastLines.get(key);
106
+ if (JSON.stringify(lines ?? null) !== JSON.stringify(prev ?? null)) {
107
+ this.lastLines.set(key, lines ?? []);
108
+ changed = true;
109
+ }
110
+ }
111
+ if (changed)
112
+ this.push();
113
+ }
114
+ push() {
115
+ const widgets = this.snapshot();
116
+ this.emit({ type: "widgets", widgets });
117
+ }
118
+ /** Render all widgets to their current text lines (without emitting). */
119
+ snapshot() {
120
+ return [...this.widgets.entries()].map(([key, w]) => {
121
+ let lines;
122
+ try {
123
+ lines = w.render(WIDGET_WIDTH);
124
+ }
125
+ catch {
126
+ lines = undefined;
127
+ }
128
+ this.lastLines.set(key, lines ?? []);
129
+ return { key, lines: lines ?? [] };
130
+ });
131
+ }
132
+ // -- notifications --------------------------------------------------------
133
+ notify(message, type) {
134
+ this.emit({ type: "notice", level: type ?? "info", text: message });
135
+ }
136
+ // -- footer status (pi-lens "LSP Inactive", pi-cache-optimizer cache stats) --
137
+ statuses = new Map();
138
+ setStatus(key, text) {
139
+ if (text === undefined || text === "") {
140
+ this.statuses.delete(key);
141
+ }
142
+ else {
143
+ this.statuses.set(key, text);
144
+ }
145
+ this.pushStatuses();
146
+ }
147
+ pushStatuses() {
148
+ this.emit({
149
+ type: "statuses",
150
+ statuses: [...this.statuses.entries()].map(([k, v]) => ({
151
+ key: k,
152
+ text: v,
153
+ })),
154
+ });
155
+ }
156
+ /** Current footer status entries (for replay on socket attach). */
157
+ statusSnapshot() {
158
+ return [...this.statuses.entries()].map(([k, v]) => ({ key: k, text: v }));
159
+ }
160
+ // -- dialogs (select/confirm/input bridged to the browser) ---------------
161
+ dialogSeq = 0;
162
+ pendingDialogs = new Map();
163
+ select = (title, options) => this.openDialog("select", title, [options]);
164
+ confirm = (title, message) => this.openDialog("confirm", title, [message]);
165
+ input = (title, placeholder) => this.openDialog("input", title, [placeholder ?? ""]);
166
+ openDialog(kind, title, args) {
167
+ return new Promise((resolve) => {
168
+ const id = ++this.dialogSeq;
169
+ this.pendingDialogs.set(id, resolve);
170
+ this.emit({ type: "dialog", id, kind, title, args });
171
+ });
172
+ }
173
+ /** Resolve a pending dialog with the user's choice (called from the client). */
174
+ resolveDialog(id, value) {
175
+ const resolve = this.pendingDialogs.get(id);
176
+ if (resolve) {
177
+ this.pendingDialogs.delete(id);
178
+ resolve(value);
179
+ this.emit({ type: "dialog_closed", id });
180
+ }
181
+ }
182
+ // -- inert TUI-only affordances ------------------------------------------
183
+ onTerminalInput = () => () => { };
184
+ setWorkingMessage = () => { };
185
+ setWorkingVisible = () => { };
186
+ setWorkingIndicator = () => { };
187
+ setHiddenThinkingLabel = () => { };
188
+ setFooter = () => { };
189
+ setHeader = () => { };
190
+ setTitle = () => { };
191
+ custom = (_factory, _done) => new Promise(() => { });
192
+ pasteToEditor = () => { };
193
+ setEditorText = () => { };
194
+ getEditorText = () => "";
195
+ editor = async () => undefined;
196
+ addAutocompleteProvider = () => { };
197
+ setEditorComponent = () => { };
198
+ getEditorComponent = () => undefined;
199
+ getAllThemes = () => [];
200
+ getTheme = () => undefined;
201
+ setTheme = () => ({ success: false });
202
+ getToolsExpanded = () => false;
203
+ setToolsExpanded = () => { };
204
+ /** Dispose all widgets (extension reload / session teardown). */
205
+ dispose() {
206
+ for (const w of this.widgets.values()) {
207
+ try {
208
+ w.dispose?.();
209
+ }
210
+ catch {
211
+ // best effort
212
+ }
213
+ }
214
+ this.widgets.clear();
215
+ this.lastLines.clear();
216
+ // Cancel any pending dialogs.
217
+ for (const [id, resolve] of this.pendingDialogs) {
218
+ resolve(null);
219
+ this.emit({ type: "dialog_closed", id });
220
+ }
221
+ this.pendingDialogs.clear();
222
+ }
223
+ }
224
+ /** Sanitize a clientId (UUID) for use as a directory name. */
225
+ function sanitizeId(id) {
226
+ return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80) || "anon";
227
+ }
228
+ const IGNORED_ENTRIES = new Set([
229
+ "node_modules",
230
+ ".git",
231
+ ".svn",
232
+ ".hg",
233
+ "dist",
234
+ ".next",
235
+ ".nuxt",
236
+ ".cache",
237
+ ".venv",
238
+ "venv",
239
+ "__pycache__",
240
+ "coverage",
241
+ ".pi-web",
242
+ ".DS_Store",
243
+ "Thumbs.db",
244
+ ]);
245
+ function countLines(buf) {
246
+ let lines = 0;
247
+ for (let i = 0; i < buf.length; i++) {
248
+ if (buf[i] === 10 /* \n */)
249
+ lines++;
250
+ }
251
+ return lines + (buf.length > 0 ? 1 : 0);
252
+ }
253
+ function extractPartialText(partial) {
254
+ const content = partial
255
+ ?.content;
256
+ if (Array.isArray(content)) {
257
+ const text = content
258
+ .map((c) => c?.type === "text"
259
+ ? c.text
260
+ : "")
261
+ .join("");
262
+ return text.length > 0 ? text : null;
263
+ }
264
+ return null;
265
+ }
266
+ export class ClientSession {
267
+ clientId;
268
+ cwd;
269
+ /** Immutable workspace root the commands file (.pi/commands.json) is anchored to. */
270
+ workspaceRoot;
271
+ /** Absolute per-client session directory. */
272
+ sessionDir;
273
+ /** pi config dir (auth/models/skills). */
274
+ agentDir;
275
+ runtime;
276
+ session;
277
+ /** PTY terminals for this client (killed when the last socket detaches). */
278
+ terminals = new TerminalManager((msg) => this.emit(msg));
279
+ /** Web-facing extension UI context (widgets, notifications). */
280
+ webUi = new WebUIContext((msg) => this.emit(msg));
281
+ widgetsTimer = null;
282
+ /** Connected sockets for this client (multiple tabs share the session). */
283
+ sinks = new Set();
284
+ pendingNotices = [];
285
+ unsubscribe;
286
+ snapshotTimer = null;
287
+ sessionsTimer = null;
288
+ version = 0;
289
+ seq = 0;
290
+ queueSteering = 0;
291
+ queueFollowUp = 0;
292
+ disposed = false;
293
+ constructor(clientId, cwd, sessionDir, agentDir, runtime) {
294
+ this.clientId = clientId;
295
+ this.cwd = cwd;
296
+ this.workspaceRoot = cwd;
297
+ this.sessionDir = sessionDir;
298
+ this.agentDir = agentDir;
299
+ this.runtime = runtime;
300
+ this.session = runtime.session;
301
+ }
302
+ static async create(clientId, cwd, sessionDir) {
303
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
304
+ const runtime = await createAgentSessionRuntime(ClientSession.runtimeFactory, {
305
+ cwd,
306
+ agentDir,
307
+ // Resume the most recent session for this client's private session dir,
308
+ // or start a fresh one on first visit.
309
+ sessionManager: SessionManager.continueRecent(cwd, sessionDir),
310
+ });
311
+ const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, runtime);
312
+ for (const d of runtime.diagnostics) {
313
+ if (d.type !== "info") {
314
+ cs.pendingNotices.push({
315
+ type: "notice",
316
+ level: d.type,
317
+ text: d.message,
318
+ });
319
+ }
320
+ }
321
+ await cs.bindSession();
322
+ return cs;
323
+ }
324
+ /** Builds a full cwd-bound runtime for the given working directory. */
325
+ static runtimeFactory = async ({ cwd: effectiveCwd, sessionManager, }) => {
326
+ const services = await createAgentSessionServices({ cwd: effectiveCwd });
327
+ return {
328
+ ...(await createAgentSessionFromServices({ services, sessionManager })),
329
+ services,
330
+ diagnostics: services.diagnostics,
331
+ };
332
+ };
333
+ /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
334
+ attachSink(send) {
335
+ this.sinks.add(send);
336
+ for (const msg of this.pendingNotices)
337
+ send(msg);
338
+ this.pendingNotices = [];
339
+ // Replay current extension widgets (setWidget may have fired during
340
+ // session creation, before any socket was attached).
341
+ const widgets = this.webUi.snapshot();
342
+ if (widgets.length > 0)
343
+ send({ type: "widgets", widgets });
344
+ const statuses = this.webUi.statusSnapshot();
345
+ if (statuses.length > 0)
346
+ send({ type: "statuses", statuses });
347
+ }
348
+ detachSink(send) {
349
+ this.sinks.delete(send);
350
+ // No sockets left for this client — kill its terminals so processes don't
351
+ // survive a closed tab / dropped connection.
352
+ if (this.sinks.size === 0)
353
+ this.terminals.killAll();
354
+ }
355
+ /** Broadcast to every connected socket of this client. */
356
+ emit(msg) {
357
+ if (this.disposed)
358
+ return;
359
+ for (const sink of [...this.sinks])
360
+ sink(msg);
361
+ }
362
+ /** (Re)attach event plumbing to the active session — also used after new_chat. */
363
+ async bindSession() {
364
+ this.unsubscribe?.();
365
+ this.session = this.runtime.session;
366
+ await this.session.bindExtensions({
367
+ mode: "rpc",
368
+ uiContext: this.webUi,
369
+ onError: (err) => {
370
+ this.emit({ type: "notice", level: "error", text: err.error });
371
+ },
372
+ });
373
+ this.unsubscribe = this.session.subscribe((event) => this.onEvent(event));
374
+ this.scheduleSnapshot();
375
+ this.webUi.refresh();
376
+ this.startWidgetsTimer();
377
+ }
378
+ /** Poll extension widgets so TUI-only overlays (e.g. rpiv-todo) stay live. */
379
+ startWidgetsTimer() {
380
+ if (this.widgetsTimer)
381
+ return;
382
+ this.widgetsTimer = setInterval(() => {
383
+ if (!this.disposed)
384
+ this.webUi.refresh();
385
+ }, WIDGET_REFRESH_MS);
386
+ }
387
+ onEvent(event) {
388
+ switch (event.type) {
389
+ case "bash_execution_update": {
390
+ if (event.id) {
391
+ this.emit({
392
+ type: "tool_delta",
393
+ toolCallId: event.id,
394
+ toolName: "bash",
395
+ delta: event.delta,
396
+ });
397
+ }
398
+ break;
399
+ }
400
+ case "tool_execution_update": {
401
+ const text = extractPartialText(event.partialResult);
402
+ if (text) {
403
+ this.emit({
404
+ type: "tool_delta",
405
+ toolCallId: event.toolCallId,
406
+ toolName: event.toolName,
407
+ delta: text,
408
+ });
409
+ }
410
+ break;
411
+ }
412
+ case "queue_update":
413
+ this.queueSteering = event.steering.length;
414
+ this.queueFollowUp = event.followUp.length;
415
+ break;
416
+ // A run finished or a new entry was persisted — keep the session list fresh
417
+ // (new chat + first message, completed turns, compaction, etc.).
418
+ case "agent_end":
419
+ case "entry_appended":
420
+ this.scheduleSessionsRefresh();
421
+ break;
422
+ default:
423
+ break;
424
+ }
425
+ this.scheduleSnapshot();
426
+ }
427
+ /** Debounced push of the persisted session list to the client. */
428
+ scheduleSessionsRefresh() {
429
+ if (this.sessionsTimer)
430
+ return;
431
+ this.sessionsTimer = setTimeout(() => {
432
+ this.sessionsTimer = null;
433
+ if (!this.disposed)
434
+ void this.pushSessions();
435
+ }, 800);
436
+ }
437
+ snapshot() {
438
+ const state = this.session.agent.state;
439
+ const model = state.model;
440
+ let stats = {
441
+ totalMessages: 0,
442
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
443
+ cost: 0,
444
+ contextUsage: { tokens: null, contextWindow: 0, percent: null },
445
+ };
446
+ try {
447
+ const s = this.session.getSessionStats();
448
+ stats = {
449
+ totalMessages: s.totalMessages,
450
+ tokens: s.tokens,
451
+ cost: s.cost,
452
+ contextUsage: s.contextUsage
453
+ ? {
454
+ tokens: s.contextUsage.tokens,
455
+ contextWindow: s.contextUsage.contextWindow,
456
+ percent: s.contextUsage.percent,
457
+ }
458
+ : stats.contextUsage,
459
+ };
460
+ }
461
+ catch {
462
+ // stats are best-effort
463
+ }
464
+ return {
465
+ clientId: this.clientId,
466
+ cwd: this.cwd,
467
+ sessionId: this.session.sessionId,
468
+ sessionFile: this.session.sessionFile,
469
+ messages: state.messages
470
+ .map((m) => serializeMessage(m, ++this.seq))
471
+ .filter((m) => m !== null),
472
+ isStreaming: this.session.isStreaming,
473
+ model: model
474
+ ? { id: model.id, name: model.name, provider: model.provider }
475
+ : null,
476
+ thinkingLevel: state.thinkingLevel,
477
+ queue: { steering: this.queueSteering, followUp: this.queueFollowUp },
478
+ errorMessage: state.errorMessage,
479
+ tools: state.tools.map((t) => t.name),
480
+ version: ++this.version,
481
+ stats,
482
+ };
483
+ }
484
+ /** Resolve a browser-bridged dialog (select/confirm/input) for this session. */
485
+ resolveDialog(id, value) {
486
+ this.webUi.resolveDialog(id, value);
487
+ }
488
+ /** Send a snapshot immediately (cancels any pending throttled one). */
489
+ flushSnapshot() {
490
+ if (this.snapshotTimer) {
491
+ clearTimeout(this.snapshotTimer);
492
+ this.snapshotTimer = null;
493
+ }
494
+ if (!this.disposed)
495
+ this.emit({ type: "snapshot", state: this.snapshot() });
496
+ }
497
+ scheduleSnapshot() {
498
+ if (this.snapshotTimer || this.disposed)
499
+ return;
500
+ this.snapshotTimer = setTimeout(() => {
501
+ this.snapshotTimer = null;
502
+ if (!this.disposed)
503
+ this.emit({ type: "snapshot", state: this.snapshot() });
504
+ }, SNAPSHOT_INTERVAL_MS);
505
+ }
506
+ // ---------------------------------------------------------------------------
507
+ // Commands
508
+ // ---------------------------------------------------------------------------
509
+ async prompt(text, attachments) {
510
+ try {
511
+ const s = this.session;
512
+ // Attach files as independent nextTurn context messages (asides) so the
513
+ // user message stays clean; they render as separate attachment cards.
514
+ const asides = await this.buildAttachmentMessages(attachments);
515
+ for (const aside of asides) {
516
+ await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
517
+ }
518
+ if (s.isStreaming) {
519
+ // Queue for delivery after the current run finishes.
520
+ await s.prompt(text, { streamingBehavior: "followUp" });
521
+ }
522
+ else {
523
+ await s.prompt(text);
524
+ }
525
+ }
526
+ catch (err) {
527
+ this.emit({
528
+ type: "notice",
529
+ level: "error",
530
+ text: `提示发送失败:${err.message}`,
531
+ });
532
+ }
533
+ this.flushSnapshot();
534
+ }
535
+ /**
536
+ * Turn attached files into custom-message payloads.
537
+ *
538
+ * Text files are size-aware: small files are inlined into the message so the
539
+ * model sees them immediately; large files are passed as a <file path="...">
540
+ * reference and the model reads them on demand with its read tool (which has
541
+ * built-in truncation). Images are always passed as image content.
542
+ */
543
+ async buildAttachmentMessages(attachments) {
544
+ if (!attachments || attachments.length === 0)
545
+ return [];
546
+ const fs = await import("node:fs/promises");
547
+ const { resolve, sep, relative, extname } = await import("node:path");
548
+ const root = resolve(this.cwd);
549
+ const MAX_ATTACHMENT_BYTES = 200 * 1024;
550
+ // Files at or below this size are inlined; larger files are referenced by
551
+ // path only (the model reads them on demand — saves tokens for small edits).
552
+ const MAX_INLINE_BYTES = Number(process.env.PI_WEB_INLINE_FILE_MAX ?? 12 * 1024);
553
+ const IMAGE_EXT = new Set([
554
+ ".png",
555
+ ".jpg",
556
+ ".jpeg",
557
+ ".gif",
558
+ ".webp",
559
+ ".bmp",
560
+ ".svg",
561
+ ]);
562
+ const MIME = {
563
+ ".png": "image/png",
564
+ ".jpg": "image/jpeg",
565
+ ".jpeg": "image/jpeg",
566
+ ".gif": "image/gif",
567
+ ".webp": "image/webp",
568
+ ".bmp": "image/bmp",
569
+ ".svg": "image/svg+xml",
570
+ };
571
+ const out = [];
572
+ for (const att of attachments) {
573
+ const abs = resolve(root, att.path);
574
+ const rel = relative(root, abs);
575
+ if (rel.startsWith("..") || rel.includes(`${sep}..`)) {
576
+ this.emit({
577
+ type: "notice",
578
+ level: "warning",
579
+ text: `附件路径超出工作区:${att.path}`,
580
+ });
581
+ continue;
582
+ }
583
+ let stat;
584
+ try {
585
+ stat = await fs.stat(abs);
586
+ }
587
+ catch {
588
+ this.emit({
589
+ type: "notice",
590
+ level: "error",
591
+ text: `附件不存在:${att.path}`,
592
+ });
593
+ continue;
594
+ }
595
+ const name = att.path.split("/").pop() ?? att.path;
596
+ // Folders can't be inlined — always a path reference the model browses
597
+ // on demand with its own tools (ls/read).
598
+ if (stat.isDirectory()) {
599
+ out.push({
600
+ message: {
601
+ customType: "file",
602
+ content: [{ type: "text", text: `<folder path="${rel}" />` }],
603
+ display: true,
604
+ details: {
605
+ name,
606
+ path: rel,
607
+ mode: "reference",
608
+ type: "folder",
609
+ },
610
+ },
611
+ });
612
+ continue;
613
+ }
614
+ if (!stat.isFile()) {
615
+ this.emit({
616
+ type: "notice",
617
+ level: "warning",
618
+ text: `跳过非文件附件:${att.path}`,
619
+ });
620
+ continue;
621
+ }
622
+ const ext = extname(att.path).toLowerCase();
623
+ if (IMAGE_EXT.has(ext)) {
624
+ // Images can't be referenced — they must be inlined, so keep a hard cap.
625
+ if (stat.size > MAX_ATTACHMENT_BYTES) {
626
+ this.emit({
627
+ type: "notice",
628
+ level: "warning",
629
+ text: `图片附件过大已跳过(>200KB):${att.path}`,
630
+ });
631
+ continue;
632
+ }
633
+ const data = await fs.readFile(abs, "base64");
634
+ out.push({
635
+ message: {
636
+ customType: "file",
637
+ content: [
638
+ { type: "image", data, mimeType: MIME[ext] ?? "image/png" },
639
+ ],
640
+ display: true,
641
+ details: { name, path: rel, mode: "image", size: stat.size },
642
+ },
643
+ });
644
+ continue;
645
+ }
646
+ const makeReference = () => ({
647
+ message: {
648
+ customType: "file",
649
+ content: [
650
+ {
651
+ type: "text",
652
+ text: `<file path="${rel}" size="${stat.size}" />`,
653
+ },
654
+ ],
655
+ display: true,
656
+ details: { name, path: rel, mode: "reference", size: stat.size },
657
+ },
658
+ });
659
+ const makeInline = (buf) => {
660
+ const lines = countLines(buf);
661
+ return {
662
+ message: {
663
+ customType: "file",
664
+ content: [
665
+ {
666
+ type: "text",
667
+ text: `\n<file path="${rel}">\n\`\`\`\n${buf.toString("utf8")}\n\`\`\`\n</file>`,
668
+ },
669
+ ],
670
+ display: true,
671
+ details: {
672
+ name,
673
+ path: rel,
674
+ mode: "inline",
675
+ size: stat.size,
676
+ lines,
677
+ },
678
+ },
679
+ };
680
+ };
681
+ // Reference mode is always honored and never reads the file.
682
+ if (att.mode === "reference") {
683
+ out.push(makeReference());
684
+ continue;
685
+ }
686
+ // Forced inline has a hard cap to protect the model context.
687
+ if (att.mode === "inline") {
688
+ if (stat.size > MAX_INLINE_BYTES) {
689
+ this.emit({
690
+ type: "notice",
691
+ level: "warning",
692
+ text: `文件过大,已改为仅引用:${att.path}`,
693
+ });
694
+ out.push(makeReference());
695
+ continue;
696
+ }
697
+ const buf = await fs.readFile(abs);
698
+ if (buf.includes(0)) {
699
+ this.emit({
700
+ type: "notice",
701
+ level: "warning",
702
+ text: `二进制文件已改为仅引用:${att.path}`,
703
+ });
704
+ out.push(makeReference());
705
+ continue;
706
+ }
707
+ out.push(makeInline(buf));
708
+ continue;
709
+ }
710
+ // Auto: small files inline, large files reference by path.
711
+ if (stat.size > MAX_INLINE_BYTES) {
712
+ out.push(makeReference());
713
+ continue;
714
+ }
715
+ const buf = await fs.readFile(abs);
716
+ if (buf.includes(0)) {
717
+ this.emit({
718
+ type: "notice",
719
+ level: "warning",
720
+ text: `二进制文件已跳过(仅引用路径):${att.path}`,
721
+ });
722
+ out.push(makeReference());
723
+ continue;
724
+ }
725
+ out.push(makeInline(buf));
726
+ }
727
+ return out;
728
+ }
729
+ async abort() {
730
+ try {
731
+ await this.session.abort();
732
+ }
733
+ catch (err) {
734
+ this.emit({
735
+ type: "notice",
736
+ level: "error",
737
+ text: `中止失败:${err.message}`,
738
+ });
739
+ }
740
+ this.flushSnapshot();
741
+ }
742
+ async newChat() {
743
+ try {
744
+ await this.runtime.newSession();
745
+ await this.bindSession();
746
+ }
747
+ catch (err) {
748
+ this.emit({
749
+ type: "notice",
750
+ level: "error",
751
+ text: `新建对话失败:${err.message}`,
752
+ });
753
+ }
754
+ this.flushSnapshot();
755
+ }
756
+ /** List persisted sessions for this client, newest first. */
757
+ /** Push the persisted session list to the client (client-requested). */
758
+ async refreshSessions() {
759
+ await this.pushSessions();
760
+ }
761
+ async pushSessions() {
762
+ try {
763
+ const { resolve } = await import("node:path");
764
+ // The pi CLI/TUI keeps sessions in <agentDir>/sessions/--<cwd-sanitized>--
765
+ // (encoded per working directory). List those too, so the conversation
766
+ // panel shows every conversation of the current folder — not just the
767
+ // ones created in this web UI.
768
+ const safePath = `--${resolve(this.cwd)
769
+ .replace(/^[/\\]/, "")
770
+ .replace(/[/\\:]/g, "-")}--`;
771
+ const tuiSessionDir = join(this.agentDir, "sessions", safePath);
772
+ const [webInfos, tuiInfos] = await Promise.all([
773
+ SessionManager.list(this.cwd, this.sessionDir),
774
+ existsSync(tuiSessionDir)
775
+ ? SessionManager.list(this.cwd, tuiSessionDir).catch(() => [])
776
+ : Promise.resolve([]),
777
+ ]);
778
+ const sessions = new Map();
779
+ for (const s of webInfos) {
780
+ sessions.set(s.path, {
781
+ path: s.path,
782
+ name: s.name,
783
+ firstMessage: s.firstMessage,
784
+ messageCount: s.messageCount,
785
+ modified: s.modified.getTime(),
786
+ source: "web",
787
+ });
788
+ }
789
+ for (const s of tuiInfos) {
790
+ sessions.set(s.path, {
791
+ path: s.path,
792
+ name: s.name,
793
+ firstMessage: s.firstMessage,
794
+ messageCount: s.messageCount,
795
+ modified: s.modified.getTime(),
796
+ source: "tui",
797
+ });
798
+ }
799
+ const sorted = [...sessions.values()].sort((a, b) => b.modified - a.modified);
800
+ this.emit({ type: "sessions", sessions: sorted });
801
+ }
802
+ catch {
803
+ this.emit({ type: "sessions", sessions: [] });
804
+ }
805
+ }
806
+ /** Switch the active session to a persisted one (from listSessions). */
807
+ async switchSession(path) {
808
+ try {
809
+ await this.runtime.switchSession(path);
810
+ await this.bindSession();
811
+ }
812
+ catch (err) {
813
+ this.emit({
814
+ type: "notice",
815
+ level: "error",
816
+ text: `切换会话失败:${err.message}`,
817
+ });
818
+ }
819
+ this.flushSnapshot();
820
+ }
821
+ /** List a workspace directory (relative to the configured cwd). */
822
+ async listFiles(relPath) {
823
+ try {
824
+ const fs = await import("node:fs/promises");
825
+ const { resolve, sep, relative } = await import("node:path");
826
+ const root = resolve(this.cwd);
827
+ const target = relPath ? resolve(root, relPath) : root;
828
+ const rel = relative(root, target);
829
+ if (rel.startsWith("..") || rel.includes(`${sep}..`)) {
830
+ this.emit({
831
+ type: "notice",
832
+ level: "warning",
833
+ text: `路径超出工作区:${relPath ?? ""}`,
834
+ });
835
+ return;
836
+ }
837
+ const dirents = await fs.readdir(target, { withFileTypes: true });
838
+ const entries = dirents
839
+ .filter((d) => !IGNORED_ENTRIES.has(d.name))
840
+ .map((d) => ({
841
+ name: d.name,
842
+ path: rel === "" ? d.name : `${rel}/${d.name}`,
843
+ type: (d.isDirectory() ? "dir" : "file"),
844
+ }))
845
+ .sort((a, b) => a.type === b.type
846
+ ? a.name.localeCompare(b.name)
847
+ : a.type === "dir"
848
+ ? -1
849
+ : 1)
850
+ .slice(0, 500);
851
+ this.emit({
852
+ type: "files",
853
+ path: rel === "" ? "" : rel,
854
+ parent: rel === ""
855
+ ? null
856
+ : rel.includes("/")
857
+ ? rel.slice(0, rel.lastIndexOf("/"))
858
+ : "",
859
+ entries,
860
+ });
861
+ }
862
+ catch (err) {
863
+ this.emit({
864
+ type: "notice",
865
+ level: "error",
866
+ text: `读取目录失败:${err.message}`,
867
+ });
868
+ }
869
+ }
870
+ async cycleModel() {
871
+ try {
872
+ await this.session.cycleModel();
873
+ }
874
+ catch (err) {
875
+ this.emit({
876
+ type: "notice",
877
+ level: "error",
878
+ text: `切换模型失败:${err.message}`,
879
+ });
880
+ }
881
+ this.flushSnapshot();
882
+ }
883
+ /**
884
+ * Path completion for the cwd input: expand ~/relative paths, list the parent
885
+ * directory, and return prefix matches (dirs first, capped).
886
+ */
887
+ async completePath(input) {
888
+ const empty = () => this.emit({ type: "path_completions", completions: [] });
889
+ try {
890
+ const fs = await import("node:fs/promises");
891
+ const { resolve, sep } = await import("node:path");
892
+ const { homedir } = await import("node:os");
893
+ const home = homedir();
894
+ // Expand ~ and relative inputs to an absolute path.
895
+ let expanded = input.trim();
896
+ if (expanded === "") {
897
+ empty();
898
+ return;
899
+ }
900
+ if (expanded === "~")
901
+ expanded = `${home}${sep}`;
902
+ else if (expanded.startsWith("~/"))
903
+ expanded = home + expanded.slice(1);
904
+ else if (!expanded.startsWith("/"))
905
+ expanded = resolve(this.cwd, expanded);
906
+ // Split into parent dir + prefix (handle trailing slash = browse a dir).
907
+ const lastSlash = expanded.lastIndexOf("/");
908
+ const dirPart = lastSlash >= 0 ? expanded.slice(0, lastSlash + 1) : "/";
909
+ const prefix = lastSlash >= 0 ? expanded.slice(lastSlash + 1) : expanded;
910
+ const dirents = await fs
911
+ .readdir(dirPart, { withFileTypes: true })
912
+ .catch(() => null);
913
+ if (!dirents) {
914
+ empty();
915
+ return;
916
+ }
917
+ const completions = dirents
918
+ .filter((d) => d.name.startsWith(prefix) && !IGNORED_ENTRIES.has(d.name))
919
+ .map((d) => ({
920
+ name: d.name,
921
+ path: dirPart + d.name,
922
+ type: (d.isDirectory() ? "dir" : "file"),
923
+ }))
924
+ .sort((a, b) => {
925
+ const aHidden = a.name.startsWith(".");
926
+ const bHidden = b.name.startsWith(".");
927
+ if (aHidden !== bHidden)
928
+ return aHidden ? 1 : -1;
929
+ if (a.type !== b.type)
930
+ return a.type === "dir" ? -1 : 1;
931
+ return a.name.localeCompare(b.name);
932
+ })
933
+ .slice(0, 30);
934
+ this.emit({ type: "path_completions", completions });
935
+ }
936
+ catch {
937
+ empty();
938
+ }
939
+ }
940
+ /**
941
+ * Switch the agent's working directory by rebuilding the runtime for the new
942
+ * cwd (services are cwd-bound). Resumes that directory's most recent session;
943
+ * refreshes snapshot, session list, and file tree.
944
+ */
945
+ async setCwd(newCwd) {
946
+ try {
947
+ const { resolve } = await import("node:path");
948
+ const fs = await import("node:fs/promises");
949
+ const abs = resolve(newCwd);
950
+ const st = await fs.stat(abs);
951
+ if (!st.isDirectory()) {
952
+ throw new Error("路径不是目录");
953
+ }
954
+ if (abs === this.cwd) {
955
+ this.emit({
956
+ type: "notice",
957
+ level: "info",
958
+ text: `已在工作目录:${abs}`,
959
+ });
960
+ this.flushSnapshot();
961
+ return;
962
+ }
963
+ // Build the new runtime first — only swap on success.
964
+ const newRuntime = await createAgentSessionRuntime(ClientSession.runtimeFactory, {
965
+ cwd: abs,
966
+ agentDir: this.agentDir,
967
+ sessionManager: SessionManager.continueRecent(abs, this.sessionDir),
968
+ });
969
+ const oldRuntime = this.runtime;
970
+ this.runtime = newRuntime;
971
+ this.cwd = abs;
972
+ this.unsubscribe?.();
973
+ this.unsubscribe = undefined;
974
+ await this.bindSession();
975
+ await oldRuntime.dispose().catch(() => { });
976
+ for (const d of newRuntime.diagnostics) {
977
+ if (d.type !== "info") {
978
+ this.emit({ type: "notice", level: d.type, text: d.message });
979
+ }
980
+ }
981
+ this.emit({
982
+ type: "notice",
983
+ level: "info",
984
+ text: `已切换到工作目录:${abs}`,
985
+ });
986
+ void this.refreshSessions();
987
+ void this.listFiles(undefined);
988
+ }
989
+ catch (err) {
990
+ this.emit({
991
+ type: "notice",
992
+ level: "error",
993
+ text: `切换工作目录失败:${err.message}`,
994
+ });
995
+ }
996
+ this.flushSnapshot();
997
+ }
998
+ /** List models that have valid authentication configured. */
999
+ async listModels() {
1000
+ try {
1001
+ const mr = this.runtime.services.modelRuntime;
1002
+ const available = await mr.getAvailable();
1003
+ const models = available.map((m) => ({
1004
+ id: `${m.provider}/${m.id}`,
1005
+ name: m.name,
1006
+ provider: m.provider,
1007
+ reasoning: m.reasoning,
1008
+ }));
1009
+ this.emit({ type: "models", models });
1010
+ }
1011
+ catch (err) {
1012
+ this.emit({
1013
+ type: "notice",
1014
+ level: "error",
1015
+ text: `获取模型列表失败:${err.message}`,
1016
+ });
1017
+ }
1018
+ }
1019
+ /** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5"). */
1020
+ async setModel(modelId) {
1021
+ try {
1022
+ const mr = this.runtime.services.modelRuntime;
1023
+ const slash = modelId.indexOf("/");
1024
+ if (slash <= 0 || slash === modelId.length - 1) {
1025
+ throw new Error(`无效的模型 ID:${modelId}`);
1026
+ }
1027
+ const provider = modelId.slice(0, slash);
1028
+ const id = modelId.slice(slash + 1);
1029
+ const model = mr.getModel(provider, id);
1030
+ if (!model)
1031
+ throw new Error(`模型不存在:${modelId}`);
1032
+ await this.session.setModel(model);
1033
+ }
1034
+ catch (err) {
1035
+ this.emit({
1036
+ type: "notice",
1037
+ level: "error",
1038
+ text: `切换模型失败:${err.message}`,
1039
+ });
1040
+ }
1041
+ this.flushSnapshot();
1042
+ }
1043
+ /** Set the thinking level for future turns. */
1044
+ setThinking(level) {
1045
+ try {
1046
+ this.session.setThinkingLevel(level);
1047
+ }
1048
+ catch (err) {
1049
+ this.emit({
1050
+ type: "notice",
1051
+ level: "error",
1052
+ text: `切换思考强度失败:${err.message}`,
1053
+ });
1054
+ }
1055
+ this.flushSnapshot();
1056
+ }
1057
+ cycleThinking() {
1058
+ try {
1059
+ this.session.cycleThinkingLevel();
1060
+ }
1061
+ catch (err) {
1062
+ this.emit({
1063
+ type: "notice",
1064
+ level: "error",
1065
+ text: `切换思考强度失败:${err.message}`,
1066
+ });
1067
+ }
1068
+ this.flushSnapshot();
1069
+ }
1070
+ /** Push the user command list (.pi/commands.json) to the client. */
1071
+ async listCommands() {
1072
+ const { commands, path, warning } = await loadCommands(this.workspaceRoot);
1073
+ if (warning) {
1074
+ this.emit({ type: "notice", level: "warning", text: warning });
1075
+ }
1076
+ this.emit({ type: "commands", commands, path });
1077
+ }
1078
+ /** Persist the user command list (.pi/commands.json). */
1079
+ async saveCommands(commands) {
1080
+ const { path, error } = await saveCommandsFile(this.workspaceRoot, commands);
1081
+ if (error) {
1082
+ this.emit({ type: "notice", level: "error", text: error });
1083
+ return;
1084
+ }
1085
+ this.emit({ type: "commands", commands, path });
1086
+ this.emit({ type: "notice", level: "info", text: `命令已保存:${path}` });
1087
+ }
1088
+ async dispose() {
1089
+ this.disposed = true;
1090
+ this.terminals.killAll();
1091
+ if (this.snapshotTimer) {
1092
+ clearTimeout(this.snapshotTimer);
1093
+ this.snapshotTimer = null;
1094
+ }
1095
+ if (this.sessionsTimer) {
1096
+ clearTimeout(this.sessionsTimer);
1097
+ this.sessionsTimer = null;
1098
+ }
1099
+ if (this.widgetsTimer) {
1100
+ clearInterval(this.widgetsTimer);
1101
+ this.widgetsTimer = null;
1102
+ }
1103
+ this.webUi.dispose();
1104
+ this.unsubscribe?.();
1105
+ this.unsubscribe = undefined;
1106
+ try {
1107
+ await this.runtime.dispose();
1108
+ }
1109
+ catch {
1110
+ // best effort
1111
+ }
1112
+ }
1113
+ }
1114
+ export class AgentService {
1115
+ cwd;
1116
+ sessionDirRoot;
1117
+ clients = new Map();
1118
+ pending = new Map();
1119
+ constructor(cwd, sessionDirRoot) {
1120
+ this.cwd = cwd;
1121
+ this.sessionDirRoot = sessionDirRoot;
1122
+ }
1123
+ /** Get or create the session for a client, racing attach calls safely. */
1124
+ async attach(clientId, send) {
1125
+ let cs = this.clients.get(clientId);
1126
+ if (!cs) {
1127
+ const inflight = this.pending.get(clientId);
1128
+ if (inflight) {
1129
+ cs = await inflight;
1130
+ }
1131
+ else {
1132
+ const creating = ClientSession.create(clientId, this.cwd, join(this.sessionDirRoot, sanitizeId(clientId))).finally(() => {
1133
+ this.pending.delete(clientId);
1134
+ });
1135
+ this.pending.set(clientId, creating);
1136
+ cs = await creating;
1137
+ this.clients.set(clientId, cs);
1138
+ }
1139
+ }
1140
+ cs.attachSink(send);
1141
+ return cs;
1142
+ }
1143
+ /** Remove a socket from a client's broadcast set (called on socket close). */
1144
+ detach(clientId, send) {
1145
+ this.clients.get(clientId)?.detachSink(send);
1146
+ }
1147
+ get(clientId) {
1148
+ return this.clients.get(clientId);
1149
+ }
1150
+ async disposeAll() {
1151
+ const all = [...this.clients.values()];
1152
+ this.clients.clear();
1153
+ await Promise.all(all.map((cs) => cs.dispose()));
1154
+ }
1155
+ }