pipane 0.1.5 → 0.1.7

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 (138) hide show
  1. package/README.md +45 -1
  2. package/THIRD_PARTY_NOTICES.md +29 -0
  3. package/bin/pipane-rendezvous.js +21 -0
  4. package/bin/pipane.js +21 -1
  5. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  65. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  66. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  67. package/dist/client/assets/index-DwbBcYUf.js +2 -0
  68. package/dist/client/assets/index-iblQYBAl.css +1 -0
  69. package/dist/client/assets/main-LGItV9Aj.js +2807 -0
  70. package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
  71. package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
  72. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  73. package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
  74. package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
  75. package/dist/client/index.html +2 -2
  76. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  77. package/dist/server/rendezvous/server.js +247 -0
  78. package/dist/server/rendezvous/trust-store.js +432 -0
  79. package/dist/server/server/auth-guard.js +61 -0
  80. package/dist/server/server/backend-connection-authorizer.js +29 -0
  81. package/dist/server/server/backend-identity.js +167 -0
  82. package/dist/server/server/backend-protocol-handler.js +132 -0
  83. package/dist/server/server/backend-trust-store.js +157 -0
  84. package/dist/server/server/backend-webrtc.js +239 -0
  85. package/dist/server/server/bundled-extensions.js +6 -0
  86. package/dist/server/server/conversation-file-access.js +97 -0
  87. package/dist/server/server/extension-status.js +31 -0
  88. package/dist/server/server/frame-connection.js +48 -0
  89. package/dist/server/server/frame-router.js +45 -0
  90. package/dist/server/server/ice-servers.js +24 -0
  91. package/dist/server/server/local-backend-api.js +389 -0
  92. package/dist/server/server/local-settings.js +28 -0
  93. package/dist/server/server/pi-rpc-protocol.js +407 -0
  94. package/dist/server/server/pi-runtime.js +1 -1
  95. package/dist/server/server/process-pool.js +179 -136
  96. package/dist/server/server/rendezvous-client.js +282 -0
  97. package/dist/server/server/rest-api.js +121 -231
  98. package/dist/server/server/server.js +188 -134
  99. package/dist/server/server/session-actor.js +202 -0
  100. package/dist/server/server/session-index.js +111 -18
  101. package/dist/server/server/session-jsonl.js +128 -8
  102. package/dist/server/server/session-path.js +145 -0
  103. package/dist/server/server/session-registry.js +67 -0
  104. package/dist/server/server/update-api.js +28 -0
  105. package/dist/server/server/update-check.js +33 -13
  106. package/dist/server/server/update-manager.js +231 -0
  107. package/dist/server/server/worktree-name.js +124 -0
  108. package/dist/server/server/ws-handler.js +784 -478
  109. package/dist/server/shared/backend-api.js +1 -0
  110. package/dist/server/shared/backend-protocol.js +164 -0
  111. package/dist/server/shared/jsonl-sync.js +41 -0
  112. package/dist/server/shared/node-trust-crypto.js +61 -0
  113. package/dist/server/shared/rendezvous-protocol.js +243 -0
  114. package/dist/server/shared/rpc-timeouts.js +4 -0
  115. package/dist/server/shared/thinking-levels.js +94 -0
  116. package/dist/server/shared/tool-runtime.js +1 -0
  117. package/dist/server/shared/trust-protocol.js +97 -0
  118. package/dist/server/shared/updates.js +4 -0
  119. package/dist/server/shared/ws-protocol.js +473 -0
  120. package/docs/protocol.md +127 -0
  121. package/extensions/canvas.ts +1 -1
  122. package/package.json +42 -17
  123. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
  124. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
  125. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
  126. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
  127. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
  128. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
  129. package/dist/client/assets/index-BtDAXAo1.css +0 -1
  130. package/dist/client/assets/index-CAQqlK61.js +0 -131
  131. package/dist/client/assets/index-CJEeBmBj.js +0 -3
  132. package/dist/client/assets/index-DUEhKmPQ.js +0 -1
  133. package/dist/client/assets/index-XEVKscP-.js +0 -4443
  134. package/dist/client/assets/index-woI4agCy.js +0 -45
  135. package/dist/server/server/attached-session.js +0 -209
  136. package/dist/server/server/load-trace-store.js +0 -48
  137. package/dist/server/server/session-lifecycle.js +0 -155
  138. package/patches/@mariozechner+pi-web-ui+0.55.3.patch +0 -3437
