@gonrocca/zero-pi 0.1.13 → 0.1.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.
@@ -0,0 +1,111 @@
1
+ // zero-pi — OpenCode model-catalog discovery.
2
+ //
3
+ // Surfaces the model providers a user can run by cross-referencing OpenCode's
4
+ // model catalog cache with the user's OpenCode auth file. This is the same
5
+ // approach the `zero` installer's model picker uses, so `/zero-models` offers
6
+ // the same providers — opencode-go, openai (codex/gpt), anthropic, … — as the
7
+ // installer, instead of a hardcoded Claude list.
8
+ //
9
+ // Sources, under the user's home (ZERO_HOME-overridable for tests):
10
+ // ~/.cache/opencode/models.json — the full provider/model catalog
11
+ // ~/.local/share/opencode/auth.json — the providers the user authenticated
12
+ //
13
+ // Discovery is total: a missing or corrupt file yields an empty list, so a
14
+ // user without OpenCode simply falls back to pi's own model registry.
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ /** A model discovered from OpenCode's catalog. */
21
+ export interface DiscoveredModel {
22
+ /** The provider id, e.g. `opencode-go`, `openai`, `anthropic`. */
23
+ provider: string;
24
+ /** The model id, e.g. `gpt-5-codex`. */
25
+ id: string;
26
+ /** The human-readable model name. */
27
+ name: string;
28
+ /** Whether the model supports tool calls — required for the SDD phases. */
29
+ toolCall: boolean;
30
+ /** Whether the model exposes a reasoning-effort setting. */
31
+ reasoning: boolean;
32
+ }
33
+
34
+ /** The home directory discovery resolves OpenCode paths against. */
35
+ function discoveryHome(): string {
36
+ const override = process.env.ZERO_HOME;
37
+ return typeof override === "string" && override.trim() !== "" ? override : homedir();
38
+ }
39
+
40
+ /** True for a plain (non-array) object. */
41
+ function isObject(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ /** Read and parse a JSON file; returns null on any failure. */
46
+ function readJson(path: string): unknown {
47
+ try {
48
+ return JSON.parse(readFileSync(path, "utf8")) as unknown;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Extract a provider's models from its OpenCode catalog entry.
56
+ *
57
+ * Exported for tests.
58
+ */
59
+ export function extractModels(
60
+ providerId: string,
61
+ entry: Record<string, unknown>,
62
+ ): DiscoveredModel[] {
63
+ const raw = entry.models;
64
+ if (!isObject(raw)) return [];
65
+
66
+ const models: DiscoveredModel[] = [];
67
+ for (const [id, model] of Object.entries(raw)) {
68
+ if (!isObject(model)) continue;
69
+ models.push({
70
+ provider: providerId,
71
+ id,
72
+ name: typeof model.name === "string" ? model.name : id,
73
+ toolCall: model.tool_call === true,
74
+ reasoning: model.reasoning === true,
75
+ });
76
+ }
77
+ return models;
78
+ }
79
+
80
+ /**
81
+ * Discover every model the user can run, flattened across all OpenCode-
82
+ * authenticated providers.
83
+ *
84
+ * `catalogJson` and `authJson` are injectable for tests; in normal use they
85
+ * are read from OpenCode's cache and auth files on disk. The result is empty
86
+ * when either source is absent or malformed.
87
+ */
88
+ export function discoverModels(
89
+ catalogJson?: unknown,
90
+ authJson?: unknown,
91
+ ): DiscoveredModel[] {
92
+ // Only touch disk when an argument is genuinely absent — an explicit value
93
+ // (including `null`) is used as given, so tests never read real files.
94
+ const auth =
95
+ authJson === undefined
96
+ ? readJson(join(discoveryHome(), ".local", "share", "opencode", "auth.json"))
97
+ : authJson;
98
+ const catalog =
99
+ catalogJson === undefined
100
+ ? readJson(join(discoveryHome(), ".cache", "opencode", "models.json"))
101
+ : catalogJson;
102
+ if (!isObject(auth) || !isObject(catalog)) return [];
103
+
104
+ const models: DiscoveredModel[] = [];
105
+ for (const providerId of Object.keys(auth)) {
106
+ const entry = catalog[providerId];
107
+ if (!isObject(entry)) continue;
108
+ models.push(...extractModels(providerId, entry));
109
+ }
110
+ return models;
111
+ }
@@ -0,0 +1,114 @@
1
+ // zero-pi — Windows process-tree kill for aborts.
2
+ //
3
+ // On Windows, `ChildProcess.kill()` terminates only the target process, not
4
+ // its descendants. A provider like pi-claude-cli spawns `claude` through a
5
+ // `cmd.exe` batch wrapper (`claude` resolves to `claude.cmd`), so when pi
6
+ // aborts a turn the wrapper is killed but the real `claude` process is
7
+ // orphaned and keeps streaming — pressing Esc appears to do nothing.
8
+ //
9
+ // This extension patches `child_process.spawn` once, at load, so every
10
+ // subprocess spawned afterwards gets a `kill()` that terminates the whole
11
+ // process tree via `taskkill /T /F`. It is a no-op on non-Windows platforms.
12
+ //
13
+ // Patching the shared `child_process` module reaches code in other packages
14
+ // (pi-claude-cli, and the `cross-spawn` it depends on, both call into the same
15
+ // builtin) without modifying them — so the fix survives `pi update`.
16
+
17
+ import { createRequire } from "node:module";
18
+
19
+ /** Build the Windows command that kills a process and its whole tree. */
20
+ export function treeKillCommand(pid: number): string {
21
+ return `taskkill /pid ${pid} /t /f`;
22
+ }
23
+
24
+ /** The slice of a child process this extension touches. */
25
+ export interface KillableChild {
26
+ pid?: number;
27
+ kill(signal?: NodeJS.Signals | number): boolean;
28
+ }
29
+
30
+ /** Marker so a `kill` is wrapped at most once. */
31
+ const WRAPPED = Symbol.for("zero-pi.win-tree-kill.wrapped");
32
+
33
+ /**
34
+ * Replace `child.kill` with one that terminates the whole process tree by
35
+ * running `exec(treeKillCommand(pid))`. Falls back to the original `kill` when
36
+ * there is no pid or the tree-kill throws. Idempotent — wrapping an already
37
+ * wrapped child is a no-op. Exported for tests.
38
+ */
39
+ export function wrapKill(
40
+ child: KillableChild,
41
+ exec: (command: string) => void,
42
+ ): KillableChild {
43
+ const original = child.kill;
44
+ if (typeof original !== "function") return child;
45
+
46
+ const tagged = original as typeof original & { [WRAPPED]?: boolean };
47
+ if (tagged[WRAPPED]) return child;
48
+
49
+ const wrapped = function (signal?: NodeJS.Signals | number): boolean {
50
+ const pid = child.pid;
51
+ if (typeof pid === "number") {
52
+ try {
53
+ exec(treeKillCommand(pid));
54
+ return true;
55
+ } catch {
56
+ // Process already gone, or taskkill unavailable — fall through.
57
+ }
58
+ }
59
+ return original.call(child, signal);
60
+ } as KillableChild["kill"] & { [WRAPPED]?: boolean };
61
+ wrapped[WRAPPED] = true;
62
+
63
+ child.kill = wrapped;
64
+ return child;
65
+ }
66
+
67
+ /** Whether the running platform needs the tree-kill patch. */
68
+ export function shouldPatch(platform: string): boolean {
69
+ return platform === "win32";
70
+ }
71
+
72
+ /** Module-level guard so the global patch is installed at most once. */
73
+ let patched = false;
74
+
75
+ /**
76
+ * The pi extension entry point. Patches `child_process.spawn` so every later
77
+ * subprocess tree-kills on `kill()`. Defensive: a failure here must never
78
+ * break a pi session, so it is swallowed.
79
+ */
80
+ export default function register(): void {
81
+ if (patched || !shouldPatch(process.platform)) return;
82
+ try {
83
+ const require = createRequire(import.meta.url);
84
+ const cp = require("node:child_process") as {
85
+ spawn: (...args: unknown[]) => KillableChild;
86
+ execSync: (command: string, options?: unknown) => unknown;
87
+ };
88
+
89
+ const originalSpawn = cp.spawn as typeof cp.spawn & { [WRAPPED]?: boolean };
90
+ if (originalSpawn[WRAPPED]) {
91
+ patched = true;
92
+ return;
93
+ }
94
+
95
+ const exec = (command: string): void => {
96
+ cp.execSync(command, { stdio: "ignore" });
97
+ };
98
+
99
+ const patchedSpawn = function (this: unknown, ...args: unknown[]): KillableChild {
100
+ const child = originalSpawn.apply(this, args);
101
+ try {
102
+ return wrapKill(child, exec);
103
+ } catch {
104
+ return child;
105
+ }
106
+ } as typeof cp.spawn & { [WRAPPED]?: boolean };
107
+ patchedSpawn[WRAPPED] = true;
108
+
109
+ cp.spawn = patchedSpawn;
110
+ patched = true;
111
+ } catch {
112
+ // Hardening must never break a session.
113
+ }
114
+ }
@@ -21,6 +21,7 @@ import { join } from "node:path";
21
21
 
22
22
  import { readAutotuneMode, type AutotuneMode } from "./autotune.ts";
23
23
  import type { AutotunePending } from "./autotune-extension.ts";
24
+ import { discoverModels } from "./opencode-models.ts";
24
25
 
25
26
  /** The SDD phases, in pipeline order. */
26
27
  export const PHASES = ["explore", "plan", "build", "veredicto"] as const;
@@ -211,34 +212,56 @@ const CUSTOM_MODEL = "— otro modelo (escribir) —";
211
212
  const CUSTOM_PROVIDER = "— otro provider (escribir) —";
212
213
 
213
214
  /**
214
- * Group the registry's models by provider. Prefers `getAvailable()` (providers
215
- * with configured auth — what the user actually has) and falls back to the
216
- * full `getAll()` set, then to an empty map when no registry is present.
215
+ * Group models by provider for the picker.
216
+ *
217
+ * The primary source is OpenCode's model catalog the same one the `zero`
218
+ * installer's picker uses — so every provider the user authenticated there
219
+ * (opencode-go, openai/codex, anthropic, …) is offered, with only tool-call-
220
+ * capable models (the SDD phases need tool calls). When OpenCode is not
221
+ * present it falls back to pi's own model registry, then to an empty map.
217
222
  */
218
223
  function providerGroups(registry: PiModelRegistry | undefined): Map<string, string[]> {
219
- if (!registry || typeof registry.getAll !== "function") return new Map();
220
224
  try {
221
- const available = typeof registry.getAvailable === "function" ? registry.getAvailable() : [];
222
- const source = available && available.length > 0 ? available : registry.getAll();
223
- return groupByProvider(source ?? []);
225
+ const discovered = discoverModels().filter((m) => m.toolCall);
226
+ if (discovered.length > 0) return groupByProvider(discovered);
224
227
  } catch {
225
- return new Map();
228
+ /* fall through to pi's registry */
229
+ }
230
+ if (registry && typeof registry.getAll === "function") {
231
+ try {
232
+ const all = registry.getAll();
233
+ if (all && all.length > 0) return groupByProvider(all);
234
+ } catch {
235
+ /* fall through */
236
+ }
226
237
  }
238
+ return new Map();
227
239
  }
228
240
 
229
- /** Find the provider that owns a model id, if the registry knows it. */
241
+ /**
242
+ * Find the provider that owns a model id — checked against OpenCode's catalog
243
+ * first, then pi's own registry.
244
+ */
230
245
  function resolveProvider(
231
246
  registry: PiModelRegistry | undefined,
232
247
  modelId: string,
233
248
  ): string | undefined {
234
- if (!registry || typeof registry.getAll !== "function") return undefined;
235
249
  try {
236
- for (const m of registry.getAll()) {
237
- if (m && m.id === modelId && typeof m.provider === "string") return m.provider;
250
+ for (const m of discoverModels()) {
251
+ if (m.id === modelId) return m.provider;
238
252
  }
239
253
  } catch {
240
254
  /* ignore */
241
255
  }
256
+ if (registry && typeof registry.getAll === "function") {
257
+ try {
258
+ for (const m of registry.getAll()) {
259
+ if (m && m.id === modelId && typeof m.provider === "string") return m.provider;
260
+ }
261
+ } catch {
262
+ /* ignore */
263
+ }
264
+ }
242
265
  return undefined;
243
266
  }
244
267
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow, skill auto-learning, and an animated ZERO startup banner. Adds capability to pi without modifying pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -25,6 +25,7 @@
25
25
  "extensions": [
26
26
  "./extensions/startup-banner.ts",
27
27
  "./extensions/working-phrases.ts",
28
+ "./extensions/win-tree-kill.ts",
28
29
  "./extensions/conversation-resume.ts",
29
30
  "./extensions/zero-models.ts",
30
31
  "./extensions/autotune-extension.ts",
@@ -38,8 +39,10 @@
38
39
  "themes",
39
40
  "extensions/startup-banner.ts",
40
41
  "extensions/working-phrases.ts",
42
+ "extensions/win-tree-kill.ts",
41
43
  "extensions/conversation-resume.ts",
42
44
  "extensions/zero-models.ts",
45
+ "extensions/opencode-models.ts",
43
46
  "extensions/autotune.ts",
44
47
  "extensions/autotune-extension.ts",
45
48
  "extensions/spec-merge.ts",