pipane 0.1.4 → 0.1.6

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.
Files changed (99) hide show
  1. package/README.md +34 -1
  2. package/THIRD_PARTY_NOTICES.md +29 -0
  3. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/client/assets/index-Dl_wdLZH.css +1 -0
  63. package/dist/client/assets/index-hNqbnG06.js +2482 -0
  64. package/dist/client/index.html +2 -2
  65. package/dist/server/server/bundled-extensions.js +6 -0
  66. package/dist/server/server/extension-status.js +31 -0
  67. package/dist/server/server/local-settings.js +15 -0
  68. package/dist/server/server/pi-runtime.js +1 -1
  69. package/dist/server/server/process-pool.js +177 -120
  70. package/dist/server/server/rest-api.js +7 -74
  71. package/dist/server/server/server.js +37 -49
  72. package/dist/server/server/session-actor.js +202 -0
  73. package/dist/server/server/session-index.js +18 -2
  74. package/dist/server/server/session-jsonl.js +49 -6
  75. package/dist/server/server/session-registry.js +67 -0
  76. package/dist/server/server/worktree-name.js +39 -0
  77. package/dist/server/server/ws-handler.js +630 -342
  78. package/dist/server/shared/jsonl-sync.js +114 -3
  79. package/dist/server/shared/rpc-timeouts.js +4 -0
  80. package/dist/server/shared/thinking-levels.js +94 -0
  81. package/dist/server/shared/ws-protocol.js +1 -0
  82. package/extensions/canvas.ts +1 -1
  83. package/package.json +26 -14
  84. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
  85. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
  86. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
  87. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
  88. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
  89. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
  90. package/dist/client/assets/index-Bf_69wyB.js +0 -3
  91. package/dist/client/assets/index-CUE9pgq9.css +0 -1
  92. package/dist/client/assets/index-CekVLaL6.js +0 -1
  93. package/dist/client/assets/index-Cp2vV6HJ.js +0 -4433
  94. package/dist/client/assets/index-D14II7Hd.js +0 -45
  95. package/dist/client/assets/index-D16nGmo3.js +0 -131
  96. package/dist/server/server/attached-session.js +0 -209
  97. package/dist/server/server/load-trace-store.js +0 -48
  98. package/dist/server/server/session-lifecycle.js +0 -155
  99. package/patches/@mariozechner+pi-web-ui+0.55.3.patch +0 -3437
@@ -17,8 +17,8 @@
17
17
  if (colorTheme && colorTheme !== "default") root.setAttribute("data-color-theme", colorTheme);
18
18
  })();
19
19
  </script>
20
- <script type="module" crossorigin src="/assets/index-Cp2vV6HJ.js"></script>
21
- <link rel="stylesheet" crossorigin href="/assets/index-CUE9pgq9.css">
20
+ <script type="module" crossorigin src="/assets/index-hNqbnG06.js"></script>
21
+ <link rel="stylesheet" crossorigin href="/assets/index-Dl_wdLZH.css">
22
22
  </head>
23
23
  <body>
24
24
  <div id="app">
@@ -0,0 +1,6 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ /** Absolute entrypoint for the bundled common-subscription usage extension. */
4
+ export function resolveUsageExtensionPath() {
5
+ return require.resolve("@sreetej510/pi-usage/dist/index.js");
6
+ }
@@ -0,0 +1,31 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ export const PROVIDER_USAGE_STATUS_KEY = "provider-usage";
3
+ const MAX_STATUS_KEY_LENGTH = 128;
4
+ const MAX_STATUS_TEXT_LENGTH = 512;
5
+ const KEY_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
6
+ const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
7
+ export function isValidExtensionStatusKey(value) {
8
+ return typeof value === "string"
9
+ && value.length > 0
10
+ && value.length <= MAX_STATUS_KEY_LENGTH
11
+ && !KEY_CONTROL_CHARACTER.test(value);
12
+ }
13
+ export function normalizeExtensionStatusText(value) {
14
+ return stripVTControlCharacters(value)
15
+ .replace(/[\r\n\t]+/g, " ")
16
+ .replace(CONTROL_CHARACTERS, "")
17
+ .replace(/\s{2,}/g, " ")
18
+ .trim()
19
+ .slice(0, MAX_STATUS_TEXT_LENGTH);
20
+ }
21
+ export function extensionStatusSnapshot(statuses) {
22
+ return Object.fromEntries(statuses ?? []);
23
+ }
24
+ /** Identify successful pi-usage status text; transient states such as "checking" do not match. */
25
+ export function providerForUsageStatus(text) {
26
+ if (/^codex(?:\s|$)/i.test(text))
27
+ return "codex";
28
+ if (/^(?:anthropic|claude)(?:\s|$)/i.test(text))
29
+ return "anthropic";
30
+ return undefined;
31
+ }
@@ -343,6 +343,20 @@ function validateSettingsObject(value, errors) {
343
343
  toolCollapse = { keepOpen: tc.keepOpen };
344
344
  }