@@ -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
- import * as readline from "node:readline";
17
9
  import { existsSync } from "node:fs";
10
+ import { attachStrictJsonlReader, decodePiRpcLine, encodePiRpcCommand, } from "./pi-rpc-protocol.js";
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,40 +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
- /** Return recent stderr lines for a process (tail). */
75
+ isLeased(proc) {
76
+ return this.leases.has(proc);
77
+ }
78
+ isDecommissioning(proc) {
79
+ return this.decommissioning.has(proc);
80
+ }
51
81
  getRecentStderr(proc, maxLines = 12) {
52
82
  if (!proc?.recentStderr?.length)
53
83
  return [];
54
84
  return proc.recentStderr.slice(Math.max(0, proc.recentStderr.length - maxLines));
55
85
  }
56
- /**
57
- * Spawn a new RPC process for the given cwd.
58
- * Does not wait for readiness — call waitForReady() separately.
59
- */
60
86
  spawn(cwd) {
61
87
  if (!existsSync(cwd)) {
62
88
  throw new Error(`Cannot spawn pi process: directory does not exist: ${cwd}`);
63
89
  }
64
90
  const procId = ++this.nextProcId;
65
91
  console.log(`[pool] Spawning pi process #${procId} (cwd: ${cwd})...`);
66
- // Strip NODE_ENV from the child environment so tools spawned by pi
67
- // (e.g. bash) don't inherit pipane's "production" setting.
68
92
  const { NODE_ENV: _, ...parentEnv } = process.env;
69
93
  const baseArgs = typeof this.spawnConfig.baseArgs === "function"
70
94
  ? this.spawnConfig.baseArgs()
@@ -90,71 +114,46 @@ export class ProcessPool {
90
114
  recentStderr.splice(0, recentStderr.length - 60);
91
115
  }
92
116
  });
93
- const rl = readline.createInterface({ input: child.stdout, terminal: false });
94
117
  const proc = {
95
118
  id: procId,
96
119
  cwd,
97
120
  process: child,
98
- rl,
121
+ stopReadingStdout: () => { },
99
122
  pendingRequests: new Map(),
100
123
  requestId: 0,
101
124
  lastResponseTime: Date.now(),
102
125
  recentStderr,
103
126
  };
