pi-codebuddy-sdk 0.4.13 → 0.4.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codebuddy-sdk",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "Pi extension that uses CodeBuddy (via Agent SDK) as a model provider and adds an AskCodebuddy tool.",
5
5
  "author": "slahser",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  "typecheck": "tsc --noEmit"
33
33
  },
34
34
  "dependencies": {
35
- "@tencent-ai/agent-sdk": "^0.3.265",
35
+ "@tencent-ai/agent-sdk": "^0.3.267",
36
36
  "change-case": "^5.4.4",
37
37
  "typebox": "^1.3.34"
38
38
  },
@@ -45,7 +45,7 @@
45
45
  "@earendil-works/pi-ai": "^0.87.1",
46
46
  "@earendil-works/pi-coding-agent": "^0.87.1",
47
47
  "@earendil-works/pi-tui": "^0.87.1",
48
- "@types/node": "^26.6.2",
48
+ "@types/node": "^26.6.3",
49
49
  "tsx": "^4.23.15",
50
50
  "typescript": "^7.0.2"
51
51
  },
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import { loadConfig, type Config } from "./config.js";
20
20
  import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
21
21
  import { buildActionSummary, type ToolCallState } from "./askcodebuddy-ui.js";
22
22
  import { withSdkGate } from "./sdk-gate.js";
23
+ import { closeQueryTransport, endQuery } from "./query-teardown.js";
23
24
 
24
25
  // Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
25
26
  const _piAi = piAi as any;
@@ -496,7 +497,7 @@ async function runIsolatedSummary(
496
497
  stream.end();
497
498
  } finally {
498
499
  options?.signal?.removeEventListener("abort", onAbort);
499
- try { sdkQuery?.interrupt(); } catch {}
500
+ endQuery(sdkQuery, "compact-summary", { log: debug });
500
501
  }
501
502
  }
502
503
 
@@ -1667,7 +1668,7 @@ async function promptAndWait(
1667
1668
  return { responseText, stopReason };
1668
1669
  } finally {
1669
1670
  signal?.removeEventListener("abort", onAbort);
1670
- void sdkQuery.interrupt().catch(() => {});
1671
+ endQuery(sdkQuery, "askCodebuddy", { log: debug });
1671
1672
  }
1672
1673
  }
1673
1674
 
@@ -1687,8 +1688,9 @@ let discoverInFlight: Promise<void> | null = null;
1687
1688
 
1688
1689
  async function discoverModels(pi: ExtensionAPI): Promise<void> {
1689
1690
  await withSdkGate(async () => {
1691
+ let q: ReturnType<typeof startQuery> | undefined;
1690
1692
  try {
1691
- const q = startQuery({ prompt: " ", options: { maxTurns: 0, permissionMode: "bypassPermissions", tools: [] } });
1693
+ q = startQuery({ prompt: " ", options: { maxTurns: 0, permissionMode: "bypassPermissions", tools: [] } });
1692
1694
  const supported = await q.supportedModels();
1693
1695
  await q.return().catch(() => {});
1694
1696
  if (!supported.length) return;
@@ -1740,6 +1742,12 @@ async function discoverModels(pi: ExtensionAPI): Promise<void> {
1740
1742
  } else {
1741
1743
  debug("discoverModels: failed, using fallback models", err);
1742
1744
  }
1745
+ } finally {
1746
+ // supportedModels() never iterates the query, so the SDK's cleanup()
1747
+ // (the only place the transport is closed) never runs here — close it
1748
+ // explicitly or the codebuddy-headless child leaks for the host's
1749
+ // lifetime. Idempotent, so a completed iterator is a harmless no-op.
1750
+ if (q) closeQueryTransport(q, "discoverModels", { log: debug });
1743
1751
  }
1744
1752
  });
1745
1753
  }
