@haoyiyin/9router 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # @haoyiyin/9router
2
+
3
+ pi 扩展 — 9Router provider(含 OpenCode Free `oc/` 模型)+ fetch / search / transcribe 工具。
4
+
5
+ Fork of [`@xynogen/pix-9router`](https://www.npmjs.com/package/@xynogen/pix-9router),修复了 9router 的 `/v1/models` 永远不列出 OpenCode Free `-free` 模型的问题,本扩展直接暴露这些 `oc/` 模型。
6
+
7
+ ## 安装
8
+
9
+ ```
10
+ pi install npm:@haoyiyin/9router
11
+ ```
12
+
13
+ ## 配置
14
+
15
+ 默认无内置地址。运行 `/9router` → 输入 9Router 地址和 API Key → 存入 `~/.pi/agent/9router-oc.json`(权限 `0600`)。Key 已配置时 `/9router` 可留空保持原值。
16
+
17
+ 也可以设置环境变量兜底:`ROUTER_API_BASE` / `ROUTER_API_KEY`(配置文件优先)。
18
+
19
+ ## oc 模型
20
+
21
+ 每 30 分钟自动拉取 `opencode.ai/zen/v1/models` 中 id 以 `-free` 结尾的模型,以 `oc/<id>` 形式注册进 9router provider。列表波动会自动跟随。缓存文件:`~/.cache/pi/zen-oc-models.json`。
22
+
23
+ ## 迁移
24
+
25
+ 如果你装过 `@xynogen/pix-9router`:
26
+
27
+ ```
28
+ pi remove npm:@xynogen/pix-9router
29
+ ```
30
+
31
+ 并(可选)删除 `~/.zshrc` 里的 `ROUTER_API_KEY` / `ROUTER_API_BASE` export。
32
+
33
+ ## 工具
34
+
35
+ `fetch` / `search` / `transcribe` 与 `pix-9router` 一致;collapse 配置仍在 `~/.pi/agent/pix.json`。
36
+
37
+ ## License
38
+
39
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@haoyiyin/9router",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "description": "Pi extension — 9Router provider (OpenCode Free oc/-free models + router models) + fetch/search/transcribe tools",
7
+ "files": [
8
+ "src",
9
+ "!src/**/*.test.ts",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "test": "bun test"
15
+ },
16
+ "pi": {
17
+ "extensions": [
18
+ "src/index.ts"
19
+ ]
20
+ },
21
+ "dependencies": {
22
+ "@xynogen/pix-data": "*",
23
+ "@xynogen/pix-pretty": "^1.11.2",
24
+ "typebox": "^1.1.38",
25
+ "@xynogen/pix-runtime": "^0.8.0"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-coding-agent": "*",
29
+ "@earendil-works/pi-ai": "*",
30
+ "@earendil-works/pi-tui": "*"
31
+ },
32
+ "keywords": [
33
+ "pi",
34
+ "pi-package",
35
+ "pi-extension",
36
+ "9router",
37
+ "opencode"
38
+ ],
39
+ "author": "yi",
40
+ "license": "MIT",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
package/src/command.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadConfig, saveConfig, DEFAULT_BASE } from "./config.ts";
3
+ import { registerProvider } from "./provider.ts";
4
+
5
+ // Never contains a real key. If a dialog pre-fills this placeholder as an
6
+ // editable default, an exact match is treated as "keep the current key".
7
+ const API_KEY_PLACEHOLDER = "sk-...(留空保持已配置的 key)";
8
+
9
+ export function registerCommand(pi: ExtensionAPI): void {
10
+ pi.registerCommand("9router", {
11
+ description: "配置 9Router 地址和 API Key(存 ~/.pi/agent/9router-oc.json)",
12
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
13
+ const current = loadConfig();
14
+
15
+ const baseUrl = await ctx.ui.input("9Router 地址", current.baseUrl || DEFAULT_BASE);
16
+ if (baseUrl === undefined) return;
17
+ const trimmedBase = baseUrl.trim();
18
+ const normalizedBase = trimmedBase.endsWith("/")
19
+ ? trimmedBase.slice(0, -1)
20
+ : trimmedBase;
21
+
22
+ const apiKey = await ctx.ui.input("9Router API Key", API_KEY_PLACEHOLDER);
23
+ if (apiKey === undefined) return;
24
+ const trimmedKey = apiKey.trim();
25
+
26
+ let finalKey: string;
27
+ if (trimmedKey === "" || trimmedKey === API_KEY_PLACEHOLDER) {
28
+ // Keep the existing key; never treat the placeholder as a submitted value.
29
+ if (!current.apiKey) {
30
+ ctx.ui.notify("未设置 API Key", "error");
31
+ return;
32
+ }
33
+ finalKey = current.apiKey;
34
+ } else {
35
+ finalKey = trimmedKey;
36
+ }
37
+
38
+ saveConfig({ baseUrl: normalizedBase, apiKey: finalKey });
39
+
40
+ try {
41
+ await registerProvider(pi);
42
+ } catch (err) {
43
+ ctx.ui.notify(
44
+ `9Router 已保存,但重新注册 provider 失败: ${err instanceof Error ? err.message : String(err)}`,
45
+ "warning",
46
+ );
47
+ }
48
+
49
+ ctx.ui.notify(`9Router 已保存: ${normalizedBase}`, "info");
50
+ },
51
+ });
52
+ }
package/src/config.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { homedir } from "node:os";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export const DEFAULT_BASE = "";
6
+
7
+ export interface RouterConfig {
8
+ baseUrl: string;
9
+ apiKey: string;
10
+ }
11
+
12
+ export function configPath(): string {
13
+ return join(homedir(), ".pi", "agent", "9router-oc.json");
14
+ }
15
+
16
+ export function loadConfig(): RouterConfig {
17
+ const path = configPath();
18
+ let fileBase: string | undefined;
19
+ let fileKey: string | undefined;
20
+
21
+ if (existsSync(path)) {
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
24
+ if (parsed && typeof parsed === "object") {
25
+ if (typeof parsed.baseUrl === "string") fileBase = parsed.baseUrl;
26
+ if (typeof parsed.apiKey === "string") fileKey = parsed.apiKey;
27
+ }
28
+ } catch {
29
+ // tolerate malformed config -> fall back to env/defaults
30
+ }
31
+ }
32
+
33
+ let baseUrl = fileBase ?? process.env.ROUTER_API_BASE ?? DEFAULT_BASE;
34
+ if (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
35
+
36
+ const apiKey = fileKey ?? process.env.ROUTER_API_KEY ?? "";
37
+
38
+ return { baseUrl, apiKey };
39
+ }
40
+
41
+ export function saveConfig(cfg: RouterConfig): void {
42
+ const path = configPath();
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ writeFileSync(path, JSON.stringify(cfg, null, 2), { mode: 0o600 });
45
+ try {
46
+ chmodSync(path, 0o600);
47
+ } catch {
48
+ // best-effort enforcement on existing files
49
+ }
50
+ }
package/src/data.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * data.ts — 9Router data layer
3
+ *
4
+ * Router-specific data only:
5
+ * - routerBaseUrl() — resolved API base URL from config
6
+ * - routerApiKey() — resolved API key from config
7
+ * - routerModels — DataSource<RouterModel[]>, cached 30m at ~/.cache/pi/9router.json
8
+ *
9
+ * Config (base URL + key, with env fallback) is owned by ./config.ts.
10
+ *
11
+ * models.dev + modelgrep data is provided by @xynogen/pix-data, which shares
12
+ * the same ~/.cache/pi/ cache directory. The DataSource implementation and the
13
+ * models.dev index helpers live there — imported here, not duplicated.
14
+ */
15
+
16
+ import { join } from "node:path";
17
+ import { CACHE_DIR, DataSource } from "@xynogen/pix-data/src/index.ts";
18
+
19
+ export { CACHE_DIR, DataSource };
20
+ export {
21
+ buildModelsDevIndex,
22
+ fetchModelsDevIndex,
23
+ lookupInIndex,
24
+ } from "@xynogen/pix-data/src/index.ts";
25
+ export type {
26
+ ModelGrepModel,
27
+ ModelsDevApi,
28
+ ModelsDevModel,
29
+ } from "@xynogen/pix-data/src/index.ts";
30
+
31
+ import { loadConfig } from "./config.ts";
32
+
33
+ // ── Types ─────────────────────────────────────────────────────────────────────
34
+
35
+ export interface RouterModel {
36
+ id?: string;
37
+ name?: string;
38
+ context_window?: number;
39
+ contextWindow?: number;
40
+ max_tokens?: number;
41
+ maxTokens?: number;
42
+ owned_by?: string;
43
+ /** 9router v1 API nests these inside capabilities */
44
+ capabilities?: {
45
+ contextWindow?: number;
46
+ maxOutput?: number;
47
+ [key: string]: unknown;
48
+ };
49
+ }
50
+
51
+ interface RouterModelsResponse {
52
+ data?: RouterModel[];
53
+ }
54
+
55
+ // ── Resolved config helpers ───────────────────────────────────────────────────
56
+
57
+ export function routerBaseUrl(): string {
58
+ return loadConfig().baseUrl;
59
+ }
60
+
61
+ export function routerApiKey(): string {
62
+ return loadConfig().apiKey;
63
+ }
64
+
65
+ // ── Router models ──────────────────────────────────────────────────────────────
66
+
67
+ export const routerModels = new DataSource<RouterModel[]>({
68
+ label: "9router",
69
+ url: () => `${routerBaseUrl()}/models`,
70
+ headers: () => {
71
+ const key = routerApiKey();
72
+ return key ? { Authorization: `Bearer ${key}` } : undefined;
73
+ },
74
+ skip: () => !routerApiKey(),
75
+ cachePath: join(CACHE_DIR, "9router.json"),
76
+ ttlMs: 30 * 60 * 1000, // 30 minutes
77
+ parse: (raw) => ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
78
+ parseCache: (data) =>
79
+ ((data as RouterModelsResponse | undefined)?.data ?? []).filter((m) => Boolean(m.id)),
80
+ empty: [],
81
+ });
package/src/fetch.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * fetch.ts — fetch tool via 9Router API, with curl fallback.
3
+ *
4
+ * Environment:
5
+ * ROUTER_API_BASE — router API base URL (default: https://9router.example.com/v1)
6
+ * ROUTER_API_KEY — bearer token for the router
7
+ */
8
+
9
+ import { StringEnum } from "@earendil-works/pi-ai";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { Type } from "typebox";
12
+ import { apiPost, curl, isCancelled } from "./http.ts";
13
+ import { makeRenderCall, makeRenderResult } from "./render.ts";
14
+
15
+ type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
16
+ type FetchFormat = "markdown" | "text" | "html";
17
+
18
+ export interface FetchResultDetails {
19
+ _type: "fetchResult";
20
+ outcome: RouterOutcome;
21
+ url: string;
22
+ format: FetchFormat;
23
+ source?: "api" | "curl-fallback" | "failed";
24
+ chars?: number;
25
+ truncated?: boolean;
26
+ }
27
+
28
+ export interface FetchParams {
29
+ url: string;
30
+ format?: FetchFormat;
31
+ max_characters?: number;
32
+ }
33
+
34
+ interface FetchResult {
35
+ content: { type: "text"; text: string }[];
36
+ details: FetchResultDetails;
37
+ isError?: boolean;
38
+ }
39
+
40
+ interface FetchDependencies {
41
+ apiPost: typeof apiPost;
42
+ curl: typeof curl;
43
+ }
44
+
45
+ /**
46
+ * Extract readable page content from the exa fetch JSON envelope.
47
+ * Shape: { provider, url, title, content: { format, text }, ... }
48
+ * Falls back to the raw string when it isn't the expected JSON shape.
49
+ */
50
+ export function formatFetchResult(raw: string): string {
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch {
55
+ return raw;
56
+ }
57
+ if (typeof parsed !== "object" || parsed === null) return raw;
58
+
59
+ const obj = parsed as {
60
+ title?: string | null;
61
+ url?: string | null;
62
+ content?: { text?: string | null } | string | null;
63
+ };
64
+
65
+ const text = typeof obj.content === "string" ? obj.content : (obj.content?.text ?? undefined);
66
+ if (typeof text !== "string") return raw;
67
+
68
+ const header: string[] = [];
69
+ if (obj.title) header.push(`# ${obj.title}`);
70
+ if (obj.url) header.push(`URL: ${obj.url}`);
71
+
72
+ return header.length > 0 ? `${header.join("\n")}\n\n${text}` : text;
73
+ }
74
+
75
+ export async function executeFetch(
76
+ params: FetchParams,
77
+ signal?: AbortSignal,
78
+ onUpdate?: (result: FetchResult) => void,
79
+ dependencies: FetchDependencies = { apiPost, curl },
80
+ ): Promise<FetchResult> {
81
+ const maxChars = params.max_characters ?? 1000;
82
+ const fmt = params.format ?? "markdown";
83
+ let apiMsg = "";
84
+
85
+ try {
86
+ onUpdate?.({
87
+ content: [{ type: "text", text: `Fetching (${fmt}): ${params.url}...` }],
88
+ details: {
89
+ _type: "fetchResult",
90
+ outcome: "running",
91
+ url: params.url,
92
+ format: fmt,
93
+ },
94
+ });
95
+
96
+ const raw = await dependencies.apiPost(
97
+ "/web/fetch",
98
+ {
99
+ model: "exa",
100
+ url: params.url,
101
+ format: fmt,
102
+ max_characters: maxChars,
103
+ },
104
+ signal,
105
+ );
106
+
107
+ const formatted = formatFetchResult(raw);
108
+ const truncated =
109
+ maxChars > 0 && formatted.length > maxChars
110
+ ? `${formatted.slice(0, maxChars)}\n\n[truncated]`
111
+ : formatted;
112
+
113
+ return {
114
+ content: [{ type: "text", text: truncated.slice(0, 20_000) }],
115
+ details: {
116
+ _type: "fetchResult",
117
+ outcome: "success",
118
+ url: params.url,
119
+ format: fmt,
120
+ source: "api",
121
+ chars: formatted.length,
122
+ truncated: maxChars > 0 && formatted.length > maxChars,
123
+ },
124
+ };
125
+ } catch (apiErr: unknown) {
126
+ apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
127
+ if (isCancelled(apiErr, signal)) {
128
+ return {
129
+ content: [{ type: "text", text: `Fetch cancelled.\nAPI: ${apiMsg}` }],
130
+ details: {
131
+ _type: "fetchResult",
132
+ outcome: "cancelled",
133
+ url: params.url,
134
+ format: fmt,
135
+ },
136
+ };
137
+ }
138
+ onUpdate?.({
139
+ content: [
140
+ {
141
+ type: "text",
142
+ text: `API failed: ${apiMsg}\nFalling back to curl...`,
143
+ },
144
+ ],
145
+ details: {
146
+ _type: "fetchResult",
147
+ outcome: "running",
148
+ url: params.url,
149
+ format: fmt,
150
+ },
151
+ });
152
+ }
153
+
154
+ try {
155
+ let html = await dependencies.curl(["-L", params.url]);
156
+ const chars = html.length;
157
+ const truncated = maxChars > 0 && html.length > maxChars;
158
+ if (truncated) html = `${html.slice(0, maxChars)}\n\n[truncated]`;
159
+
160
+ const banner =
161
+ "[FALLBACK — raw curl] API unavailable, content may differ from exa formatted output.";
162
+ const body =
163
+ fmt === "html"
164
+ ? `${banner}\n\n${html.slice(0, 19_500)}`
165
+ : `${banner}\n\nRaw HTML (format='${fmt}' not applied):\n\n${html.slice(0, 19_000)}`;
166
+
167
+ return {
168
+ content: [{ type: "text", text: body.slice(0, 20_000) }],
169
+ details: {
170
+ _type: "fetchResult",
171
+ outcome: "fallback",
172
+ url: params.url,
173
+ format: fmt,
174
+ source: "curl-fallback",
175
+ chars,
176
+ truncated,
177
+ },
178
+ };
179
+ } catch (curlErr: unknown) {
180
+ const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
181
+ return {
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text: `Fetch failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
186
+ },
187
+ ],
188
+ details: {
189
+ _type: "fetchResult",
190
+ outcome: "error",
191
+ url: params.url,
192
+ format: fmt,
193
+ source: "failed",
194
+ },
195
+ isError: true,
196
+ };
197
+ }
198
+ }
199
+
200
+ export default function registerFetch(pi: ExtensionAPI): void {
201
+ pi.registerTool({
202
+ name: "fetch",
203
+ label: "Fetch",
204
+ renderShell: "self",
205
+ description: "Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
206
+ promptSnippet:
207
+ "fetch(url, format, max_characters?) — format: 'markdown', 'text', or 'html'. Read page content via exa.",
208
+ promptGuidelines: [
209
+ "Use fetch when you need to read the full content of a specific URL.",
210
+ "Prefer format='markdown' for readable content, 'text' for plain text extraction, 'html' for raw source.",
211
+ "Set max_characters to cap response size (default 1000, 0 = unlimited). Use 5000-10000 for typical pages.",
212
+ ],
213
+ renderCall: makeRenderCall<unknown>("fetch", (args) =>
214
+ String((args as Partial<FetchParams>).url ?? ""),
215
+ ),
216
+ renderResult: (
217
+ (renderTerminal) => (result, options, theme, context) =>
218
+ renderTerminal(result, options, theme, {
219
+ ...context,
220
+ isError: context.isError && options.expanded,
221
+ })
222
+ )(
223
+ makeRenderResult<FetchResultDetails>({
224
+ tool: "fetch",
225
+ target: (details) => details.url,
226
+ meta: (details) => {
227
+ if (details.outcome === "error") return "failed";
228
+ if (details.outcome === "cancelled") return "cancelled";
229
+ const chars = `${details.chars ?? 0} chars`;
230
+ return details.outcome === "fallback"
231
+ ? `${chars} · curl fallback`
232
+ : `${chars} · ${details.format}`;
233
+ },
234
+ status: (details) =>
235
+ details.outcome === "error"
236
+ ? "error"
237
+ : details.outcome === "fallback" || details.outcome === "cancelled"
238
+ ? "warning"
239
+ : "success",
240
+ }),
241
+ ),
242
+ parameters: Type.Object({
243
+ url: Type.String({ description: "URL to fetch" }),
244
+ format: Type.Optional(
245
+ StringEnum(["markdown", "text", "html"] as const, {
246
+ description:
247
+ 'Optional choice. Enter exactly "markdown" for readable Markdown, "text" for plain text, or "html" for raw HTML. Default is "markdown".',
248
+ }),
249
+ ),
250
+ max_characters: Type.Optional(
251
+ Type.Number({
252
+ description: "Max characters (default 1000, 0 = unlimited)",
253
+ default: 1000,
254
+ }),
255
+ ),
256
+ }),
257
+
258
+ async execute(_toolCallId, params, signal, onUpdate) {
259
+ return executeFetch(params as FetchParams, signal, onUpdate);
260
+ },
261
+ });
262
+ }
package/src/http.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * http.ts — shared 9Router HTTP plumbing.
3
+ *
4
+ * auth / apiPost / curl / isCancelled were duplicated across fetch.ts,
5
+ * search.ts and transcribe.ts. Extracted here; the only real differences were
6
+ * timeout values (audio needs longer) and curl stdin (search pipes a body),
7
+ * so both are parameters with the old per-file defaults.
8
+ */
9
+
10
+ import { type ExecFileException, execFile } from "node:child_process";
11
+ import { ioTimeoutMs, ioTimeoutSignal } from "@xynogen/pix-runtime/io";
12
+ import { loadConfig } from "./config.ts";
13
+ import { routerBaseUrl } from "./data.ts";
14
+
15
+ export function auth(): string | undefined {
16
+ return loadConfig().apiKey || undefined;
17
+ }
18
+
19
+ /** POST a JSON body to a router path, returning the raw response text. */
20
+ export async function apiPost(
21
+ path: string,
22
+ body: Record<string, unknown>,
23
+ signal?: AbortSignal,
24
+ timeoutMs: number = ioTimeoutMs(),
25
+ ): Promise<string> {
26
+ const url = `${routerBaseUrl()}${path}`;
27
+ const key = auth();
28
+ const timeoutSignal =
29
+ timeoutMs === ioTimeoutMs() ? ioTimeoutSignal() : AbortSignal.timeout(timeoutMs);
30
+ const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
31
+ const res = await fetch(url, {
32
+ method: "POST",
33
+ headers: {
34
+ "Content-Type": "application/json",
35
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
36
+ },
37
+ body: JSON.stringify(body),
38
+ signal: requestSignal,
39
+ });
40
+ if (!res.ok) {
41
+ const errText = await res.text().catch(() => "");
42
+ throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
43
+ }
44
+ return res.text();
45
+ }
46
+
47
+ export interface CurlOptions {
48
+ /** curl --max-time in seconds (default 25). */
49
+ maxTime?: number;
50
+ /** child-process kill timeout in ms (default 30_000). */
51
+ timeoutMs?: number;
52
+ /** Optional stdin body piped into curl. */
53
+ stdin?: string;
54
+ }
55
+
56
+ /** Run curl with sane defaults, rejecting on non-zero exit or timeout. */
57
+ export function curl(args: string[], opts: CurlOptions = {}): Promise<string> {
58
+ const timeoutMs = opts.timeoutMs ?? ioTimeoutMs();
59
+ const maxTime = opts.maxTime ?? timeoutMs / 1000;
60
+ const { stdin } = opts;
61
+ return new Promise((resolve, reject) => {
62
+ const child = execFile(
63
+ "curl",
64
+ ["-sS", "--connect-timeout", "10", "--max-time", String(maxTime), ...args],
65
+ { maxBuffer: 10 * 1024 * 1024, timeout: timeoutMs },
66
+ (err, stdout, stderr) => {
67
+ if (err) {
68
+ const e = err as ExecFileException;
69
+ const msg = e.killed
70
+ ? "curl timed out"
71
+ : `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
72
+ reject(new Error(msg));
73
+ return;
74
+ }
75
+ resolve(stdout);
76
+ },
77
+ );
78
+ if (stdin && child.stdin) {
79
+ child.stdin.write(stdin);
80
+ child.stdin.end();
81
+ }
82
+ });
83
+ }
84
+
85
+ export function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
86
+ return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
87
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import registerFetch from "./fetch.ts";
3
+ import registerSearch from "./search.ts";
4
+ import registerTranscribe from "./transcribe.ts";
5
+ import { registerProvider } from "./provider.ts";
6
+ import { registerCommand } from "./command.ts";
7
+
8
+ export default async function (pi: ExtensionAPI): Promise<void> {
9
+ registerFetch(pi);
10
+ registerSearch(pi);
11
+ registerTranscribe(pi);
12
+
13
+ registerCommand(pi);
14
+
15
+ await registerProvider(pi);
16
+
17
+ let refreshing = false;
18
+ const timer = setInterval(
19
+ async () => {
20
+ // ponytail: models refresh by unregister+register; zen cache TTL is
21
+ // 30m so refetch happens naturally. Overlap guard avoids re-entrancy.
22
+ if (refreshing) return;
23
+ refreshing = true;
24
+ try {
25
+ await registerProvider(pi);
26
+ } finally {
27
+ refreshing = false;
28
+ }
29
+ },
30
+ 30 * 60 * 1000,
31
+ );
32
+ if (typeof (timer as { unref?: () => void }).unref === "function") {
33
+ (timer as { unref: () => void }).unref();
34
+ }
35
+ }