@raingor/pi-web-switch 0.6.0 → 0.6.2

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/dist/index.html CHANGED
@@ -9,8 +9,8 @@
9
9
  <link rel="manifest" href="./manifest.webmanifest" />
10
10
  <meta name="theme-color" content="#05090d" />
11
11
  <meta name="description" content="Web UI for pi coding agent — configuration management, session browser, and usage dashboard" />
12
- <script type="module" crossorigin src="./assets/main-CqkZxauO.js"></script>
13
- <link rel="stylesheet" crossorigin href="./assets/main-DiEs78Xc.css">
12
+ <script type="module" crossorigin src="./assets/main-KiBX8nrj.js"></script>
13
+ <link rel="stylesheet" crossorigin href="./assets/main-DF0CuClO.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@raingor/pi-web-switch",
3
3
  "private": false,
4
- "version": "0.6.0",
4
+ "version": "0.6.2",
5
5
  "type": "module",
6
6
  "main": "dist/index.html",
7
7
  "description": "Web UI for pi coding agent — live configuration management, session browser, and memory viewer",
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "fs";
2
2
  import { homedir, platform } from "os";
3
3
  import { join, resolve, dirname, relative, sep } from "path";
4
- import { spawnSync } from "child_process";
4
+ import { spawnSync, spawn } from "child_process";
5
5
  import { DatabaseSync } from "node:sqlite";
6
6
 
7
7
  const PI_DIR = join(homedir(), ".pi", "agent");