104
- // Set up response handler
105
- rl.on("line", (line) => {
106
- let data;
107
- try {
108
- data = JSON.parse(line);
109
- }
110
- catch {
127
+ proc.stopReadingStdout = attachStrictJsonlReader(child.stdout, (line) => {
128
+ const decoded = decodePiRpcLine(line);
129
+ if (!decoded.ok) {
130
+ console.warn(`[pool] pi#${proc.id} ignored invalid RPC output: ${decoded.error.message}`);
111
131
  return;
112
132
  }
113
- if (data.type === "response" && data.id && proc.pendingRequests.has(data.id)) {
114
- const pending = proc.pendingRequests.get(data.id);
115
- proc.pendingRequests.delete(data.id);
133
+ const message = decoded.value;
134
+ if (message.type === "response") {
135
+ const pending = message.id ? proc.pendingRequests.get(message.id) : undefined;
136
+ if (!pending || !message.id)
137
+ return;
138
+ proc.pendingRequests.delete(message.id);
116
139
  proc.lastResponseTime = Date.now();
117
- pending.resolve(data);
140
+ if (message.command !== pending.command) {
141
+ pending.reject(new Error(`Mismatched RPC response for ${pending.command}: received ${message.command}`));
142
+ return;
143
+ }
144
+ pending.resolve(message);
145
+ return;
118
146
  }
147
+ this.emitEvent(proc, message);
119
148
  });
120
149
  child.on("error", (err) => {
121
150
  console.error(`[pool] pi#${proc.id} spawn error: ${err.message}`);
122
- // Remove from pool
123
- const poolForCwd = this.pools.get(cwd);
124
- if (poolForCwd) {
125
- const idx = poolForCwd.indexOf(proc);
126
- if (idx !== -1)
127
- poolForCwd.splice(idx, 1);
128
- if (poolForCwd.length === 0)
129
- this.pools.delete(cwd);
130
- }
131
- // Reject any pending requests
132
- for (const [, pending] of proc.pendingRequests) {
133
- pending.reject(new Error(`pi process #${proc.id} failed to spawn: ${err.message}`));
134
- }
135
- proc.pendingRequests.clear();
136
- this.onProcessExit?.(proc);
151
+ this.finalizeProcess(proc, new Error(`pi process #${proc.id} failed to spawn: ${err.message}`));
137
152
  });
138
153
  child.on("exit", (code) => {
139
154
  console.log(`[pool] pi#${proc.id} exited (code ${code})`);
140
- // Remove from pool
141
- const poolForCwd = this.pools.get(cwd);
142
- if (poolForCwd) {
143
- const idx = poolForCwd.indexOf(proc);
144
- if (idx !== -1)
145
- poolForCwd.splice(idx, 1);
146
- if (poolForCwd.length === 0)
147
- this.pools.delete(cwd);
148
- }
149
- // Reject any pending requests
150
- for (const [, pending] of proc.pendingRequests) {
151
- pending.reject(new Error(`pi process #${proc.id} exited unexpectedly (code ${code})`));
152
- }
153
- proc.pendingRequests.clear();
154
- // Notify caller
155
- this.onProcessExit?.(proc);
155
+ this.finalizeProcess(proc, new Error(`pi process #${proc.id} exited unexpectedly (code ${code})`));
156
156
  });
157
- // Add to pool
158
157
  let poolForCwd = this.pools.get(cwd);
159
158
  if (!poolForCwd) {
160
159
  poolForCwd = [];
@@ -164,10 +163,29 @@ export class ProcessPool {
164
163
  console.log(`[pool] pi#${procId} spawned (total: ${this.totalProcesses})`);
165
164
  return proc;
166
165
  }
167
- /**
168
- * Wait for a process to be ready by sending a get_state RPC.
169
- * Falls back to a timeout if the process doesn't respond.
170
- */
166
+ finalizeProcess(proc, error) {
167
+ if (this.finalizedProcesses.has(proc))
168
+ return;
169
+ this.finalizedProcesses.add(proc);
170
+ const poolForCwd = this.pools.get(proc.cwd);
171
+ if (poolForCwd) {
172
+ const index = poolForCwd.indexOf(proc);
173
+ if (index !== -1)
174
+ poolForCwd.splice(index, 1);
175
+ if (poolForCwd.length === 0)
176
+ this.pools.delete(proc.cwd);
177
+ }
178
+ const lease = this.leases.get(proc);
179
+ if (lease)
180
+ lease.markReleased();
181
+ this.leases.delete(proc);
182
+ this.decommissioning.delete(proc);
183
+ proc.stopReadingStdout();
184
+ for (const pending of proc.pendingRequests.values())
185
+ pending.reject(error);
186
+ proc.pendingRequests.clear();
187
+ this.onProcessExit?.(proc);
188
+ }
171
189
  async waitForReady(proc, timeoutMs = 5000) {
172
190
  try {
173
191
  await this.sendRpc(proc, { type: "get_state" }, timeoutMs);
@@ -177,116 +195,141 @@ export class ProcessPool {
177
195
  return false;
178
196
  }
179
197
  }
180
- /**
181
- * Get an idle process for the given cwd, or spawn one.
182
- * An "idle" process is one that's alive and not in the busySet.
183
- */
184
- acquire(cwd, busySet) {
198
+ /** Atomically reserve a matching process, spawning one when capacity allows. */
199
+ acquire(cwd) {
185
200
  const poolForCwd = this.pools.get(cwd);
186
- if (poolForCwd) {
187
- const idle = poolForCwd.find((p) => p.process.exitCode === null && !busySet.has(p));
188
- if (idle)
189
- return idle;
190
- }
191
- // Check global cap
192
- if (this.totalProcesses >= this.maxProcesses) {
201
+ const idle = poolForCwd?.find((proc) => this.isAvailable(proc));
202
+ if (idle)
203
+ return this.reserve(idle);
204
+ if (this.totalProcesses >= this.maxProcesses)
193
205
  return null;
194
- }
195
- return this.spawn(cwd);
206
+ return this.reserve(this.spawn(cwd));
196
207
  }
197
- /**
198
- * Get any live process (idle preferred). For model queries that don't
199
- * need a specific cwd.
200
- */
201
- getAny(busySet) {
202
- // Prefer idle
208
+ /** Reserve any idle live process, regardless of cwd. */
209
+ acquireAny() {
203
210
  for (const procs of this.pools.values()) {
204
- const idle = procs.find((p) => p.process.exitCode === null && !busySet.has(p));
211
+ const idle = procs.find((proc) => this.isAvailable(proc));
205
212
  if (idle)
206
- return idle;
207
- }
208
- // Fall back to any live process
209
- for (const procs of this.pools.values()) {
210
- const live = procs.find((p) => p.process.exitCode === null);
211
- if (live)
212
- return live;
213
+ return this.reserve(idle);
213
214
  }
214
215
  return null;
215
216
  }
216
- /**
217
- * Evict one idle process from a different cwd to free capacity.
218
- * Returns the evicted process, or null if none can be evicted.
219
- */
220
- evictIdleDifferentCwd(targetCwd, busySet) {
217
+ isAvailable(proc) {
218
+ return proc.process.exitCode === null
219
+ && !this.leases.has(proc)
220
+ && !this.decommissioning.has(proc);
221
+ }
222
+ reserve(proc) {
223
+ if (!this.isAvailable(proc))
224
+ throw new Error(`pi process #${proc.id} is not available`);
225
+ const lease = new RpcProcessLease(this, proc);
226
+ this.leases.set(proc, lease);
227
+ return lease;
228
+ }
229
+ /** @internal Called only by RpcProcessLease.release(). */
230
+ releaseLease(lease) {
231
+ if (lease.released)
232
+ return;
233
+ const proc = lease.process;
234
+ if (this.leases.get(proc) !== lease) {
235
+ lease.markReleased();
236
+ return;
237
+ }
238
+ this.leases.delete(proc);
239
+ lease.markReleased();
240
+ if (this.decommissioning.has(proc) && proc.process.exitCode === null && !proc.process.killed) {
241
+ // Keep the marker until the exit event removes the process. Otherwise a
242
+ // concurrent acquisition could lease the dying process before SIGTERM lands.
243
+ console.log(`[pool] Decommissioning pi#${proc.id} after completed operation`);
244
+ proc.process.kill("SIGTERM");
245
+ }
246
+ }
247
+ decommission(proc) {
248
+ if (proc.process.exitCode !== null || this.decommissioning.has(proc))
249
+ return "ignored";
250
+ this.decommissioning.add(proc);
251
+ if (this.leases.has(proc))
252
+ return "draining";
253
+ proc.process.kill("SIGTERM");
254
+ return "killed";
255
+ }
256
+ /** Evict an idle process from a different cwd to free capacity. */
257
+ evictIdleDifferentCwd(targetCwd) {
221
258
  for (const [cwd, procs] of this.pools) {
222
259
  if (cwd === targetCwd)
223
260
  continue;
224
- const victim = procs.find((p) => p.process.exitCode === null && !busySet.has(p));
261
+ const victim = procs.find((proc) => this.isAvailable(proc));
225
262
  if (!victim)
226
263
  continue;
227
264
  console.log(`[pool] Evicting idle pi#${victim.id} from cwd ${cwd} to make room for ${targetCwd}`);
228
- victim.process.kill("SIGTERM");
265
+ this.decommission(victim);
229
266
  return victim;
230
267
  }
231
268
  return null;
232
269
  }
233
- /**
234
- * Pre-warm the pool with processes for the given cwd.
235
- */
236
- /**
237
- * Pre-warm the pool with processes for the given cwd.
238
- * Spawns are staggered: each process must be ready (via get_state RPC)
239
- * before the next one is spawned. This prevents lock contention on
240
- * shared resources like auth.json during startup.
241
- */
270
+ /** Immediately kill one process while keeping it unavailable until exit. */
271
+ forceKill(proc) {
272
+ if (proc.process.exitCode !== null)
273
+ return false;
274
+ this.decommissioning.add(proc);
275
+ return proc.process.kill("SIGKILL");
276
+ }
277
+ /** Kill idle processes now and mark leased processes for kill on release. */
278
+ decommissionAll() {
279
+ let killed = 0;
280
+ let draining = 0;
281
+ for (const proc of this.getAllProcesses()) {
282
+ const result = this.decommission(proc);
283
+ if (result === "killed")
284
+ killed += 1;
285
+ if (result === "draining")
286
+ draining += 1;
287
+ }
288
+ return { killed, draining };
289
+ }
242
290
  async prewarm(cwd) {
243
- const existing = this.pools.get(cwd)?.filter((p) => p.process.exitCode === null).length ?? 0;
291
+ const existing = this.pools.get(cwd)?.filter((proc) => proc.process.exitCode === null).length ?? 0;
244
292
  const needed = Math.min(this.prewarmCount, this.maxProcesses) - existing;
245
293
  for (let i = 0; i < needed; i++) {
246
294
  if (this.totalProcesses >= this.maxProcesses)
247
295
  break;
248
296
  const proc = this.spawn(cwd);
249
- if (i < needed - 1) {
250
- // Wait for this process to be ready before spawning the next one
297
+ if (i < needed - 1)
251
298
  await this.waitForReady(proc);
252
- }
253
299
  }
254
300
  }
255
- /**
256
- * Send an RPC command to a process and wait for a response.
257
- */
258
301
  sendRpc(proc, command, timeoutMs) {
259
302
  if (!proc.process || proc.process.exitCode !== null) {
260
303
  return Promise.reject(new Error("RPC process is dead"));
261
304
  }
262
305
  const timeout = timeoutMs ?? this.rpcTimeout;
263
306
  const id = `req_${++proc.requestId}`;
264
- const fullCommand = { ...command, id };
265
307
  return new Promise((resolve, reject) => {
266
308
  const timer = setTimeout(() => {
267
309
  proc.pendingRequests.delete(id);
310
+ // A timed-out RPC may still be executing. Never return that process to
311
+ // the idle pool where a new session command could overlap with it.
312
+ this.decommission(proc);
268
313
  reject(new Error(`Timeout waiting for RPC response to ${command.type}`));
269
314
  }, timeout);
270
315
  proc.pendingRequests.set(id, {
271
- resolve: (data) => {
316
+ command: command.type,
317
+ resolve: (response) => {
272
318
  clearTimeout(timer);
273
- resolve(data);
319
+ resolve(response);
274
320
  },
275
- reject: (err) => {
321
+ reject: (error) => {
276
322
  clearTimeout(timer);
277
- reject(err);
323
+ reject(error);
278
324
  },
279
325
  });
280
- proc.process.stdin.write(JSON.stringify(fullCommand) + "\n");
326
+ proc.process.stdin.write(encodePiRpcCommand(command, id));
281
327
  });
282
328
  }
283
- /**
284
- * Send an RPC command and throw if it fails.
285
- */
286
329
  async sendRpcChecked(proc, command) {
287
330
  const response = await this.sendRpc(proc, command);
288
- if (!response?.success) {
289
- throw new Error(response?.error || `RPC command failed: ${command.type}`);
331
+ if (!response.success) {
332
+ throw new Error(response.error || `RPC command failed: ${command.type}`);
290
333
  }
291
334
  return response;
292
335
  }