@victor-software-house/pi-openai-proxy 0.2.3 → 0.3.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.
Files changed (2) hide show
  1. package/extensions/proxy.ts +158 -46
  2. package/package.json +1 -1
@@ -43,6 +43,8 @@ interface ProxyConfig {
43
43
  remoteImages: boolean;
44
44
  maxBodySizeMb: number;
45
45
  upstreamTimeoutSec: number;
46
+ /** "detached" = daemon that outlives the session, "session" = dies with the session */
47
+ lifetime: "detached" | "session";
46
48
  }
47
49
 
48
50
  interface RuntimeStatus {
@@ -62,6 +64,7 @@ const DEFAULT_CONFIG: ProxyConfig = {
62
64
  remoteImages: false,
63
65
  maxBodySizeMb: 50,
64
66
  upstreamTimeoutSec: 120,
67
+ lifetime: "detached",
65
68
  };
66
69
 
67
70
  function toObject(value: unknown): Record<string, unknown> {
@@ -85,6 +88,7 @@ function normalizeConfig(raw: unknown): ProxyConfig {
85
88
  remoteImages: typeof v["remoteImages"] === "boolean" ? (v["remoteImages"] as boolean) : DEFAULT_CONFIG.remoteImages,
86
89
  maxBodySizeMb: clampInt(v["maxBodySizeMb"], 1, 500, DEFAULT_CONFIG.maxBodySizeMb),
87
90
  upstreamTimeoutSec: clampInt(v["upstreamTimeoutSec"], 5, 600, DEFAULT_CONFIG.upstreamTimeoutSec),
91
+ lifetime: v["lifetime"] === "session" ? "session" : "detached",
88
92
  };
89
93
  }
90
94
 
@@ -142,8 +146,8 @@ function configToEnv(config: ProxyConfig): Record<string, string> {
142
146
  // ---------------------------------------------------------------------------
143
147
 
144
148
  export default function proxyExtension(pi: ExtensionAPI): void {
145
- let proxyProcess: ChildProcess | undefined;
146
149
  let config = loadConfig();
150
+ let sessionProcess: ChildProcess | undefined;
147
151
 
148
152
  const extensionDir = dirname(fileURLToPath(import.meta.url));
149
153
  const packageRoot = resolve(extensionDir, "..");
@@ -161,7 +165,11 @@ export default function proxyExtension(pi: ExtensionAPI): void {
161
165
  });
162
166
 
163
167
  pi.on("session_shutdown", async () => {
164
- killProxy();
168
+ // Only kill session-tied processes
169
+ if (sessionProcess !== undefined) {
170
+ sessionProcess.kill("SIGTERM");
171
+ sessionProcess = undefined;
172
+ }
165
173
  });
166
174
 
167
175
  // --- Command family ---
@@ -220,30 +228,72 @@ export default function proxyExtension(pi: ExtensionAPI): void {
220
228
  },
221
229
  });
222
230
 
231
+ // --- PID file ---
232
+
233
+ function getPidPath(): string {
234
+ const piDir = process.env["PI_CODING_AGENT_DIR"] ?? resolve(process.env["HOME"] ?? "~", ".pi", "agent");
235
+ return resolve(piDir, "proxy.pid");
236
+ }
237
+
238
+ function readPid(): number | undefined {
239
+ const p = getPidPath();
240
+ if (!existsSync(p)) return undefined;
241
+ try {
242
+ const raw = readFileSync(p, "utf-8").trim();
243
+ const pid = Number.parseInt(raw, 10);
244
+ if (!Number.isFinite(pid) || pid <= 0) return undefined;
245
+ // Check if process is alive
246
+ try {
247
+ process.kill(pid, 0);
248
+ return pid;
249
+ } catch {
250
+ // Process is dead, clean up stale PID file
251
+ unlinkSync(p);
252
+ return undefined;
253
+ }
254
+ } catch {
255
+ return undefined;
256
+ }
257
+ }
258
+
259
+ function writePid(pid: number): void {
260
+ const p = getPidPath();
261
+ mkdirSync(dirname(p), { recursive: true });
262
+ writeFileSync(p, String(pid), "utf-8");
263
+ }
264
+
265
+ function removePid(): void {
266
+ const p = getPidPath();
267
+ if (existsSync(p)) {
268
+ try {
269
+ unlinkSync(p);
270
+ } catch {
271
+ // ignore
272
+ }
273
+ }
274
+ }
275
+
223
276
  // --- Proxy process management ---
