@victor-software-house/pi-openai-proxy 0.2.2 → 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.
package/README.md CHANGED
@@ -231,16 +231,6 @@ pi install npm:@victor-software-house/pi-openai-proxy
231
231
 
232
232
  `/proxy` (or `/proxy config`) opens an interactive settings panel where you can configure the bind address, port, auth token, remote images, body size limit, and upstream timeout. Changes are saved to `~/.pi/agent/proxy-config.json` immediately. Restart the proxy to apply changes.
233
233
 
234
- ### Auto-start with a pi session
235
-
236
- ```bash
237
- pi --proxy
238
- ```
239
-
240
- The proxy starts automatically on session start and stops when the session ends. A status indicator in the footer shows the proxy URL and model count.
241
-
242
- Note: the `--proxy` flag is registered by the extension at runtime and does not appear in `pi --help`.
243
-
244
234
  ### Standalone (background) mode
245
235
 
246
236
  For a proxy that outlives pi sessions, run the binary directly:
@@ -11,9 +11,6 @@
11
11
  * /proxy path Show config file location
12
12
  * /proxy reset Restore default settings
13
13
  * /proxy help Usage line
14
- *
15
- * Flag:
16
- * --proxy Auto-start on session start
17
14
  */
18
15
 
19
16
  import {
@@ -46,6 +43,8 @@ interface ProxyConfig {
46
43
  remoteImages: boolean;
47
44
  maxBodySizeMb: number;
48
45
  upstreamTimeoutSec: number;
46
+ /** "detached" = daemon that outlives the session, "session" = dies with the session */
47
+ lifetime: "detached" | "session";
49
48
  }
50
49
 
51
50
  interface RuntimeStatus {
@@ -65,6 +64,7 @@ const DEFAULT_CONFIG: ProxyConfig = {
65
64
  remoteImages: false,
66
65
  maxBodySizeMb: 50,
67
66
  upstreamTimeoutSec: 120,
67
+ lifetime: "detached",
68
68
  };
69
69
 
70
70
  function toObject(value: unknown): Record<string, unknown> {
@@ -88,6 +88,7 @@ function normalizeConfig(raw: unknown): ProxyConfig {
88
88
  remoteImages: typeof v["remoteImages"] === "boolean" ? (v["remoteImages"] as boolean) : DEFAULT_CONFIG.remoteImages,
89
89
  maxBodySizeMb: clampInt(v["maxBodySizeMb"], 1, 500, DEFAULT_CONFIG.maxBodySizeMb),
90
90
  upstreamTimeoutSec: clampInt(v["upstreamTimeoutSec"], 5, 600, DEFAULT_CONFIG.upstreamTimeoutSec),
91
+ lifetime: v["lifetime"] === "session" ? "session" : "detached",
91
92
  };
92
93
  }
93
94
 
@@ -145,8 +146,8 @@ function configToEnv(config: ProxyConfig): Record<string, string> {
145
146
  // ---------------------------------------------------------------------------
146
147
 
147
148
  export default function proxyExtension(pi: ExtensionAPI): void {
148
- let proxyProcess: ChildProcess | undefined;
149
149
  let config = loadConfig();
150
+ let sessionProcess: ChildProcess | undefined;
150
151
 
151
152
  const extensionDir = dirname(fileURLToPath(import.meta.url));
152
153
  const packageRoot = resolve(extensionDir, "..");
@@ -156,27 +157,19 @@ export default function proxyExtension(pi: ExtensionAPI): void {
156
157
  return `http://${config.host}:${String(config.port)}`;
157
158
  }
158
159
 
159
- // --- Flag ---
160
-
161
- pi.registerFlag("proxy", {
162
- description: "Start the OpenAI proxy on session start",
163
- type: "boolean",
164
- default: false,
165
- });
166
-
167
160
  // --- Lifecycle ---
168
161
 
169
162
  pi.on("session_start", async (_event, ctx) => {
170
163
  config = loadConfig();
171
- if (pi.getFlag("--proxy")) {
172
- await startProxy(ctx);
173
- } else {
174
- await refreshStatus(ctx);
175
- }
164
+ await refreshStatus(ctx);
176
165
  });
177
166
 
178
167
  pi.on("session_shutdown", async () => {
179
- killProxy();
168
+ // Only kill session-tied processes
169
+ if (sessionProcess !== undefined) {
170
+ sessionProcess.kill("SIGTERM");
171
+ sessionProcess = undefined;
172
+ }
180
173
  });
181
174
 
182
175
  // --- Command family ---
@@ -235,30 +228,72 @@ export default function proxyExtension(pi: ExtensionAPI): void {
235
228
  },
236
229
  });
237
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
+
238
276
  // --- Proxy process management ---
239
277
 
240
278
  async function probe(): Promise<RuntimeStatus> {
241
- const managed = proxyProcess !== undefined;
242
279
  try {
243
280
  const res = await fetch(`${proxyUrl()}/v1/models`, {
244
281
  signal: AbortSignal.timeout(2000),
245
282
  });
246
283
  if (res.ok) {
247
284
  const body = (await res.json()) as { data?: unknown[] };
248
- return { reachable: true, models: body.data?.length ?? 0, managed };
285
+ return { reachable: true, models: body.data?.length ?? 0 };
249
286
  }
250
287
  } catch {
251
288
  // not reachable
252
289
  }
253
- return { reachable: false, models: 0, managed };
290
+ return { reachable: false, models: 0 };
254
291
  }
255
292
 
256
293
  async function refreshStatus(ctx: ExtensionContext): Promise<void> {
257
294
  const status = await probe();
258
295
  if (status.reachable) {
259
296
  ctx.ui.setStatus("proxy", `proxy: ${proxyUrl()} (${String(status.models)} models)`);
260
- } else if (proxyProcess !== undefined) {
261
- ctx.ui.setStatus("proxy", "proxy: starting...");
262
297
  } else {
263
298
  ctx.ui.setStatus("proxy", undefined);
264
299
  }
@@ -266,6 +301,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
266
301
 
267
302
  async function startProxy(ctx: ExtensionContext): Promise<void> {
268
303
  config = loadConfig();
304
+
305
+ // Already running?
269
306
  const status = await probe();
270
307
  if (status.reachable) {
271
308
  ctx.ui.notify(
@@ -276,9 +313,17 @@ export default function proxyExtension(pi: ExtensionAPI): void {
276
313
  return;
277
314
  }
278
315
 
279
- if (proxyProcess !== undefined) {
280
- ctx.ui.notify("Proxy is already starting...", "info");
281
- 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));
282
327
  }
283
328
 
284
329
  ctx.ui.setStatus("proxy", "proxy: starting...");
@@ -286,30 +331,17 @@ export default function proxyExtension(pi: ExtensionAPI): void {
286
331
  try {
287
332
  const proxyEnv = { ...process.env, ...configToEnv(config) };
288
333
 
289
- proxyProcess = spawn("bun", ["run", proxyEntry], {
290
- stdio: ["ignore", "pipe", "pipe"],
291
- detached: false,
292
- env: proxyEnv,
293
- });
294
-
295
- proxyProcess.on("exit", (code) => {
296
- proxyProcess = undefined;
297
- if (code !== null && code !== 0) {
298
- ctx.ui.notify(`Proxy exited with code ${String(code)}`, "warning");
299
- }
300
- ctx.ui.setStatus("proxy", undefined);
301
- });
302
-
303
- proxyProcess.on("error", (err) => {
304
- proxyProcess = undefined;
305
- ctx.ui.notify(`Failed to start proxy: ${err.message}`, "error");
306
- ctx.ui.setStatus("proxy", undefined);
307
- });
334
+ if (config.lifetime === "detached") {
335
+ await startDetached(ctx, proxyEnv);
336
+ } else {
337
+ startSessionTied(ctx, proxyEnv);
338
+ }
308
339
 
309
340
  const ready = await waitForReady(3000);
310
341
  if (ready.reachable) {
342
+ const mode = config.lifetime === "detached" ? "background" : "session";
311
343
  ctx.ui.notify(
312
- `Proxy started at ${proxyUrl()} (${String(ready.models)} models)`,
344
+ `Proxy started at ${proxyUrl()} (${String(ready.models)} models) [${mode}]`,
313
345
  "info",
314
346
  );
315
347
  } else {
@@ -323,36 +355,97 @@ export default function proxyExtension(pi: ExtensionAPI): void {
323
355
  }
324
356
  }
325
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
+
326
400
  async function stopProxy(ctx: ExtensionContext): Promise<void> {
327
- if (proxyProcess !== undefined) {
328
- killProxy();
329
- 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");
330
406
  ctx.ui.setStatus("proxy", undefined);
331
407
  return;
332
408
  }
333
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?
334
425
  const status = await probe();
335
426
  if (status.reachable) {
336
427
  ctx.ui.notify(
337
- `Proxy at ${proxyUrl()} is running externally (not managed by this session)`,
428
+ `Proxy at ${proxyUrl()} was not started by /proxy -- stop it manually`,
338
429
  "info",
339
430
  );
340
431
  } else {
341
432
  ctx.ui.notify("Proxy is not running", "info");
433
+ ctx.ui.setStatus("proxy", undefined);
342
434
  }
343
435
  }
344
436
 
345
437
  async function showStatus(ctx: ExtensionContext): Promise<void> {
346
438
  const status = await probe();
347
- const tag = status.managed ? " (managed)" : " (external)";
439
+ const pid = readPid();
440
+ const pidTag = pid !== undefined ? ` [pid ${String(pid)}]` : "";
348
441
 
349
442
  if (status.reachable) {
350
443
  ctx.ui.notify(
351
- `${proxyUrl()}${tag} -- ${String(status.models)} models available`,
444
+ `${proxyUrl()} -- ${String(status.models)} models available${pidTag}`,
352
445
  "info",
353
446
  );
354
447
  } else {
355
- ctx.ui.notify("Proxy not running. Use /proxy start or pi --proxy", "info");
448
+ ctx.ui.notify("Proxy not running. Use /proxy start", "info");
356
449
  }
357
450
  await refreshStatus(ctx);
358
451
  }
@@ -362,24 +455,18 @@ export default function proxyExtension(pi: ExtensionAPI): void {
362
455
  const authDisplay =
363
456
  config.authToken.length > 0 ? `enabled (token: ${config.authToken})` : "disabled";
364
457
  const lines = [
458
+ `lifetime: ${config.lifetime}`,
365
459
  `host: ${config.host}`,
366
460
  `port: ${String(config.port)}`,
367
461
  `auth: ${authDisplay}`,
368
462
  `remote images: ${String(config.remoteImages)}`,
369
463
  `max body: ${String(config.maxBodySizeMb)} MB`,
370
- `upstream timeout: ${String(config.upstreamTimeoutSec)}s`,
464
+ `timeout: ${String(config.upstreamTimeoutSec)}s`,
371
465
  ];
372
466
  ctx.ui.notify(lines.join(" | "), "info");
373
467
  await refreshStatus(ctx);
374
468
  }
375
469
 
376
- function killProxy(): void {
377
- if (proxyProcess !== undefined) {
378
- proxyProcess.kill("SIGTERM");
379
- proxyProcess = undefined;
380
- }
381
- }
382
-
383
470
  async function waitForReady(timeoutMs: number): Promise<RuntimeStatus> {
384
471
  const start = Date.now();
385
472
  const interval = 300;
@@ -388,7 +475,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
388
475
  if (status.reachable) return status;
389
476
  await new Promise((r) => setTimeout(r, interval));
390
477
  }
391
- return { reachable: false, models: 0, managed: proxyProcess !== undefined };
478
+ return { reachable: false, models: 0 };
392
479
  }
393
480
 
394
481
  // --- Settings panel ---
@@ -397,6 +484,13 @@ export default function proxyExtension(pi: ExtensionAPI): void {
397
484
 
398
485
  function buildSettingItems(): SettingItem[] {
399
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
+ },
400
494
  {
401
495
  id: "host",
402
496
  label: "Bind address",
@@ -447,6 +541,9 @@ export default function proxyExtension(pi: ExtensionAPI): void {
447
541
 
448
542
  function applySetting(id: string, value: string): void {
449
543
  switch (id) {
544
+ case "lifetime":
545
+ config = { ...config, lifetime: value === "session" ? "session" : "detached" };
546
+ break;
450
547
  case "host":
451
548
  config = { ...config, host: value };
452
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.2",
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",