345
345
  }
346
+ // Validate messages (optional)
347
+ let messagesConfig;
348
+ if (value.messages !== undefined) {
349
+ const mc = value.messages;
350
+ if (!mc || typeof mc !== "object" || Array.isArray(mc)) {
351
+ errors.push("messages must be an object");
352
+ }
353
+ else if (typeof mc.initialCount !== "number" || !Number.isFinite(mc.initialCount) || mc.initialCount < 0 || Math.floor(mc.initialCount) !== mc.initialCount) {
354
+ errors.push("messages.initialCount must be a non-negative integer");
355
+ }
356
+ else {
357
+ messagesConfig = { initialCount: mc.initialCount };
358
+ }
359
+ }
346
360
  if (errors.length > 0)
347
361
  return null;
348
362
  return {
@@ -362,6 +376,7 @@ function validateSettingsObject(value, errors) {
362
376
  showTokenUsage,
363
377
  },
364
378
  ...(toolCollapse ? { toolCollapse } : {}),
379
+ ...(messagesConfig ? { messages: messagesConfig } : {}),
365
380
  };
366
381
  }
367
382
  function compileFilters(settings) {
@@ -24,7 +24,7 @@ export function makePiNotFoundMessage(command) {
24
24
  }
25
25
  export async function installPiGlobal(deps = {}) {
26
26
  const spawnSync = deps.spawnSync ?? childSpawnSync;
27
- const result = spawnSync("npm", ["install", "-g", "@mariozechner/pi-coding-agent"], {
27
+ const result = spawnSync("npm", ["install", "-g", "@earendil-works/pi-coding-agent"], {
28
28
  stdio: "inherit",
29
29
  env: process.env,
30
30
  });
@@ -1,29 +1,43 @@
1
1
  /**
2
- * CWD-aware process pool for pi RPC processes.
2
+ * CWD-aware, leased process pool for pi RPC processes.
3
3
  *
4
- * Processes are grouped by cwd. When acquiring a process for a session,
5
- * the caller provides the session's cwd and gets a process that was
6
- * spawned in that directory. This ensures bash/read/edit/write tools
7
- * operate in the correct project directory.
8
- *
9
- * Features:
10
- * - Lazy spawning per-cwd with configurable pre-warming
11
- * - Readiness check via get_state RPC (replaces setTimeout(500) hack)
12
- * - Dead process cleanup on exit
13
- * - Global process cap
4
+ * The pool is the sole owner of process availability. Callers receive an
5
+ * idempotent lease instead of passing a separate busy set, so a process is
6
+ * reserved atomically before control returns to asynchronous application code.
14
7
  */
15
8
  import { spawn } from "node:child_process";
16
9
  import * as readline from "node:readline";
17
10
  import { existsSync } from "node:fs";
11
+ /** Exclusive ownership of one pooled process. */
12
+ export class RpcProcessLease {
13
+ process;
14
+ pool;
15
+ _released = false;
16
+ constructor(pool, process) {
17
+ this.pool = pool;
18
+ this.process = process;
19
+ }
20
+ get released() { return this._released; }
21
+ release() {
22
+ this.pool.releaseLease(this);
23
+ }
24
+ /** @internal */
25
+ markReleased() {
26
+ this._released = true;
27
+ }
28
+ }
18
29
  export class ProcessPool {
19
- /** All live processes, keyed by cwd */
20
30
  pools = new Map();
31
+ leases = new Map();
32
+ decommissioning = new Set();
33
+ finalizedProcesses = new WeakSet();
21
34
  nextProcId = 0;
22
35
  spawnConfig;
23
36
  maxProcesses;
24
37
  prewarmCount;
25
38
  rpcTimeout;
26
39
  onProcessExit;
40
+ eventListeners = new Set();
27
41
  constructor(spawnConfig, options) {
28
42
  this.spawnConfig = spawnConfig;
29
43
  this.maxProcesses = options?.maxProcesses ?? 6;
@@ -31,34 +45,50 @@ export class ProcessPool {
31
45
  this.rpcTimeout = options?.rpcTimeout ?? 30000;
32
46
  this.onProcessExit = options?.onProcessExit;
33
47
  }
34
- /** Get the total count of live processes across all cwds */
35
48
  get totalProcesses() {
36
49
  let count = 0;
37
50
  for (const procs of this.pools.values()) {
38
- count += procs.filter((p) => p.process.exitCode === null).length;
51
+ count += procs.filter((proc) => proc.process.exitCode === null).length;
39
52
  }
40
53
  return count;
41
54
  }
42
- /** Get all processes (for debug endpoint) */
55
+ subscribeEvents(listener) {
56
+ this.eventListeners.add(listener);
57
+ return () => this.eventListeners.delete(listener);
58
+ }
59
+ emitEvent(proc, event) {
60
+ for (const listener of this.eventListeners) {
61
+ try {
62
+ listener(proc, event);
63
+ }
64
+ catch (err) {
65
+ console.error(`[pool] pi#${proc.id} event listener failed:`, err);
66
+ }
67
+ }
68
+ }
43
69
  getAllProcesses() {
44
70
  const all = [];
45
- for (const procs of this.pools.values()) {
71
+ for (const procs of this.pools.values())
46
72
  all.push(...procs);
47
- }
48
73
  return all;
49
74
  }
50
- /**
51
- * Spawn a new RPC process for the given cwd.
52
- * Does not wait for readiness — call waitForReady() separately.
53
- */
75
+ isLeased(proc) {
76
+ return this.leases.has(proc);
77
+ }
78
+ isDecommissioning(proc) {
79
+ return this.decommissioning.has(proc);
80
+ }
81
+ getRecentStderr(proc, maxLines = 12) {
82
+ if (!proc?.recentStderr?.length)
83
+ return [];
84
+ return proc.recentStderr.slice(Math.max(0, proc.recentStderr.length - maxLines));
85
+ }
54
86
  spawn(cwd) {
55
87
  if (!existsSync(cwd)) {
56
88
  throw new Error(`Cannot spawn pi process: directory does not exist: ${cwd}`);
57
89
  }
58
90
  const procId = ++this.nextProcId;
59
91
  console.log(`[pool] Spawning pi process #${procId} (cwd: ${cwd})...`);
60
- // Strip NODE_ENV from the child environment so tools spawned by pi
61
- // (e.g. bash) don't inherit pipane's "production" setting.
62
92
  const { NODE_ENV: _, ...parentEnv } = process.env;
63
93
  const baseArgs = typeof this.spawnConfig.baseArgs === "function"
64
94
  ? this.spawnConfig.baseArgs()
@@ -71,8 +101,18 @@ export class ProcessPool {
71
101
  env: { ...parentEnv, ...(this.spawnConfig.env ?? {}) },
72
102
  stdio: ["pipe", "pipe", "pipe"],
73
103
  });
104
+ const recentStderr = [];
74
105
  child.stderr?.on("data", (data) => {
75
- process.stderr.write(`[pi#${procId}] ${data.toString()}`);
106
+ const text = data.toString();
107
+ process.stderr.write(`[pi#${procId}] ${text}`);
108
+ for (const line of text.split(/\r?\n/)) {
109
+ const trimmed = line.trim();
110
+ if (!trimmed)
111
+ continue;
112
+ recentStderr.push(trimmed);
113
+ if (recentStderr.length > 60)
114
+ recentStderr.splice(0, recentStderr.length - 60);
115
+ }
76
116
  });
77
117
  const rl = readline.createInterface({ input: child.stdout, terminal: false });
78
118
  const proc = {
@@ -83,8 +123,8 @@ export class ProcessPool {
83
123
  pendingRequests: new Map(),
84
124
  requestId: 0,
85
125
  lastResponseTime: Date.now(),
126
+ recentStderr,
86
127
  };
87
- // Set up response handler
88
128
  rl.on("line", (line) => {
89
129
  let data;
90
130
  try {
@@ -93,51 +133,25 @@ export class ProcessPool {
93
133
  catch {
94
134
  return;
95
135
  }
96
- if (data.type === "response" && data.id && proc.pendingRequests.has(data.id)) {
97
- const pending = proc.pendingRequests.get(data.id);
98
- proc.pendingRequests.delete(data.id);
99
- proc.lastResponseTime = Date.now();
100
- pending.resolve(data);
136
+ if (data.type === "response") {
137
+ if (data.id && proc.pendingRequests.has(data.id)) {
138
+ const pending = proc.pendingRequests.get(data.id);
139
+ proc.pendingRequests.delete(data.id);
140
+ proc.lastResponseTime = Date.now();
141
+ pending.resolve(data);
142
+ }
143
+ return;
101
144
  }
145
+ this.emitEvent(proc, data);
102
146
  });
103
147
  child.on("error", (err) => {
104
148
  console.error(`[pool] pi#${proc.id} spawn error: ${err.message}`);
105
- // Remove from pool
106
- const poolForCwd = this.pools.get(cwd);
107
- if (poolForCwd) {
108
- const idx = poolForCwd.indexOf(proc);
109
- if (idx !== -1)
110
- poolForCwd.splice(idx, 1);
111
- if (poolForCwd.length === 0)
112
- this.pools.delete(cwd);
113
- }
114
- // Reject any pending requests
115
- for (const [, pending] of proc.pendingRequests) {
116
- pending.reject(new Error(`pi process #${proc.id} failed to spawn: ${err.message}`));
117
- }
118
- proc.pendingRequests.clear();
119
- this.onProcessExit?.(proc);
149
+ this.finalizeProcess(proc, new Error(`pi process #${proc.id} failed to spawn: ${err.message}`));
120
150
  });
121
151
  child.on("exit", (code) => {
122
152
  console.log(`[pool] pi#${proc.id} exited (code ${code})`);
123
- // Remove from pool
124
- const poolForCwd = this.pools.get(cwd);
125
- if (poolForCwd) {
126
- const idx = poolForCwd.indexOf(proc);
127
- if (idx !== -1)
128
- poolForCwd.splice(idx, 1);
129
- if (poolForCwd.length === 0)
130
- this.pools.delete(cwd);
131
- }
132
- // Reject any pending requests
133
- for (const [, pending] of proc.pendingRequests) {
134
- pending.reject(new Error(`pi process #${proc.id} exited unexpectedly (code ${code})`));
135
- }
136
- proc.pendingRequests.clear();
137
- // Notify caller
138
- this.onProcessExit?.(proc);
153
+ this.finalizeProcess(proc, new Error(`pi process #${proc.id} exited unexpectedly (code ${code})`));
139
154
  });
140
- // Add to pool
141
155
  let poolForCwd = this.pools.get(cwd);
142
156
  if (!poolForCwd) {
143
157
  poolForCwd = [];
@@ -147,10 +161,28 @@ export class ProcessPool {
147
161
  console.log(`[pool] pi#${procId} spawned (total: ${this.totalProcesses})`);
148
162
  return proc;
149
163
  }
150
- /**
151
- * Wait for a process to be ready by sending a get_state RPC.
152
- * Falls back to a timeout if the process doesn't respond.
153
- */
164
+ finalizeProcess(proc, error) {
165
+ if (this.finalizedProcesses.has(proc))
166
+ return;
167
+ this.finalizedProcesses.add(proc);
168
+ const poolForCwd = this.pools.get(proc.cwd);
169
+ if (poolForCwd) {
170
+ const index = poolForCwd.indexOf(proc);
171
+ if (index !== -1)
172
+ poolForCwd.splice(index, 1);
173
+ if (poolForCwd.length === 0)
174
+ this.pools.delete(proc.cwd);
175
+ }
176
+ const lease = this.leases.get(proc);
177
+ if (lease)
178
+ lease.markReleased();
179
+ this.leases.delete(proc);
180
+ this.decommissioning.delete(proc);
181
+ for (const pending of proc.pendingRequests.values())
182
+ pending.reject(error);
183
+ proc.pendingRequests.clear();
184
+ this.onProcessExit?.(proc);
185
+ }
154
186
  async waitForReady(proc, timeoutMs = 5000) {
155
187
  try {
156
188
  await this.sendRpc(proc, { type: "get_state" }, timeoutMs);
@@ -160,84 +192,109 @@ export class ProcessPool {
160
192
  return false;
161
193
  }
162
194
  }
163
- /**
164
- * Get an idle process for the given cwd, or spawn one.
165
- * An "idle" process is one that's alive and not in the busySet.
166
- */
167
- acquire(cwd, busySet) {
195
+ /** Atomically reserve a matching process, spawning one when capacity allows. */
196
+ acquire(cwd) {
168
197
  const poolForCwd = this.pools.get(cwd);
169
- if (poolForCwd) {
170
- const idle = poolForCwd.find((p) => p.process.exitCode === null && !busySet.has(p));
171
- if (idle)
172
- return idle;
173
- }
174
- // Check global cap
175
- if (this.totalProcesses >= this.maxProcesses) {
198
+ const idle = poolForCwd?.find((proc) => this.isAvailable(proc));
199
+ if (idle)
200
+ return this.reserve(idle);
201
+ if (this.totalProcesses >= this.maxProcesses)
176
202
  return null;
177
- }
178
- return this.spawn(cwd);
203
+ return this.reserve(this.spawn(cwd));
179
204
  }
180
- /**
181
- * Get any live process (idle preferred). For model queries that don't
182
- * need a specific cwd.
183
- */
184
- getAny(busySet) {
185
- // Prefer idle
205
+ /** Reserve any idle live process, regardless of cwd. */
206
+ acquireAny() {
186
207
  for (const procs of this.pools.values()) {
187
- const idle = procs.find((p) => p.process.exitCode === null && !busySet.has(p));
208
+ const idle = procs.find((proc) => this.isAvailable(proc));
188
209
  if (idle)
189
- return idle;
190
- }
191
- // Fall back to any live process
192
- for (const procs of this.pools.values()) {
193
- const live = procs.find((p) => p.process.exitCode === null);
194
- if (live)
195
- return live;
210
+ return this.reserve(idle);
196
211
  }
197
212
  return null;
198
213
  }
199
- /**
200
- * Evict one idle process from a different cwd to free capacity.
201
- * Returns the evicted process, or null if none can be evicted.
202
- */
203
- evictIdleDifferentCwd(targetCwd, busySet) {
214
+ isAvailable(proc) {
215
+ return proc.process.exitCode === null
216
+ && !this.leases.has(proc)
217
+ && !this.decommissioning.has(proc);
218
+ }
219
+ reserve(proc) {
220
+ if (!this.isAvailable(proc))
221
+ throw new Error(`pi process #${proc.id} is not available`);
222
+ const lease = new RpcProcessLease(this, proc);
223
+ this.leases.set(proc, lease);
224
+ return lease;
225
+ }
226
+ /** @internal Called only by RpcProcessLease.release(). */
227
+ releaseLease(lease) {
228
+ if (lease.released)
229
+ return;
230
+ const proc = lease.process;
231
+ if (this.leases.get(proc) !== lease) {
232
+ lease.markReleased();
233
+ return;
234
+ }
235
+ this.leases.delete(proc);
236
+ lease.markReleased();
237
+ if (this.decommissioning.has(proc) && proc.process.exitCode === null && !proc.process.killed) {
238
+ // Keep the marker until the exit event removes the process. Otherwise a
239
+ // concurrent acquisition could lease the dying process before SIGTERM lands.
240
+ console.log(`[pool] Decommissioning pi#${proc.id} after completed operation`);
241
+ proc.process.kill("SIGTERM");
242
+ }
243
+ }
244
+ decommission(proc) {
245
+ if (proc.process.exitCode !== null || this.decommissioning.has(proc))
246
+ return "ignored";
247
+ this.decommissioning.add(proc);
248
+ if (this.leases.has(proc))
249
+ return "draining";
250
+ proc.process.kill("SIGTERM");
251
+ return "killed";
252
+ }
253
+ /** Evict an idle process from a different cwd to free capacity. */
254
+ evictIdleDifferentCwd(targetCwd) {
204
255
  for (const [cwd, procs] of this.pools) {
205
256
  if (cwd === targetCwd)
206
257
  continue;
207
- const victim = procs.find((p) => p.process.exitCode === null && !busySet.has(p));
258
+ const victim = procs.find((proc) => this.isAvailable(proc));
208
259
  if (!victim)
209
260
  continue;
210
261
  console.log(`[pool] Evicting idle pi#${victim.id} from cwd ${cwd} to make room for ${targetCwd}`);
211
- victim.process.kill("SIGTERM");
262
+ this.decommission(victim);
212
263
  return victim;
213
264
  }
214
265
  return null;
215
266
  }
216
- /**
217
- * Pre-warm the pool with processes for the given cwd.
218
- */
219
- /**
220
- * Pre-warm the pool with processes for the given cwd.
221
- * Spawns are staggered: each process must be ready (via get_state RPC)
222
- * before the next one is spawned. This prevents lock contention on
223
- * shared resources like auth.json during startup.
224
- */
267
+ /** Immediately kill one process while keeping it unavailable until exit. */
268
+ forceKill(proc) {
269
+ if (proc.process.exitCode !== null)
270
+ return false;
271
+ this.decommissioning.add(proc);
272
+ return proc.process.kill("SIGKILL");
273
+ }
274
+ /** Kill idle processes now and mark leased processes for kill on release. */
275
+ decommissionAll() {
276
+ let killed = 0;
277
+ let draining = 0;
278
+ for (const proc of this.getAllProcesses()) {
279
+ const result = this.decommission(proc);
280
+ if (result === "killed")
281
+ killed += 1;
282
+ if (result === "draining")
283
+ draining += 1;
284
+ }
285
+ return { killed, draining };
286
+ }
225
287
  async prewarm(cwd) {
226
- const existing = this.pools.get(cwd)?.filter((p) => p.process.exitCode === null).length ?? 0;
288
+ const existing = this.pools.get(cwd)?.filter((proc) => proc.process.exitCode === null).length ?? 0;
227
289
  const needed = Math.min(this.prewarmCount, this.maxProcesses) - existing;
228
290
  for (let i = 0; i < needed; i++) {
229
291
  if (this.totalProcesses >= this.maxProcesses)
230
292
  break;
231
293
  const proc = this.spawn(cwd);
232
- if (i < needed - 1) {
233
- // Wait for this process to be ready before spawning the next one
294
+ if (i < needed - 1)
234
295
  await this.waitForReady(proc);
235
- }
236
296
  }
237
297
  }
238
- /**
239
- * Send an RPC command to a process and wait for a response.
240
- */
241
298
  sendRpc(proc, command, timeoutMs) {
242
299
  if (!proc.process || proc.process.exitCode !== null) {
243
300
  return Promise.reject(new Error("RPC process is dead"));
@@ -248,6 +305,9 @@ export class ProcessPool {
248
305
  return new Promise((resolve, reject) => {
249
306
  const timer = setTimeout(() => {
250
307
  proc.pendingRequests.delete(id);
308
+ // A timed-out RPC may still be executing. Never return that process to
309
+ // the idle pool where a new session command could overlap with it.
310
+ this.decommission(proc);
251
311
  reject(new Error(`Timeout waiting for RPC response to ${command.type}`));
252
312
  }, timeout);
253
313
  proc.pendingRequests.set(id, {
@@ -263,9 +323,6 @@ export class ProcessPool {
263
323
  proc.process.stdin.write(JSON.stringify(fullCommand) + "\n");
264
324
  });
265
325
  }
266
- /**
267
- * Send an RPC command and throw if it fails.
268
- */
269
326
  async sendRpcChecked(proc, command) {
270
327
  const response = await this.sendRpc(proc, command);
271
328
  if (!response?.success) {
@@ -6,7 +6,7 @@
6
6
  import { existsSync, readFileSync, readdirSync, watchFile } from "node:fs";
7
7
  import { unlink } from "node:fs/promises";
8
8
  import path from "node:path";
9
- import { buildSessionContext, parseSessionEntries } from "@mariozechner/pi-coding-agent";
9
+ import { parseSessionEntries } from "@earendil-works/pi-coding-agent";
10
10
  import { SessionIndex } from "./session-index.js";
11
11
  import { LocalSettingsStore } from "./local-settings.js";
12
12
  let localSettingsStore;
@@ -37,60 +37,11 @@ async function readJsonBody(req) {
37
37
  return JSON.parse(raw || "{}");
38
38
  }
39
39
  export function registerRestApi(app, options = {}) {
40
- const traceStore = options.traceStore;
41
40
  localSettingsStore = options.localSettingsStore ?? new LocalSettingsStore();
42
41
  sessionIndex = new SessionIndex({
43
42
  cwdDisplayFormatter: (cwd) => localSettingsStore.formatCwdTitle(cwd),
44
43
  });
45
44
  startLocalSettingsWatcher(options.onLocalSettingsReloaded);
46
- app.post("/api/debug/load-trace/event", async (req, res) => {
47
- try {
48
- if (!traceStore) {
49
- res.status(404).json({ error: "Tracing disabled" });
50
- return;
51
- }
52
- const chunks = [];
53
- for await (const chunk of req)
54
- chunks.push(chunk);
55
- const body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
56
- const traceId = String(body.traceId || req.headers["x-pi-trace-id"] || "");
57
- if (!traceId) {
58
- res.status(400).json({ error: "Missing traceId" });
59
- return;
60
- }
61
- traceStore.record(traceId, {
62
- ts: new Date().toISOString(),
63
- source: "frontend",
64
- kind: body.durationMs != null ? "span" : "instant",
65
- name: String(body.name || "frontend_event"),
66
- durationMs: typeof body.durationMs === "number" ? body.durationMs : undefined,
67
- attrs: body.attrs && typeof body.attrs === "object" ? body.attrs : undefined,
68
- });
69
- res.json({ ok: true });
70
- }
71
- catch (err) {
72
- res.status(500).json({ error: err.message });
73
- }
74
- });
75
- app.get("/api/debug/load-trace/latest", (_req, res) => {
76
- if (!traceStore) {
77
- res.status(404).json({ error: "Tracing disabled" });
78
- return;
79
- }
80
- res.json({ traces: traceStore.getLatest() });
81
- });
82
- app.get("/api/debug/load-trace/:traceId", (req, res) => {
83
- if (!traceStore) {
84
- res.status(404).json({ error: "Tracing disabled" });
85
- return;
86
- }
87
- const trace = traceStore.get(req.params.traceId);
88
- if (!trace) {
89
- res.status(404).json({ error: "Trace not found" });
90
- return;
91
- }
92
- res.json(trace);
93
- });
94
45
  app.get("/api/sessions", async (_req, res) => {
95
46
  try {
96
47
  res.json(await sessionIndex.listSessions());
@@ -175,32 +126,14 @@ export function registerRestApi(app, options = {}) {
175
126
  res.status(404).json({ error: "Session not found" });
176
127
  return;
177
128
  }
178
- await unlink(sessionPath);
179
- res.json({ success: true });
180
- }
181
- catch (err) {
182
- res.status(500).json({ error: err.message });
183
- }
184
- });
185
- app.get("/api/sessions/messages", (req, res) => {
186
- try {
187
- const sessionPath = req.query.path;
188
- if (!sessionPath || !sessionPath.endsWith(".jsonl")) {
189
- res.status(400).json({ error: "Missing or invalid session path" });
190
- return;
129
+ const remove = () => unlink(sessionPath);
130
+ if (options.runSessionMutation) {
131
+ await options.runSessionMutation(sessionPath, "delete session", remove);
191
132
  }
192
- if (!existsSync(sessionPath)) {
193
- res.status(404).json({ error: "Session file not found" });
194
- return;
133
+ else {
134
+ await remove();
195
135
  }
196
- const content = readFileSync(sessionPath, "utf8");
197
- const entries = parseSessionEntries(content);
198
- const context = buildSessionContext(entries);
199
- res.json({
200
- messages: context.messages,
201
- model: context.model,
202
- thinkingLevel: context.thinkingLevel,
203
- });
136
+ res.json({ success: true });
204
137
  }
205
138
  catch (err) {
206
139
  res.status(500).json({ error: err.message });