224
277
 
225
278
  async function probe(): Promise<RuntimeStatus> {
226
- const managed = proxyProcess !== undefined;
227
279
  try {
228
280
  const res = await fetch(`${proxyUrl()}/v1/models`, {
229
281
  signal: AbortSignal.timeout(2000),
230
282
  });
231
283
  if (res.ok) {
232
284
  const body = (await res.json()) as { data?: unknown[] };
233
- return { reachable: true, models: body.data?.length ?? 0, managed };
285
+ return { reachable: true, models: body.data?.length ?? 0 };
234
286
  }
235
287
  } catch {
236
288
  // not reachable
237
289
  }
238
- return { reachable: false, models: 0, managed };
290
+ return { reachable: false, models: 0 };
239
291
  }
240
292
 
241
293
  async function refreshStatus(ctx: ExtensionContext): Promise<void> {
242
294
  const status = await probe();
243
295
  if (status.reachable) {
244
296
  ctx.ui.setStatus("proxy", `proxy: ${proxyUrl()} (${String(status.models)} models)`);
245
- } else if (proxyProcess !== undefined) {
246
- ctx.ui.setStatus("proxy", "proxy: starting...");
247
297
  } else {
248
298
  ctx.ui.setStatus("proxy", undefined);
249
299
  }
@@ -251,6 +301,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
251
301
 
252
302
  async function startProxy(ctx: ExtensionContext): Promise<void> {
253
303
  config = loadConfig();
304
+
305
+ // Already running?
254
306
  const status = await probe();
255
307
  if (status.reachable) {
256
308
  ctx.ui.notify(
@@ -261,9 +313,17 @@ export default function proxyExtension(pi: ExtensionAPI): void {
261
313
  return;
262
314
  }
263
315
 
264
- if (proxyProcess !== undefined) {
265
- ctx.ui.notify("Proxy is already starting...", "info");
266
- return;
316
+ // Stale PID from a previous detached run?
317
+ const existingPid = readPid();
318
+ if (existingPid !== undefined) {
319
+ ctx.ui.notify(`Stale proxy process ${String(existingPid)} -- killing`, "warning");
320
+ try {
321
+ process.kill(existingPid, "SIGTERM");
322
+ } catch {
323
+ // already dead
324
+ }
325
+ removePid();
326
+ await new Promise((r) => setTimeout(r, 500));
267
327
  }
268
328
 
269
329
  ctx.ui.setStatus("proxy", "proxy: starting...");
@@ -271,30 +331,17 @@ export default function proxyExtension(pi: ExtensionAPI): void {
271
331
  try {
272
332
  const proxyEnv = { ...process.env, ...configToEnv(config) };
273
333
 
274
- proxyProcess = spawn("bun", ["run", proxyEntry], {
275
- stdio: ["ignore", "pipe", "pipe"],
276
- detached: false,
277
- env: proxyEnv,
278
- });
279
-
280
- proxyProcess.on("exit", (code) => {
281
- proxyProcess = undefined;
282
- if (code !== null && code !== 0) {
283
- ctx.ui.notify(`Proxy exited with code ${String(code)}`, "warning");
284
- }
285
- ctx.ui.setStatus("proxy", undefined);
286
- });
287
-
288
- proxyProcess.on("error", (err) => {
289
- proxyProcess = undefined;
290
- ctx.ui.notify(`Failed to start proxy: ${err.message}`, "error");
291
- ctx.ui.setStatus("proxy", undefined);
292
- });
334
+ if (config.lifetime === "detached") {
335
+ await startDetached(ctx, proxyEnv);
336
+ } else {
337
+ startSessionTied(ctx, proxyEnv);
338
+ }
293
339
 
294
340
  const ready = await waitForReady(3000);
295
341
  if (ready.reachable) {
342
+ const mode = config.lifetime === "detached" ? "background" : "session";
296
343
  ctx.ui.notify(
297
- `Proxy started at ${proxyUrl()} (${String(ready.models)} models)`,
344
+ `Proxy started at ${proxyUrl()} (${String(ready.models)} models) [${mode}]`,
298
345
  "info",
299
346
  );
300
347
  } else {
@@ -308,36 +355,97 @@ export default function proxyExtension(pi: ExtensionAPI): void {
308
355
  }
309
356
  }
310
357
 
358
+ async function startDetached(ctx: ExtensionContext, env: Record<string, string>): Promise<void> {
359
+ const child = spawn("bun", ["run", proxyEntry], {
360
+ stdio: ["ignore", "ignore", "ignore"],
361
+ detached: true,
362
+ env,
363
+ });
364
+
365
+ if (child.pid === undefined) {
366
+ throw new Error("No PID returned from spawn");
367
+ }
368
+
369
+ child.unref();
370
+ writePid(child.pid);
371
+ }
372
+
373
+ function startSessionTied(ctx: ExtensionContext, env: Record<string, string>): void {
374
+ if (sessionProcess !== undefined) {
375
+ ctx.ui.notify("Session proxy already running", "info");
376
+ return;
377
+ }
378
+
379
+ sessionProcess = spawn("bun", ["run", proxyEntry], {
380
+ stdio: ["ignore", "pipe", "pipe"],
381
+ detached: false,
382
+ env,
383
+ });
384
+
385
+ sessionProcess.on("exit", (code) => {
386
+ sessionProcess = undefined;
387
+ if (code !== null && code !== 0) {
388
+ ctx.ui.notify(`Proxy exited with code ${String(code)}`, "warning");
389
+ }
390
+ ctx.ui.setStatus("proxy", undefined);
391
+ });
392
+
393
+ sessionProcess.on("error", (err) => {
394
+ sessionProcess = undefined;
395
+ ctx.ui.notify(`Proxy error: ${err.message}`, "error");
396
+ ctx.ui.setStatus("proxy", undefined);
397
+ });
398
+ }
399
+
311
400
  async function stopProxy(ctx: ExtensionContext): Promise<void> {
312
- if (proxyProcess !== undefined) {
313
- killProxy();
314
- ctx.ui.notify("Proxy stopped", "info");
401
+ // Session-tied process?
402
+ if (sessionProcess !== undefined) {
403
+ sessionProcess.kill("SIGTERM");
404
+ sessionProcess = undefined;
405
+ ctx.ui.notify("Session proxy stopped", "info");
315
406
  ctx.ui.setStatus("proxy", undefined);
316
407
  return;
317
408
  }
318
409
 
410
+ // Detached process via PID file?
411
+ const pid = readPid();
412
+ if (pid !== undefined) {
413
+ try {
414
+ process.kill(pid, "SIGTERM");
415
+ removePid();
416
+ ctx.ui.notify(`Proxy stopped (pid ${String(pid)})`, "info");
417
+ ctx.ui.setStatus("proxy", undefined);
418
+ return;
419
+ } catch {
420
+ removePid();
421
+ }
422
+ }
423
+
424
+ // Something else listening?
319
425
  const status = await probe();
320
426
  if (status.reachable) {
321
427
  ctx.ui.notify(
322
- `Proxy at ${proxyUrl()} is running externally (not managed by this session)`,
428
+ `Proxy at ${proxyUrl()} was not started by /proxy -- stop it manually`,
323
429
  "info",
324
430
  );
325
431
  } else {
326
432
  ctx.ui.notify("Proxy is not running", "info");
433
+ ctx.ui.setStatus("proxy", undefined);
327
434
  }
328
435
  }
329
436
 
330
437
  async function showStatus(ctx: ExtensionContext): Promise<void> {
331
438
  const status = await probe();
332
- const tag = status.managed ? " (managed)" : " (external)";
439
+ const pid = readPid();
440
+ const pidTag = pid !== undefined ? ` [pid ${String(pid)}]` : "";
333
441
 
334
442
  if (status.reachable) {
335
443
  ctx.ui.notify(
336
- `${proxyUrl()}${tag} -- ${String(status.models)} models available`,
444
+ `${proxyUrl()} -- ${String(status.models)} models available${pidTag}`,
337
445
  "info",
338
446
  );
339
447
  } else {
340
- ctx.ui.notify("Proxy not running. Use /proxy start or pi --proxy", "info");
448
+ ctx.ui.notify("Proxy not running. Use /proxy start", "info");
341
449
  }
342
450
  await refreshStatus(ctx);
343
451
  }
@@ -347,24 +455,18 @@ export default function proxyExtension(pi: ExtensionAPI): void {
347
455
  const authDisplay =
348
456
  config.authToken.length > 0 ? `enabled (token: ${config.authToken})` : "disabled";
349
457
  const lines = [
458
+ `lifetime: ${config.lifetime}`,
350
459
  `host: ${config.host}`,
351
460
  `port: ${String(config.port)}`,
352
461
  `auth: ${authDisplay}`,
353
462
  `remote images: ${String(config.remoteImages)}`,
354
463
  `max body: ${String(config.maxBodySizeMb)} MB`,
355
- `upstream timeout: ${String(config.upstreamTimeoutSec)}s`,
464
+ `timeout: ${String(config.upstreamTimeoutSec)}s`,
356
465
  ];
357
466
  ctx.ui.notify(lines.join(" | "), "info");
358
467
  await refreshStatus(ctx);
359
468
  }
360
469
 
361
- function killProxy(): void {
362
- if (proxyProcess !== undefined) {
363
- proxyProcess.kill("SIGTERM");
364
- proxyProcess = undefined;
365
- }
366
- }
367
-
368
470
  async function waitForReady(timeoutMs: number): Promise<RuntimeStatus> {
369
471
  const start = Date.now();
370
472
  const interval = 300;
@@ -373,7 +475,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
373
475
  if (status.reachable) return status;
374
476
  await new Promise((r) => setTimeout(r, interval));
375
477
  }
376
- return { reachable: false, models: 0, managed: proxyProcess !== undefined };
478
+ return { reachable: false, models: 0 };
377
479
  }
378
480
 
379
481
  // --- Settings panel ---
@@ -382,6 +484,13 @@ export default function proxyExtension(pi: ExtensionAPI): void {
382
484
 
383
485
  function buildSettingItems(): SettingItem[] {
384
486
  return [
487
+ {
488
+ id: "lifetime",
489
+ label: "Lifetime",
490
+ description: "detached = background daemon, session = dies when pi exits",
491
+ currentValue: config.lifetime,
492
+ values: ["detached", "session"],
493
+ },
385
494
  {
386
495
  id: "host",
387
496
  label: "Bind address",
@@ -432,6 +541,9 @@ export default function proxyExtension(pi: ExtensionAPI): void {
432
541
 
433
542
  function applySetting(id: string, value: string): void {
434
543
  switch (id) {
544
+ case "lifetime":
545
+ config = { ...config, lifetime: value === "session" ? "session" : "detached" };
546
+ break;
435
547
  case "host":
436
548
  config = { ...config, host: value };
437
549
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-openai-proxy",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Local OpenAI-compatible HTTP proxy built on pi's SDK",
5
5
  "license": "MIT",
6
6
  "author": "Victor Software House",