pi-web-ui 0.20.0 → 0.21.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.
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { spawn } from "node:child_process";
14
14
  import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
15
- import { dirname, join, relative, resolve, sep } from "node:path";
15
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
18
18
  import { Type } from "typebox";
@@ -225,14 +225,14 @@ function decodeText(buf) {
225
225
  * programs wait for input that never comes. Legacy Chinese files are often
226
226
  * GBK/GB2312 — read them with the right encoding, never paste mojibake into
227
227
  * reasoning/answers. */
228
- const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
229
-
230
-
231
-
232
- - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
233
- - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
234
- - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
235
-
228
+ const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
229
+
230
+
231
+
232
+ - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
233
+ - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
234
+ - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
235
+
236
236
  Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
237
237
  /**
238
238
  * Killable bash tool: wraps the SDK bash tool with operations that register
@@ -750,6 +750,15 @@ async function readDirForUI(abs, rel) {
750
750
  out.length = MAX;
751
751
  return { entries: out, truncated };
752
752
  }
753
+ /** Stable identity of an extension for the enable/disable toggle: the npm
754
+ * spec for packages (survives version bumps), the resolved entry path
755
+ * otherwise. */
756
+ function extensionKey(e) {
757
+ const src = e.sourceInfo;
758
+ if (src?.origin === "package" && src.source)
759
+ return src.source;
760
+ return src?.path ?? e.path;
761
+ }
753
762
  /**
754
763
  * Persists which workspace each browser client last used + which workspaces it
755
764
  * has opened, so a server restart / page reload restores the same project and
@@ -820,6 +829,40 @@ class ClientStateStore {
820
829
  };
821
830
  this.save();
822
831
  }
832
+ /** Last-used settings-panel state for a client, or defaults. */
833
+ getSettings(clientId) {
834
+ const s = this.load()[clientId];
835
+ return {
836
+ promptMode: s?.settings?.promptMode === "replace" ? "replace" : "append",
837
+ customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
838
+ disabledSkills: s?.settings?.disabledSkills ?? [],
839
+ disabledExtensions: s?.settings?.disabledExtensions ?? [],
840
+ };
841
+ }
842
+ /** Persist the client's settings-panel state (partial merge). */
843
+ saveSettings(clientId, settings) {
844
+ const all = this.load();
845
+ const state = (all[clientId] ??= { projects: [] });
846
+ const cur = state.settings ?? {};
847
+ state.settings = {
848
+ promptMode: settings.promptMode ?? cur.promptMode ?? "append",
849
+ customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
850
+ disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
851
+ disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
852
+ };
853
+ this.save();
854
+ }
855
+ /** Named settings presets for a client (empty if never saved). */
856
+ getPresets(clientId) {
857
+ return this.load()[clientId]?.presets ?? [];
858
+ }
859
+ /** Persist the client's named settings presets. */
860
+ savePresets(clientId, presets) {
861
+ const all = this.load();
862
+ const state = (all[clientId] ??= { projects: [] });
863
+ state.presets = presets;
864
+ this.save();
865
+ }
823
866
  }
824
867
  /** Hard cap on how long ONE tool call may run before the watchdog aborts the
825
868
  * session. The SDK bash tool has NO default timeout, so a command that never
@@ -914,6 +957,20 @@ export class ClientSession {
914
957
  /** Guard: the goal wizard and the review loop are mutually exclusive — a
915
958
  * wizard in flight stops review triggers (and vice versa). */
916
959
  goalWizardRunning = false;
