@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.
- package/extensions/opencode-models.ts +111 -0
- package/extensions/win-tree-kill.ts +114 -0
- package/extensions/zero-models.ts +35 -12
- package/package.json +4 -1
|
@@ -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
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|
|
222
|
-
|
|
223
|
-
return groupByProvider(source ?? []);
|
|
225
|
+
const discovered = discoverModels().filter((m) => m.toolCall);
|
|
226
|
+
if (discovered.length > 0) return groupByProvider(discovered);
|
|
224
227
|
} catch {
|
|
225
|
-
|
|
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
|
-
/**
|
|
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
|
|
237
|
-
if (m
|
|
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.
|
|
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",
|