@@ -0,0 +1,84 @@
1
+ // Guarantee the CodeBuddy CLI subprocess is reaped once a query is finished.
2
+ //
3
+ // The @tencent-ai/agent-sdk `Query` closes its transport (which ends stdin and
4
+ // terminates the spawned `codebuddy-headless.js` child) only from its private
5
+ // `cleanup()`, and `cleanup()` runs only in `createIterator()`'s `finally` —
6
+ // i.e. only if the query was actually iterated to completion. `Query.return()`
7
+ // just sends an `interrupt` control frame and, per its own comment, "does not
8
+ // close the transport".
9
+ //
10
+ // Any path that never iterates the query therefore leaks the child for the
11
+ // lifetime of the host process. `discoverModels()` is the clearest offender: it
12
+ // issues a single `supportedModels()` control request and abandons the query,
13
+ // so its iterator (and `cleanup()`) never runs. Long-running sessions pile up
14
+ // stopped CLIs and eventually exhaust the CLI's local port pool (EADDRINUSE on
15
+ // 127.0.0.1:6252x/6253x).
16
+ //
17
+ // We reach for the transport the same way the issue #10 teardown guard in
18
+ // index.ts already does: `Transport.close()` is public (transport/index.d.ts),
19
+ // but `Query.transport` and `Query.cleanup()` are private (query.d.ts).
20
+ // `close()` is idempotent (`if (this.closed) return;`), so calling it after a
21
+ // query that already completed on its own is a harmless no-op.
22
+
23
+ type ClosableTransport = { close?: () => void; process?: { pid?: number } };
24
+ type TeardownQuery = { transport?: ClosableTransport | null };
25
+
26
+ export type TeardownOptions = {
27
+ /** Force-kill the child if it is still alive this long after close(). */
28
+ forceKillAfterMs?: number;
29
+ /** Optional debug sink (wired to index.ts `debug()` at the call sites). */
30
+ log?: (msg: string) => void;
31
+ };
32
+
33
+ const DEFAULT_FORCE_KILL_MS = 2000;
34
+
35
+ /**
36
+ * Close the query's transport and, as a backstop for a CLI that ignores
37
+ * SIGTERM, force-kill its child process if it is still alive shortly after.
38
+ * A no-op for anything that does not look like a closable query.
39
+ */
40
+ export function closeQueryTransport(q: unknown, label: string, options: TeardownOptions = {}): void {
41
+ const transport = (q as TeardownQuery | null | undefined)?.transport;
42
+ if (!transport || typeof transport.close !== "function") return;
43
+
44
+ // Capture the pid before close() nulls out `transport.process`.
45
+ const pid = typeof transport.process?.pid === "number" ? transport.process.pid : undefined;
46
+
47
+ try {
48
+ transport.close();
49
+ options.log?.(`teardown(${label}): transport closed${pid !== undefined ? ` (pid ${pid})` : ""}`);
50
+ } catch (err) {
51
+ options.log?.(`teardown(${label}): transport close failed: ${err instanceof Error ? err.message : String(err)}`);
52
+ }
53
+
54
+ if (pid === undefined) return;
55
+
56
+ // Backstop: if the CLI did not exit on the transport's own terminate(),
57
+ // force-kill it so we never leak a stopped child.
58
+ const wait = options.forceKillAfterMs ?? DEFAULT_FORCE_KILL_MS;
59
+ const timer = setTimeout(() => {
60
+ try {
61
+ process.kill(pid, 0); // throws ESRCH once the child is gone
62
+ } catch {
63
+ return;
64
+ }
65
+ try {
66
+ process.kill(pid, "SIGKILL");
67
+ options.log?.(`teardown(${label}): CLI pid ${pid} survived transport close, sent SIGKILL`);
68
+ } catch {
69
+ // Raced with its own exit — nothing to do.
70
+ }
71
+ }, wait);
72
+ // Never let this safety net hold the process/event loop open.
73
+ timer.unref?.();
74
+ }
75
+
76
+ /** Stop the in-flight turn, then guarantee the CLI child process exits. */
77
+ export function endQuery(q: unknown, label: string, options: TeardownOptions = {}): void {
78
+ try {
79
+ void (q as { interrupt?: () => Promise<unknown> } | null | undefined)?.interrupt?.()?.catch?.(() => {});
80
+ } catch {
81
+ // interrupt() failing on a half-dead query is expected; teardown still runs.
82
+ }
83
+ closeQueryTransport(q, label, options);
84
+ }