960
+ /** Settings-panel state (system prompt + disabled skills/extensions). The
961
+ * resource-loader overrides in makeRuntimeFactory() read this at every
962
+ * reload(), so session.reload() applies changes to the running runtime. */
963
+ settings;
964
+ /** Named settings presets (saved combos the user can re-apply). */
965
+ presets = [];
966
+ /** Settings changed while the run was streaming — the runtime reload is
967
+ * deferred to the next agent_end so an in-flight run is never torn down. */
968
+ pendingSettingsReload = false;
969
+ /** Full (incl. disabled) skill/extension lists seen so far — disabled
970
+ * entries disappear from the loader after reload, so this cache keeps them
971
+ * visible (and re-enableable) in the settings panel. */
972
+ knownSkills = new Map();
973
+ knownExtensions = new Map();
917
974
  /** Aborts the currently-running goal wizard (user clicked ✗ / timed out). Drives
918
975
  * the in-flight goal_ask dialog to resolve as cancelled and (via the run
919
976
  * signal) stops the wizard session's agent run. Recreated per wizard. */
@@ -993,6 +1050,8 @@ export class ClientSession {
993
1050
  this.cwd = cwd;
994
1051
  this.agentDir = agentDir;
995
1052
  this.stateStore = stateStore;
1053
+ this.settings = stateStore.getSettings(clientId);
1054
+ this.presets = stateStore.getPresets(clientId);
996
1055
  }
