@victor-software-house/pi-openai-proxy 0.2.3 → 0.3.1

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