pi-web-ui 0.13.0 → 0.14.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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * AgentService — wraps the pi SDK (@earendil-works/pi-coding-agent) for the web
3
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.
4
+ * own AgentSessionRuntime, but sessions live in the SDK default per-project
5
+ * directory (<agentDir>/sessions/--<cwd>--/) the same transcript files the
6
+ * pi CLI/TUI use — so every conversation of a folder shows up everywhere.
6
7
  *
7
8
  * Streaming model: the SDK emits AgentSessionEvents; we forward lightweight
8
9
  * `tool_delta` messages for live tool output and schedule throttled full-state
@@ -446,10 +447,6 @@ export class WebUIContext {
446
447
  this.pendingDialogs.clear();
447
448
  }
448
449
  }
449
- /** Sanitize a clientId (UUID) for use as a directory name. */
450
- function sanitizeId(id) {
451
- return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80) || "anon";
452
- }
453
450
  const IS_WIN32 = process.platform === "win32";
454
451
  // mac/linux: hide build & dependency noise (original behavior).
455
452
  const IGNORED_ENTRIES = new Set([
@@ -683,9 +680,6 @@ function conversationTitle(session) {
683
680
  export class ClientSession {
684
681
  clientId;
685
682
  cwd;
686
- /** Absolute per-client session directory.
687
- /** Absolute per-client session directory. */
688
- sessionDir;
689
683
  /** pi config dir (auth/models/skills). */
690
684
  agentDir;
691
685
  /** Persisted per-client UI state (last workspace + recent projects). */
@@ -734,22 +728,22 @@ export class ClientSession {
734
728
  disposed = false;
735
729
  /** pi-config readiness check, cached briefly so 60ms snapshots don't hit disk. */
736
730
  piCheckCache = null;
737
- constructor(clientId, cwd, sessionDir, agentDir, stateStore) {
731
+ constructor(clientId, cwd, agentDir, stateStore) {
738
732
  this.clientId = clientId;
739
733
  this.cwd = cwd;
740
- this.sessionDir = sessionDir;
741
734
  this.agentDir = agentDir;
742
735
  this.stateStore = stateStore;
743
736
  }
744
- static async create(clientId, cwd, sessionDir, stateStore) {
737
+ static async create(clientId, cwd, stateStore) {
745
738
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
746
- const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, stateStore);
739
+ const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
747
740
  const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(), {
748
741
  cwd,
749
742
  agentDir,
750
- // Resume the most recent session for this client's private session dir,
751
- // or start a fresh one on first visit.
752
- sessionManager: SessionManager.continueRecent(cwd, sessionDir),
743
+ // Resume the most recent session for this project the SDK default
744
+ // per-project dir (<agentDir>/sessions/--<cwd>--/, shared with the
745
+ // pi CLI/TUI) — or start a fresh one on first visit.
746
+ sessionManager: SessionManager.continueRecent(cwd),
753
747
  });
754
748
  // First conversation = the resumed session; it also seeds the shared
755
749
  // ModelRuntime that every later conversation reuses.
@@ -809,6 +803,7 @@ export class ClientSession {
809
803
  lastMessagesArray: [],
810
804
  queueSteering: 0,
811
805
  queueFollowUp: 0,
806
+ toolStartTimes: new Map(),
812
807
  };
813
808
  }
814
809
  /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
@@ -883,6 +878,52 @@ export class ClientSession {
883
878
  }
884
879
  break;
885
880
  }
881
+ case "tool_execution_start": {
882
+ // Record the moment the tool actually starts so tool_status can
883
+ // report real execution time (vs. time spent waiting on the model).
884
+ conv.toolStartTimes.set(event.toolCallId, Date.now());
885
+ break;
886
+ }
887
+ case "tool_execution_end": {
888
+ const startedAt = conv.toolStartTimes.get(event.toolCallId);
889
+ conv.toolStartTimes.delete(event.toolCallId);
890
+ const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
891
+ // The bash tool does not put its exit code in result.details — on
892
+ // failure it throws "Command exited with code N" and the agent
893
+ // wraps that into the error result text. Try details first (future
894
+ // tools / SDK changes), then parse the error text.
895
+ const details = event.result?.details;
896
+ let exitCode;
897
+ if (typeof details === "object" &&
898
+ details !== null &&
899
+ typeof details.exitCode === "number") {
900
+ exitCode = details.exitCode;
901
+ }
902
+ else if (event.isError) {
903
+ const content = event.result?.content;
904
+ const text = Array.isArray(content)
905
+ ? content
906
+ .map((c) => (typeof c === "object" &&
907
+ c !== null &&
908
+ c.type === "text")
909
+ ? (c.text ?? "")
910
+ : "")
911
+ .join("\n")
912
+ : "";
913
+ const m = text.match(/exited with code (\d+)/);
914
+ if (m)
915
+ exitCode = Number(m[1]);
916
+ }
917
+ this.emit({
918
+ type: "tool_status",
919
+ toolCallId: event.toolCallId,
920
+ toolName: event.toolName,
921
+ isError: event.isError,
922
+ exitCode,
923
+ durationMs,
924
+ });
925
+ break;
926
+ }
886
927
  case "tool_execution_update": {
887
928
  const text = extractPartialText(event.partialResult);
888
929
  if (text) {
@@ -2100,7 +2141,7 @@ export class ClientSession {
2100
2141
  const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
2101
2142
  cwd: this.cwd,
2102
2143
  agentDir: this.agentDir,
2103
- sessionManager: SessionManager.create(this.cwd, this.sessionDir),
2144
+ sessionManager: SessionManager.create(this.cwd),
2104
2145
  });
2105
2146
  const conv = this.makeConversation(runtime);
2106
2147
  this.convs.set(conv.id, conv);
@@ -2206,23 +2247,12 @@ export class ClientSession {
2206
2247
  }
2207
2248
  async pushSessions() {
2208
2249
  try {
2209
- const { resolve } = await import("node:path");
2210
- // The pi CLI/TUI keeps sessions in <agentDir>/sessions/--<cwd-sanitized>--
2211
- // (encoded per working directory). List those too, so the conversation
2212
- // panel shows every conversation of the current folder — not just the
2213
- // ones created in this web UI.
2214
- const safePath = `--${resolve(this.cwd)
2215
- .replace(/^[/\\]/, "")
2216
- .replace(/[/\\:]/g, "-")}--`;
2217
- const tuiSessionDir = join(this.agentDir, "sessions", safePath);
2218
- const [webInfos, tuiInfos] = await Promise.all([
2219
- SessionManager.list(this.cwd, this.sessionDir),
2220
- existsSync(tuiSessionDir)
2221
- ? SessionManager.list(this.cwd, tuiSessionDir).catch(() => [])
2222
- : Promise.resolve([]),
2223
- ]);
2250
+ // Sessions live in the SDK default per-project dir
2251
+ // (<agentDir>/sessions/--<cwd>--/), the same files the pi CLI/TUI
2252
+ // use one listing covers every conversation of the current folder.
2253
+ const infos = await SessionManager.list(this.cwd);
2224
2254
  const sessions = new Map();
2225
- for (const s of webInfos) {
2255
+ for (const s of infos) {
2226
2256
  sessions.set(s.path, {
2227
2257
  path: s.path,
2228
2258
  name: s.name,
@@ -2232,16 +2262,6 @@ export class ClientSession {
2232
2262
  source: "web",
2233
2263
  });
2234
2264
  }
2235
- for (const s of tuiInfos) {
2236
- sessions.set(s.path, {
2237
- path: s.path,
2238
- name: s.name,
2239
- firstMessage: s.firstMessage,
2240
- messageCount: s.messageCount,
2241
- modified: s.modified.getTime(),
2242
- source: "tui",
2243
- });
2244
- }
2245
2265
  const sorted = [...sessions.values()].sort((a, b) => b.modified - a.modified);
2246
2266
  this.emit({ type: "sessions", sessions: sorted });
2247
2267
  }
@@ -2366,7 +2386,7 @@ export class ClientSession {
2366
2386
  const map = new Map();
2367
2387
  for (const p of saved.projects)
2368
2388
  map.set(p.path, p.lastUsed);
2369
- const all = await SessionManager.listAll(this.sessionDir);
2389
+ const all = await SessionManager.listAll();
2370
2390
  for (const s of all) {
2371
2391
  if (s.cwd) {
2372
2392
  const t = s.modified.getTime();
@@ -2655,7 +2675,7 @@ export class ClientSession {
2655
2675
  const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
2656
2676
  cwd: abs,
2657
2677
  agentDir: this.agentDir,
2658
- sessionManager: SessionManager.continueRecent(abs, this.sessionDir),
2678
+ sessionManager: SessionManager.continueRecent(abs),
2659
2679
  });
2660
2680
  const conv = this.makeConversation(newRuntime);
2661
2681
  this.convs.set(conv.id, conv);
@@ -2816,7 +2836,6 @@ export class ClientSession {
2816
2836
  }
2817
2837
  export class AgentService {
2818
2838
  cwd;
2819
- sessionDirRoot;
2820
2839
  clients = new Map();
2821
2840
  pending = new Map();
2822
2841
  stateStore;
@@ -2825,9 +2844,8 @@ export class AgentService {
2825
2844
  * self-update; returns whether the process will restart itself.
2826
2845
  */
2827
2846
  onUpdateReady = undefined;
2828
- constructor(cwd, sessionDirRoot, stateFile) {
2847
+ constructor(cwd, stateFile) {
2829
2848
  this.cwd = cwd;
2830
- this.sessionDirRoot = sessionDirRoot;
2831
2849
  this.stateStore = new ClientStateStore(stateFile);
2832
2850
  }
2833
2851
  /** Get or create the session for a client, racing attach calls safely. */
@@ -2852,7 +2870,8 @@ export class AgentService {
2852
2870
  // gone (unmounted drive / deleted) — fall back to the default
2853
2871
  }
2854
2872
  }
2855
- const creating = ClientSession.create(clientId, cwd, join(this.sessionDirRoot, sanitizeId(clientId)), this.stateStore).finally(() => {
2873
+ // Sessions use the SDK default per-project dir no per-client dir.
2874
+ const creating = ClientSession.create(clientId, cwd, this.stateStore).finally(() => {
2856
2875
  this.pending.delete(clientId);
2857
2876
  });
2858
2877
  this.pending.set(clientId, creating);
@@ -9,7 +9,11 @@
9
9
  * Env:
10
10
  * PORT HTTP port (default 8787)
11
11
  * PI_WEB_CWD workspace the agent operates in (default: process.cwd())
12
- * PI_WEB_DATA_DIR where per-client session dirs are stored (default: <cwd>/.pi-web)
12
+ * PI_WEB_DATA_DIR where per-client UI state is stored (client-state.json,
13
+ * default: <home>/.pi-web). Chat sessions are NOT stored here — they live
14
+ * in the pi agent's global TUI session dir (~/.pi/agent/sessions/--<cwd>--/)
15
+ * via the SDK default, so this web UI, the dev instance, and the pi CLI/TUI
16
+ * all share one conversation list per project.
13
17
  * PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
14
18
  */
15
19
  import { existsSync } from "node:fs";
@@ -18,16 +22,20 @@ import { createServer } from "node:http";
18
22
  import { createConnection } from "node:net";
19
23
  import { spawn } from "node:child_process";
20
24
  import { basename, dirname, join, resolve } from "node:path";
25
+ import { homedir } from "node:os";
21
26
  import { fileURLToPath } from "node:url";
22
27
  import { randomUUID } from "node:crypto";
23
28
  import express from "express";
24
29
  import { WebSocket, WebSocketServer } from "ws";
25
- import { VERSION } from "@earendil-works/pi-coding-agent";
30
+ import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
26
31
  import { AgentService, previewKind, workspacePath } from "./agent-service.js";
27
32
  const PORT = Number(process.env.PORT ?? 8787);
28
33
  const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
29
- const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(CWD, ".pi-web"));
30
- const SESSION_DIR_ROOT = join(DATA_DIR, "sessions");
34
+ const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
35
+ // Root of the SDK default per-project session dirs — chat transcripts live in
36
+ // <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
37
+ // honors PI_CODING_AGENT_DIR).
38
+ const SESSION_DIR_ROOT = join(getAgentDir(), "sessions");
31
39
  const app = express();
32
40
  app.use(express.json({ limit: "10mb" }));
33
41
  app.get("/api/health", (_req, res) => {
@@ -126,7 +134,7 @@ const heartbeatTimer = setInterval(() => {
126
134
  }
127
135
  }
128
136
  }, 10_000);
129
- const service = new AgentService(CWD, SESSION_DIR_ROOT,
137
+ const service = new AgentService(CWD,
130
138
  // Per-client persisted UI state: last-used workspace + recent projects.
131
139
  join(DATA_DIR, "client-state.json"));
132
140
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",