997
1056
  static async create(clientId, cwd, stateStore) {
998
1057
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
@@ -1040,16 +1099,41 @@ export class ClientSession {
1040
1099
  const services = await createAgentSessionServices({
1041
1100
  cwd: effectiveCwd,
1042
1101
  modelRuntime: this.sharedModelRuntime,
1043
- ...(process.platform === "win32"
1044
- ? {
1045
- // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端是
1046
- // 交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
1047
- // GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
1048
- resourceLoaderOptions: {
1049
- systemPromptOverride: (base) => base ? `${base}\n\n${WINDOWS_PERSONA}` : WINDOWS_PERSONA,
1050
- },
1051
- }
1052
- : {}),
1102
+ // 设置面板钩子(官方 SDK 的 resourceLoader overrides):三个 override
1103
+ // 在每次 resourceLoader.reload() 时重放,且读取 this.settings 的当前
1104
+ // 值——因此 session.reload() 即可让系统提示词 / 技能 / 插件开关生效,
1105
+ // 新对话(新 runtime)也会自动带上当前设置。
1106
+ resourceLoaderOptions: {
1107
+ // 系统提示词:replace 模式整体替换;append 模式追加到提示词末尾。
1108
+ systemPromptOverride: (base) => this.settings.promptMode === "replace" &&
1109
+ this.settings.customSystemPrompt.trim()
1110
+ ? this.settings.customSystemPrompt
1111
+ : base,
1112
+ appendSystemPromptOverride: (base) => {
1113
+ const out = [...base];
1114
+ const custom = this.settings.customSystemPrompt.trim();
1115
+ if (this.settings.promptMode === "append" && custom) {
1116
+ out.push(custom);
1117
+ }
1118
+ if (process.platform === "win32") {
1119
+ // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端
1120
+ // 是交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
1121
+ // GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
1122
+ out.push(WINDOWS_PERSONA);
1123
+ }
1124
+ return out;
1125
+ },
1126
+ // 技能开关:禁用的技能从系统提示词和 /skill: 目录中剔除。
1127
+ skillsOverride: (res) => ({
1128
+ ...res,
1129
+ skills: res.skills.filter((s) => !this.settings.disabledSkills.includes(s.name)),
1130
+ }),
1131
+ // 插件开关:禁用的扩展整个卸载(工具 / 命令随之消失)。
1132
+ extensionsOverride: (res) => ({
1133
+ ...res,
1134
+ extensions: res.extensions.filter((e) => !this.settings.disabledExtensions.includes(extensionKey(e))),
1135
+ }),
1136
+ },
1053
1137
  });
1054
1138
  return {
1055
1139
  ...(await createAgentSessionFromServices({
@@ -1117,6 +1201,9 @@ export class ClientSession {
1117
1201
  // Reconnect: push the remembered goal prefs (model choice, rounds cap,
1118
1202
  // locked) so the goal bar restores them on reload — "全局记忆".
1119
1203
  this.emitGoalStatus();
1204
+ // Reconnect: push the settings panel state (prompt text/mode, skill &
1205
+ // extension toggles, saved presets).
1206
+ this.pushSettings();
1120
1207
  }
1121
1208
  detachSink(send) {
1122
1209
  this.sinks.delete(send);
@@ -1327,6 +1414,13 @@ export class ClientSession {
1327
1414
  !this.disposed) {
1328
1415
  void this.runGoalReview(conv);
1329
1416
  }
1417
+ // Deferred settings reload: settings (system prompt / skills /
1418
+ // extensions) changed while the run was streaming — applying now
1419
+ // would have torn down the in-flight run.
1420
+ if (this.pendingSettingsReload && !this.disposed) {
1421
+ this.pendingSettingsReload = false;
1422
+ void this.applySettingsReload();
1423
+ }
1330
1424
  break;
1331
1425
  }
1332
1426
  case "entry_appended":
@@ -1565,6 +1659,8 @@ export class ClientSession {
1565
1659
  * the process is going to restart itself (so the notice can say so).
1566
1660
  */
1567
1661
  onUpdateReady = undefined;
1662
+ /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
1663
+ onQuit = undefined;
1568
1664
  /** Ask the npm registry for the latest pi-web-ui version and report it. */
1569
1665
  async checkUpdate() {
1570
1666
  const current = ClientSession.currentAppVersion();
@@ -2038,19 +2134,22 @@ export class ClientSession {
2038
2134
  * (they never reach the server) but stay listed so the picker shows them.
2039
2135
  */
2040
2136
  static NATIVE_COMMANDS = [
2041
- { name: "new", description: "新建对话" },
2042
- { name: "model", description: "切换模型", argumentHint: "[名称]" },
2043
- { name: "compact", description: "压缩上下文", argumentHint: "[说明]" },
2044
- { name: "cwd", description: "切换工作目录", argumentHint: "<路径>" },
2137
+ { name: "new", description: "新建对话", descriptionEn: "New chat" },
2138
+ { name: "model", description: "切换模型", descriptionEn: "Switch model", argumentHint: "[名称]", argumentHintEn: "[name]" },
2139
+ { name: "compact", description: "压缩上下文", descriptionEn: "Compact context", argumentHint: "[说明]", argumentHintEn: "[instructions]" },
2140
+ { name: "cwd", description: "切换工作目录", descriptionEn: "Switch workspace", argumentHint: "<路径>", argumentHintEn: "<path>" },
2045
2141
  {
2046
2142
  name: "thinking",
2047
2143
  description: "设置思考强度",
2048
- argumentHint: "<off|low|medium|high>",
2144
+ descriptionEn: "Set thinking level",
2145
+ argumentHint: "<off|low|medium|high|xhigh|max>",
2146
+ argumentHintEn: "<off|low|medium|high|xhigh|max>",
2049
2147
  },
2050
- { name: "resume", description: "刷新会话列表" },
2051
- { name: "reload", description: "重新加载扩展、技能与模板" },
2052
- { name: "help", description: "显示全部命令" },
2053
- { name: "copy", description: "复制上一条助手回复" },
2148
+ { name: "resume", description: "刷新会话列表", descriptionEn: "Refresh session list" },
2149
+ { name: "reload", description: "重新加载扩展、技能与模板", descriptionEn: "Reload extensions, skills & templates" },
2150
+ { name: "help", description: "显示全部命令", descriptionEn: "Show all commands" },
2151
+ { name: "copy", description: "复制上一条助手回复", descriptionEn: "Copy last assistant reply" },
2152
+ { name: "pi-web-ui:quit", description: "退出服务", descriptionEn: "Quit server (supervisor will restart)" },
2054
2153
  ];
2055
2154
  /** Parse a prompt into "/command args" — returns null when it isn't one. */
2056
2155
  parseSlash(text) {
@@ -2190,6 +2289,20 @@ export class ClientSession {
2190
2289
  });
2191
2290
  }
2192
2291
  return true;
2292
+ case "pi-web-ui:quit": {
2293
+ this.emit({
2294
+ type: "notice",
2295
+ level: "info",
2296
+ text: "正在退出 pi-web-ui… supervisor 将自动重启服务",
2297
+ });
2298
+ setTimeout(() => {
2299
+ const didSchedule = this.onQuit?.() ?? false;
2300
+ if (!didSchedule) {
2301
+ setTimeout(() => process.exit(0), 100);
2302
+ }
2303
+ }, 300);
2304
+ return true;
2305
+ }
2193
2306
  case "help":
2194
2307
  case "copy":
2195
2308
  // Client-side UI actions — the client handles them before sending;
@@ -2256,6 +2369,174 @@ export class ClientSession {
2256
2369
  this.emit({ type: "slash_commands", commands });
2257
2370
  }
2258
2371
  // ---------------------------------------------------------------------------
2372
+ // Settings (system prompt / skills / extensions / presets)
2373
+ // ---------------------------------------------------------------------------
2374
+ /** Push the full settings state (current settings + loaded skills/extensions
2375
+ * with enabled flags + saved presets). Pushed on attach and after every
2376
+ * settings change. */
2377
+ pushSettings() {
2378
+ const disabledSkills = new Set(this.settings.disabledSkills);
2379
+ const disabledExts = new Set(this.settings.disabledExtensions);
2380
+ try {
2381
+ // Refresh the cache with the CURRENTLY loaded set (post-filter).
2382
+ for (const s of this.session.resourceLoader.getSkills().skills) {
2383
+ this.knownSkills.set(s.name, {
2384
+ name: s.name,
2385
+ description: s.description,
2386
+ enabled: true,
2387
+ });
2388
+ }
2389
+ for (const e of this.session.resourceLoader.getExtensions().extensions) {
2390
+ const id = extensionKey(e);
2391
+ const p = e.sourceInfo?.path ?? e.path;
2392
+ this.knownExtensions.set(id, {
2393
+ id,
2394
+ name: e.sourceInfo?.origin === "package" && e.sourceInfo.source
2395
+ ? e.sourceInfo.source
2396
+ : basename(p),
2397
+ path: p,
2398
+ enabled: true,
2399
+ });
2400
+ }
2401
+ }
2402
+ catch {
2403
+ // Session not ready yet — keep whatever we already know.
2404
+ }
2405
+ // Disabled entries are filtered out of the loader — keep them in the
2406
+ // panel (with the last-known description) so they can be re-enabled.
2407
+ for (const name of this.settings.disabledSkills) {
2408
+ if (!this.knownSkills.has(name)) {
2409
+ this.knownSkills.set(name, { name, description: "", enabled: false });
2410
+ }
2411
+ }
2412
+ for (const id of this.settings.disabledExtensions) {
2413
+ if (!this.knownExtensions.has(id)) {
2414
+ this.knownExtensions.set(id, {
2415
+ id,
2416
+ name: id.startsWith("npm:") ? id : basename(id),
2417
+ path: "",
2418
+ enabled: false,
2419
+ });
2420
+ }
2421
+ }
2422
+ const skills = [...this.knownSkills.values()]
2423
+ .map((s) => ({ ...s, enabled: !disabledSkills.has(s.name) }))
2424
+ .sort((a, b) => a.name.localeCompare(b.name));
2425
+ const extensions = [...this.knownExtensions.values()]
2426
+ .map((e) => ({ ...e, enabled: !disabledExts.has(e.id) }))
2427
+ .sort((a, b) => a.name.localeCompare(b.name));
2428
+ this.emit({
2429
+ type: "settings_state",
2430
+ settings: {
2431
+ promptMode: this.settings.promptMode,
2432
+ customSystemPrompt: this.settings.customSystemPrompt,
2433
+ disabledSkills: [...this.settings.disabledSkills],
2434
+ disabledExtensions: [...this.settings.disabledExtensions],
2435
+ skills,
2436
+ extensions,
2437
+ presets: this.presets.map((p) => ({ ...p })),
2438
+ },
2439
+ });
2440
+ }
2441
+ /** Persist + apply a partial settings update (prompt text/mode, toggles). */
2442
+ async setSettings(partial) {
2443
+ if (partial.promptMode !== undefined)
2444
+ this.settings.promptMode = partial.promptMode;
2445
+ if (partial.customSystemPrompt !== undefined) {
2446
+ this.settings.customSystemPrompt = partial.customSystemPrompt;
2447
+ }
2448
+ if (partial.disabledSkills !== undefined) {
2449
+ this.settings.disabledSkills = partial.disabledSkills;
2450
+ }
2451
+ if (partial.disabledExtensions !== undefined) {
2452
+ this.settings.disabledExtensions = partial.disabledExtensions;
2453
+ }
2454
+ this.stateStore.saveSettings(this.clientId, this.settings);
2455
+ this.pushSettings();
2456
+ await this.applyRuntimeSettings();
2457
+ }
2458
+ /** Save the CURRENT settings as a named preset (overwrites if exists). */
2459
+ async savePreset(name) {
2460
+ const n = name.trim();
2461
+ if (!n) {
2462
+ this.emit({ type: "notice", level: "error", text: "预设名称不能为空" });
2463
+ return;
2464
+ }
2465
+ const preset = {
2466
+ name: n,
2467
+ promptMode: this.settings.promptMode,
2468
+ customSystemPrompt: this.settings.customSystemPrompt,
2469
+ disabledSkills: [...this.settings.disabledSkills],
2470
+ disabledExtensions: [...this.settings.disabledExtensions],
2471
+ };
2472
+ const existing = this.presets.findIndex((p) => p.name === n);
2473
+ if (existing >= 0)
2474
+ this.presets[existing] = preset;
2475
+ else
2476
+ this.presets.push(preset);
2477
+ this.stateStore.savePresets(this.clientId, this.presets);
2478
+ this.pushSettings();
2479
+ }
2480
+ /** Replace the current settings with the named preset and apply it. */
2481
+ async applyPreset(name) {
2482
+ const p = this.presets.find((x) => x.name === name);
2483
+ if (!p) {
2484
+ this.emit({ type: "notice", level: "error", text: `预设不存在:${name}` });
2485
+ return;
2486
+ }
2487
+ this.settings = {
2488
+ promptMode: p.promptMode,
2489
+ customSystemPrompt: p.customSystemPrompt,
2490
+ disabledSkills: [...p.disabledSkills],
2491
+ disabledExtensions: [...p.disabledExtensions],
2492
+ };
2493
+ this.stateStore.saveSettings(this.clientId, this.settings);
2494
+ this.pushSettings();
2495
+ await this.applyRuntimeSettings();
2496
+ }
2497
+ /** Remove a named preset. */
2498
+ async deletePreset(name) {
2499
+ this.presets = this.presets.filter((p) => p.name !== name);
2500
+ this.stateStore.savePresets(this.clientId, this.presets);
2501
+ this.pushSettings();
2502
+ }
2503
+ /**
2504
+ * Make settings changes effective in the running runtime. The resource-loader
2505
+ * overrides read this.settings at call time, so a reload re-applies them.
2506
+ * Reloading mid-stream would tear down the in-flight run — defer instead.
2507
+ */
2508
+ async applyRuntimeSettings() {
2509
+ if (this.disposed)
2510
+ return;
2511
+ if (this.session.isStreaming) {
2512
+ this.pendingSettingsReload = true;
2513
+ this.emit({
2514
+ type: "notice",
2515
+ level: "info",
2516
+ text: "当前回复进行中,设置将在回复结束后自动应用",
2517
+ });
2518
+ return;
2519
+ }
2520
+ await this.applySettingsReload();
2521
+ }
2522
+ /** session.reload() + refresh the slash-command catalog + push state. */
2523
+ async applySettingsReload() {
2524
+ try {
2525
+ await this.session.reload();
2526
+ await this.pushSlashCommands();
2527
+ this.pushSettings();
2528
+ this.flushSnapshot();
2529
+ this.emit({ type: "notice", level: "info", text: "设置已应用" });
2530
+ }
2531
+ catch (err) {
2532
+ this.emit({
2533
+ type: "notice",
2534
+ level: "error",
2535
+ text: `设置应用失败:${err.message}`,
2536
+ });
2537
+ }
2538
+ }
2539
+ // ---------------------------------------------------------------------------
2259
2540
  // Commands
2260
2541
  // ---------------------------------------------------------------------------
2261
2542
  async prompt(text, attachments) {
@@ -4349,6 +4630,8 @@ export class AgentService {
4349
4630
  * self-update; returns whether the process will restart itself.
4350
4631
  */
4351
4632
  onUpdateReady = undefined;
4633
+ /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
4634
+ onQuit = undefined;
4352
4635
  constructor(cwd, stateFile) {
4353
4636
  this.cwd = cwd;
4354
4637
  this.stateStore = new ClientStateStore(stateFile);
@@ -4394,8 +4677,9 @@ export class AgentService {
4394
4677
  }
4395
4678
  }
4396
4679
  cs.attachSink(send);
4397
- // Forward the update hook (set once by index.ts) to every session.
4680
+ // Forward hooks (set once by index.ts) to every session.
4398
4681
  cs.onUpdateReady = this.onUpdateReady;
4682
+ cs.onQuit = this.onQuit;
4399
4683
  return cs;
4400
4684
  }
4401
4685
  /** Remove a socket from a client's broadcast set (called on socket close). */
@@ -189,6 +189,26 @@ function scheduleUpdateRestart() {
189
189
  return true;
190
190
  }
191
191
  service.onUpdateReady = scheduleUpdateRestart;
192
+ function scheduleQuit() {
193
+ const isLaunchd = process.platform === "darwin" && process.ppid === 1;
194
+ const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
195
+ const inDocker = existsSync("/.dockerenv");
196
+ if (isLaunchd || isSystemd || inDocker) {
197
+ setTimeout(() => {
198
+ console.log("pi-web-ui:quit — shutting down (supervisor will restart)…");
199
+ if (isSystemd)
200
+ process.exit(3);
201
+ void shutdown();
202
+ }, 300);
203
+ return true;
204
+ }
205
+ setTimeout(() => {
206
+ console.log("pi-web-ui:quit — shutting down (restart to reload)…");
207
+ void shutdown();
208
+ }, 300);
209
+ return true;
210
+ }
211
+ service.onQuit = scheduleQuit;
192
212
  wss.on("connection", (ws) => {
193
213
  let clientId = null;
194
214
  let closed = false;
@@ -343,6 +363,26 @@ wss.on("connection", (ws) => {
343
363
  locked: msg.locked,
344
364
  });
345
365
  break;
366
+ case "get_settings":
367
+ cs.pushSettings();
368
+ break;
369
+ case "set_settings":
370
+ void cs.setSettings({
371
+ promptMode: msg.promptMode,
372
+ customSystemPrompt: msg.customSystemPrompt,
373
+ disabledSkills: msg.disabledSkills,
374
+ disabledExtensions: msg.disabledExtensions,
375
+ });
376
+ break;
377
+ case "save_preset":
378
+ void cs.savePreset(msg.name);
379
+ break;
380
+ case "apply_preset":
381
+ void cs.applyPreset(msg.name);
382
+ break;
383
+ case "delete_preset":
384
+ void cs.deletePreset(msg.name);
385
+ break;
346
386
  default:
347
387
  break;
348
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",