@@ -1868,26 +1868,39 @@ export async function optimizeMemory(): Promise<OptimizeMemoryResult> {
1868
1868
  ? cfg.consolidationTimeoutMs
1869
1869
  : 600000;
1870
1870
 
1871
+ // IMPORTANT: use async spawn, NOT spawnSync. This runs inside the Vite dev
1872
+ // server's Node process; a multi-minute spawnSync would block the event loop,
1873
+ // freeze the HMR WebSocket heartbeat, and make the browser's Vite client
1874
+ // force a full page reload when the socket reconnects. Async spawn keeps the
1875
+ // loop free so /api/pi/memory/status polling and HMR stay responsive.
1871
1876
  try {
1872
- const out = spawnSync(bin, args, {
1873
- encoding: "utf8",
1874
- timeout: timeoutMs,
1875
- maxBuffer: 1024 * 1024 * 8,
1877
+ const status2: { code: number | null; killed: boolean; error?: Error } = await new Promise((resolvePromise) => {
1878
+ const child = spawn(bin, args, { stdio: ["ignore", "ignore", "ignore"] });
1879
+ let settled = false;
1880
+ const finish = (r: { code: number | null; killed: boolean; error?: Error }) => {
1881
+ if (settled) return;
1882
+ settled = true;
1883
+ clearTimeout(killTimer);
1884
+ resolvePromise(r);
1885
+ };
1886
+ const killTimer = setTimeout(() => {
1887
+ child.kill("SIGKILL");
1888
+ finish({ code: null, killed: true });
1889
+ }, timeoutMs);
1890
+ child.on("error", (error) => finish({ code: null, killed: false, error }));
1891
+ child.on("close", (code) => finish({ code, killed: false }));
1876
1892
  });
1893
+
1877
1894
  const after = memoryFileSizes();
1878
1895
  const freedBytes = Object.values(before).reduce((a, b) => a + b, 0)
1879
1896
  - Object.values(after).reduce((a, b) => a + b, 0);
1880
- if (out.error) {
1881
- const killed = (out.error as NodeJS.ErrnoException).code === "ETIMEDOUT";
1882
- return {
1883
- success: false,
1884
- before,
1885
- after,
1886
- freedBytes,
1887
- message: killed ? `consolidation timed out after ${Math.round(timeoutMs / 1000)}s` : String(out.error.message),
1888
- };
1897
+ if (status2.error) {
1898
+ return { success: false, before, after, freedBytes, message: String(status2.error.message) };
1889
1899
  }
1890
- return { success: out.status === 0, before, after, freedBytes, message: out.status === 0 ? undefined : `exit ${out.status}` };
1900
+ if (status2.killed) {
1901
+ return { success: false, before, after, freedBytes, message: `consolidation timed out after ${Math.round(timeoutMs / 1000)}s` };
1902
+ }
1903
+ return { success: status2.code === 0, before, after, freedBytes, message: status2.code === 0 ? undefined : `exit ${status2.code}` };
1891
1904
  } catch (e) {
1892
1905
  const after = memoryFileSizes();
1893
1906
  return { success: false, before, after, freedBytes: 0, message: e instanceof Error ? e.message : String(e) };
@@ -2741,6 +2754,60 @@ export function readSubagents(): SubagentsData {
2741
2754
  };
2742
2755
  }
2743
2756
 
2757
+ const AGENT_NAME_RE = /^[\w.-]+\.md$/;
2758
+
2759
+ /**
2760
+ * Patch a single agent's frontmatter field (model / thinking) in place while
2761
+ * preserving the rest of the file byte-for-byte. Rewriting from the parsed
2762
+ * AgentDef would be lossy (body is truncated to 500 chars in listAgents), so we
2763
+ * edit the original file's YAML frontmatter line directly.
2764
+ *
2765
+ * - Updating `model` to a non-empty value replaces or inserts the line.
2766
+ * - An empty string removes the field so the agent falls back to the default.
2767
+ */
2768
+ export function updateAgentFields(
2769
+ fileName: string,
2770
+ patch: { model?: string; thinking?: string }
2771
+ ): boolean {
2772
+ try {
2773
+ // Guard against path traversal: only bare *.md names in the agents dir.
2774
+ if (!AGENT_NAME_RE.test(fileName)) return false;
2775
+ const filePath = join(AGENTS_DIR, fileName);
2776
+ if (!existsSync(filePath)) return false;
2777
+
2778
+ const raw = readFileSync(filePath, "utf-8");
2779
+ if (raw.indexOf("---") !== 0) return false;
2780
+ const second = raw.indexOf("---", 3);
2781
+ if (second === -1) return false;
2782
+
2783
+ const fmBlock = raw.slice(3, second);
2784
+ const rest = raw.slice(second); // starts at closing "---"
2785
+ const lines = fmBlock.replace(/^\n/, "").split("\n");
2786
+
2787
+ const setField = (key: string, value: string | undefined) => {
2788
+ const idx = lines.findIndex((l) => l.trimStart().toLowerCase().startsWith(`${key}:`));
2789
+ if (value === undefined || value === "") {
2790
+ // Remove the field entirely when cleared.
2791
+ if (idx !== -1) lines.splice(idx, 1);
2792
+ return;
2793
+ }
2794
+ const newLine = `${key}: ${value}`;
2795
+ if (idx !== -1) lines[idx] = newLine;
2796
+ else lines.push(newLine);
2797
+ };
2798
+
2799
+ if (patch.model !== undefined) setField("model", patch.model.trim());
2800
+ if (patch.thinking !== undefined) setField("thinking", patch.thinking.trim());
2801
+
2802
+ const newFm = lines.join("\n");
2803
+ const out = `---\n${newFm}\n${rest}`;
2804
+ writeFileSync(filePath, out, "utf-8");
2805
+ return true;
2806
+ } catch {
2807
+ return false;
2808
+ }
2809
+ }
2810
+
2744
2811
  // ─── Built-in Provider Catalog (from the local pi install) ───
2745
2812
  // pi ships its full model catalog (same source as pi.dev/models) inside
2746
2813
  // @earendil-works/pi-ai as dist/providers/data/*.json. Reading it locally
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { useTranslation } from "@/lib/i18n";
3
+ import { useConfigStore } from "@/store/config-store";
3
4
  import { Badge } from "@/components/ui/Badge";
4
5
  import { EmptyState } from "@/components/ui/EmptyState";
5
6
  import { formatTokens } from "@/lib/utils";
@@ -16,6 +17,9 @@ import {
16
17
  Loader2,
17
18
  Search,
18
19
  ExternalLink,
20
+ Pencil,
21
+ Check,
22
+ X,
19
23
  } from "lucide-react";
20
24
 
21
25
  const API_BASE = "/api/pi";
@@ -226,7 +230,7 @@ function AgentList({
226
230
  {/* Right: Agent detail */}
227
231
  <div className="min-w-0 flex-1 p-6">
228
232
  {selected ? (
229
- <AgentDetail agent={selected} />
233
+ <AgentDetail agent={selected} onSaved={onRefresh} />
230
234
  ) : (
231
235
  <div className="flex h-40 items-center justify-center text-sm text-gray-500">
232
236
  {t("providers_models.select_hint")}
@@ -237,8 +241,47 @@ function AgentList({
237
241
  );
238
242
  }
239
243
 
240
- function AgentDetail({ agent }: { agent: AgentDef }) {
244
+ function AgentDetail({ agent, onSaved }: { agent: AgentDef; onSaved: () => void }) {
241
245
  const { t } = useTranslation();
246
+ const { allModels } = useConfigStore();
247
+ const [editing, setEditing] = useState(false);
248
+ const [model, setModel] = useState(agent.model ?? "");
249
+ const [thinking, setThinking] = useState(agent.thinking ?? "");
250
+ const [saving, setSaving] = useState(false);
251
+ const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
252
+
253
+ // Reset the draft whenever a different agent is selected.
254
+ useEffect(() => {
255
+ setModel(agent.model ?? "");
256
+ setThinking(agent.thinking ?? "");
257
+ setEditing(false);
258
+ setMsg(null);
259
+ }, [agent.fileName]);
260
+
261
+ const handleSave = async () => {
262
+ setSaving(true);
263
+ setMsg(null);
264
+ try {
265
+ const res = await fetch(`${API_BASE}/subagents/update-agent`, {
266
+ method: "POST",
267
+ headers: { "Content-Type": "application/json" },
268
+ body: JSON.stringify({ fileName: agent.fileName, model: model.trim(), thinking: thinking.trim() }),
269
+ });
270
+ const { success } = (await res.json()) as { success: boolean };
271
+ if (success) {
272
+ setMsg({ ok: true, text: t("subagents.saved") });
273
+ setEditing(false);
274
+ onSaved();
275
+ } else {
276
+ setMsg({ ok: false, text: t("subagents.save_failed") });
277
+ }
278
+ } catch {
279
+ setMsg({ ok: false, text: t("subagents.save_failed") });
280
+ } finally {
281
+ setSaving(false);
282
+ }
283
+ };
284
+
242
285
  return (
243
286
  <div className="space-y-4">
244
287
  <div className="flex items-center gap-3">
@@ -246,23 +289,94 @@ function AgentDetail({ agent }: { agent: AgentDef }) {
246
289
  <Badge variant={agent.package === "custom" ? "default" : "info"}>
247
290
  {agent.package}
248
291
  </Badge>
292
+ {agent.package === "custom" && !editing && (
293
+ <button
294
+ onClick={() => { setMsg(null); setEditing(true); }}
295
+ className="ml-auto flex items-center gap-1.5 rounded-lg border border-gray-700 px-2.5 py-1 text-xs text-gray-300 hover:bg-gray-800"
296
+ >
297
+ <Pencil className="h-3.5 w-3.5" />
298
+ {t("subagents.edit")}
299
+ </button>
300
+ )}
249
301
  </div>
250
302
 
251
303
  <p className="text-sm text-gray-400">{agent.description}</p>
252
304
 
253
305
  <div className="grid grid-cols-2 gap-4 rounded-lg border border-gray-800 bg-gray-900/70 p-4">
254
- {agent.model && (
255
- <div>
256
- <span className="text-xs text-gray-500">{t("subagents.model")}</span>
257
- <p className="mt-0.5 text-sm text-gray-200 font-mono">{agent.model}</p>
306
+ {/* Model — editable for custom agents */}
307
+ <div className={editing ? "col-span-2" : ""}>
308
+ <span className="text-xs text-gray-500">{t("subagents.model")}</span>
309
+ {editing ? (
310
+ <>
311
+ <input
312
+ type="text"
313
+ list="agent-model-options"
314
+ value={model}
315
+ onChange={(e) => setModel(e.target.value)}
316
+ placeholder={t("subagents.model_placeholder")}
317
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 font-mono text-sm text-gray-100 outline-none focus:ring-1 focus:ring-blue-500"
318
+ />
319
+ <datalist id="agent-model-options">
320
+ {allModels.map((m) => (
321
+ <option key={`${m.providerId}/${m.id}`} value={`${m.providerId}/${m.id}`}>
322
+ {m.providerName} · {m.name ?? m.id}
323
+ </option>
324
+ ))}
325
+ </datalist>
326
+ </>
327
+ ) : (
328
+ <p className="mt-0.5 text-sm text-gray-200 font-mono">{agent.model || t("subagents.model_default")}</p>
329
+ )}
330
+ </div>
331
+
332
+ {/* Thinking — editable for custom agents */}
333
+ <div>
334
+ <span className="text-xs text-gray-500">{t("subagents.thinking")}</span>
335
+ {editing ? (
336
+ <select
337
+ value={thinking}
338
+ onChange={(e) => setThinking(e.target.value)}
339
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-100 outline-none focus:ring-1 focus:ring-blue-500"
340
+ >
341
+ <option value="">{t("subagents.thinking_default")}</option>
342
+ {["off", "minimal", "low", "medium", "high", "xhigh"].map((lvl) => (
343
+ <option key={lvl} value={lvl}>{lvl}</option>
344
+ ))}
345
+ </select>
346
+ ) : (
347
+ <p className="mt-0.5 text-sm text-gray-200">{agent.thinking || t("subagents.thinking_default")}</p>
348
+ )}
349
+ </div>
350
+
351
+ {editing && (
352
+ <div className="col-span-2 flex items-center gap-3">
353
+ <button
354
+ onClick={handleSave}
355
+ disabled={saving}
356
+ className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
357
+ >
358
+ {saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
359
+ {saving ? t("subagents.saving") : t("subagents.save")}
360
+ </button>
361
+ <button
362
+ onClick={() => { setEditing(false); setModel(agent.model ?? ""); setThinking(agent.thinking ?? ""); }}
363
+ disabled={saving}
364
+ className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-4 py-1.5 text-sm text-gray-300 hover:bg-gray-800"
365
+ >
366
+ <X className="h-3.5 w-3.5" />
367
+ {t("subagents.cancel")}
368
+ </button>
369
+ {msg && (
370
+ <span className={`text-xs ${msg.ok ? "text-emerald-400" : "text-red-400"}`}>{msg.text}</span>
371
+ )}
258
372
  </div>
259
373
  )}
260
- {agent.thinking && (
261
- <div>
262
- <span className="text-xs text-gray-500">{t("subagents.thinking")}</span>
263
- <p className="mt-0.5 text-sm text-gray-200">{agent.thinking}</p>
374
+ {!editing && msg && (
375
+ <div className="col-span-2">
376
+ <span className={`text-xs ${msg.ok ? "text-emerald-400" : "text-red-400"}`}>{msg.text}</span>
264
377
  </div>
265
378
  )}
379
+
266
380
  {agent.tools && (
267
381
  <div className="col-span-2">
268
382
  <span className="text-xs text-gray-500">{t("subagents.tools")}</span>
@@ -140,6 +140,15 @@ const en: Record<string, string> = {
140
140
  "subagents.no_history": "No run history",
141
141
  "subagents.no_history_desc": "Subagent run history will appear here after agents are executed.",
142
142
  "subagents.model": "Model",
143
+ "subagents.edit": "Edit",
144
+ "subagents.save": "Save",
145
+ "subagents.saving": "Saving…",
146
+ "subagents.cancel": "Cancel",
147
+ "subagents.saved": "Saved",
148
+ "subagents.save_failed": "Save failed",
149
+ "subagents.model_placeholder": "provider/model-id (blank = default)",
150
+ "subagents.model_default": "(default)",
151
+ "subagents.thinking_default": "(default)",
143
152
  "subagents.thinking": "Thinking Level",
144
153
  "subagents.tools": "Tools",
145
154
  "subagents.system_prompt_mode": "System Prompt Mode",
@@ -138,6 +138,15 @@ const ja: Record<string, string> = {
138
138
  "subagents.no_history": "実行履歴がありません",
139
139
  "subagents.no_history_desc": "エージェントを実行すると、履歴がここに表示されます。",
140
140
  "subagents.model": "モデル",
141
+ "subagents.edit": "編集",
142
+ "subagents.save": "保存",
143
+ "subagents.saving": "保存中…",
144
+ "subagents.cancel": "キャンセル",
145
+ "subagents.saved": "保存しました",
146
+ "subagents.save_failed": "保存に失敗",
147
+ "subagents.model_placeholder": "provider/model-id(空欄で既定)",
148
+ "subagents.model_default": "(既定)",
149
+ "subagents.thinking_default": "(既定)",
141
150
  "subagents.thinking": "思考レベル",
142
151
  "subagents.tools": "ツール",
143
152
  "subagents.system_prompt_mode": "システムプロンプトモード",
@@ -138,6 +138,15 @@ const zhCN: Record<string, string> = {
138
138
  "subagents.no_history": "暂无运行记录",
139
139
  "subagents.no_history_desc": "运行子代理后,运行记录将显示在这里。",
140
140
  "subagents.model": "模型",
141
+ "subagents.edit": "编辑",
142
+ "subagents.save": "保存",
143
+ "subagents.saving": "保存中…",
144
+ "subagents.cancel": "取消",
145
+ "subagents.saved": "已保存",
146
+ "subagents.save_failed": "保存失败",
147
+ "subagents.model_placeholder": "provider/model-id(留空用默认)",
148
+ "subagents.model_default": "(默认)",
149
+ "subagents.thinking_default": "(默认)",
141
150
  "subagents.thinking": "思考级别",
142
151
  "subagents.tools": "工具",
143
152
  "subagents.system_prompt_mode": "系统提示模式",
@@ -137,6 +137,15 @@ const zhTW: Record<string, string> = {
137
137
  "subagents.no_history": "暫無執行記錄",
138
138
  "subagents.no_history_desc": "執行子代理後,執行記錄將顯示在這裡。",
139
139
  "subagents.model": "模型",
140
+ "subagents.edit": "編輯",
141
+ "subagents.save": "儲存",
142
+ "subagents.saving": "儲存中…",
143
+ "subagents.cancel": "取消",
144
+ "subagents.saved": "已儲存",
145
+ "subagents.save_failed": "儲存失敗",
146
+ "subagents.model_placeholder": "provider/model-id(留空用預設)",
147
+ "subagents.model_default": "(預設)",
148
+ "subagents.thinking_default": "(預設)",
140
149
  "subagents.thinking": "思考級別",
141
150
  "subagents.tools": "工具",
142
151
  "subagents.system_prompt_mode": "系統提示模式",
package/vite.config.ts CHANGED
@@ -148,6 +148,22 @@ function piApiPlugin(): Plugin {
148
148
  res.setHeader("Content-Type", "application/json");
149
149
  res.end(JSON.stringify(data));
150
150
  },
151
+ "POST /api/pi/subagents/update-agent"(req, res) {
152
+ let body = "";
153
+ req.on("data", (chunk: string) => (body += chunk));
154
+ req.on("end", () => {
155
+ try {
156
+ const { fileName, model, thinking } = JSON.parse(body) as { fileName: string; model?: string; thinking?: string };
157
+ const ok = pi.updateAgentFields(fileName, { model, thinking });
158
+ res.setHeader("Content-Type", "application/json");
159
+ res.end(JSON.stringify({ success: ok }));
160
+ } catch {
161
+ res.statusCode = 400;
162
+ res.setHeader("Content-Type", "application/json");
163
+ res.end(JSON.stringify({ success: false, error: "Invalid request body" }));
164
+ }
165
+ });
166
+ },
151
167
  "GET /api/pi/memory/config"(_, res) {
152
168
  res.setHeader("Content-Type", "application/json");
153
169
  res.end(JSON.stringify(pi.readHermesMemoryConfig() ?? {}));