agent-purge 0.2.8 → 0.2.9

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/lib/slashes.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // 会话内 `/purge`:把当前 persona 装进去,之后在这个会话里说一句 `/purge` 就切。
2
2
  //
3
3
  // 这里是整个项目里唯一"照实说实话"的地方:**斜杠命令不等于系统提示词注入**。
4
- // pi —— 扩展能在 before_agent_start 改写 systemPrompt → sys 层,热切换(实测 100%)
4
+ // pi / omp —— 扩展能在 before_agent_start 改写 systemPrompt → sys 层,热切换(实测 100%)
5
5
  // claude —— ~/.claude/commands/purge.md 是**用户消息展开** → user 层
6
6
  // codex —— ~/.codex/prompts/purge.md 同上 → user 层
7
7
  // opencode —— ~/.config/opencode/command/purge.md 同上 → user 层(sys 层要靠 plugin 常驻)
@@ -9,7 +9,7 @@
9
9
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { detect } from "./detect.mjs";
12
- import { SLASH_RESIDENT, addPlugin, hasPlugin, pluginDir, removePlugin, slashSource } from "./dsh.mjs";
12
+ import { SLASH_RESIDENT, addPlugin, hasPluginAny, pluginFileOnDisk, removePlugin, slashSource } from "./dsh.mjs";
13
13
 
14
14
  const SIDECAR = ".disabled-by-agent-purge";
15
15
 
@@ -70,6 +70,19 @@ export function resolveSlash(id, root) {
70
70
  note: "opencode 的 command 是用户消息 → user 层(bench 实测 0/2 无效);要 sys 层请用 install(chat.system.transform 插件)",
71
71
  };
72
72
  },
73
+ omp: () => {
74
+ const base = homeOf("omp");
75
+ if (!base) return null;
76
+ return {
77
+ layer: "sys",
78
+ offered: true,
79
+ hot: true,
80
+ trigger: "/purge on|off|status|mode",
81
+ file: join(base, "agent", "extensions", "agent-purge.ts"),
82
+ render: () => ompExtension(root),
83
+ note: "扩展在 before_agent_start 改写 systemPrompt 数组:真系统提示词层,/purge 热切,下一轮生效",
84
+ };
85
+ },
73
86
  // dsh:不是写文件,而是往三个 profile 各装一个 cordis 小插件(agent-purge-slash)。
74
87
  // 它只注册 /purge 命令、改 state.json,不碰系统提示词 —— 注入归 agent-purge 常驻插件。
75
88
  dsh: () => ({
@@ -77,7 +90,9 @@ export function resolveSlash(id, root) {
77
90
  offered: true,
78
91
  hot: true,
79
92
  trigger: "/purge on|off|status|mode",
80
- file: join(pluginDir("desktop", SLASH_RESIDENT), "index.js"),
93
+ file: pluginFileOnDisk(SLASH_RESIDENT),
94
+ // 真值看挂载块 + 文件,不看某个 profile 的固定路径(没建过 desktop 就无意义);任一份在就算能用
95
+ probe: () => hasPluginAny(SLASH_RESIDENT),
81
96
  render: () => slashSource(root),
82
97
  note: "cordis 插件注册 /purge(/ap 为别名):改 state.json,下一句生效,不产生模型消息",
83
98
  }),
@@ -96,7 +111,7 @@ export function installSlash(id, root, { personaId, personaBody }) {
96
111
  // dsh:装 cordis 插件到三个 profile,没有单文件可写
97
112
  if (id === "dsh") {
98
113
  const r = addPlugin(SLASH_RESIDENT, slashSource(root));
99
- return { file: s.file, layer: s.layer, note: s.note, sidecars: [], profiles: r.profiles };
114
+ return { file: pluginFileOnDisk(SLASH_RESIDENT), layer: s.layer, note: s.note, sidecars: [], profiles: r.profiles };
100
115
  }
101
116
  mkdirSync(dirname(s.file), { recursive: true });
102
117
 
@@ -140,14 +155,14 @@ export function uninstallSlash(id, root) {
140
155
 
141
156
  export function slashStatus(root) {
142
157
  const out = [];
143
- for (const id of Object.keys({ pi: 1, claude: 1, codex: 1, opencode: 1, dsh: 1 })) {
158
+ for (const id of Object.keys({ pi: 1, omp: 1, claude: 1, codex: 1, opencode: 1, dsh: 1 })) {
144
159
  const s = resolveSlash(id, root);
145
160
  if (!s) continue;
146
- const installed = id === "dsh" ? hasPlugin(SLASH_RESIDENT) : existsSync(s.file);
161
+ const installed = s.probe ? s.probe() : existsSync(s.file);
147
162
  let personaId = null;
148
- if (installed) {
163
+ if (installed && existsSync(s.file)) {
149
164
  const text = readFileSync(s.file, "utf8");
150
- personaId = (text.match(/agent-purge: ([a-z0-9_-]+)/i) || [])[1] ?? (id === "pi" ? "(跟随 state.json)" : null);
165
+ personaId = (text.match(/agent-purge: ([a-z0-9_-]+)/i) || [])[1] ?? (id === "pi" || id === "omp" ? "(跟随 state.json)" : null);
151
166
  }
152
167
  out.push({ id, layer: s.layer, offered: s.offered !== false, hot: s.hot, file: s.file, installed, personaId, trigger: s.trigger, note: s.note });
153
168
  }
@@ -222,13 +237,13 @@ function loadState(): { persona: string | null; mode: string; shim: boolean; piA
222
237
  };
223
238
  return {
224
239
  persona: envPersona ?? st.persona ?? null,
225
- mode: envMode ?? st.default?.mode ?? "replace",
240
+ mode: st.default?.mode ?? "replace",
226
241
  shim: Array.isArray(st.armed?.pi) && st.armed.pi.includes("shim"),
227
242
  piAuto: st.piAuto === true,
228
243
  piUserLayer: st.piUserLayer === true,
229
244
  };
230
245
  } catch {
231
- return { persona: envPersona ?? null, mode: envMode ?? "replace", shim: false, piAuto: process.env.AGENT_PURGE_FORCE === "1", piUserLayer: false };
246
+ return { persona: envPersona ?? null, mode: "replace", shim: false, piAuto: process.env.AGENT_PURGE_FORCE === "1", piUserLayer: false };
232
247
  }
233
248
  }
234
249
 
@@ -245,6 +260,11 @@ export default function agentPurge(pi: ExtensionAPI) {
245
260
  // 所以“只装会话内热切”的扩展不会自己开工;直呼真身二进制也不会被劫持。
246
261
  let forced: "on" | "off" | null = null;
247
262
  let mode: "strict" | "replace" | "append" = "strict";
263
+ // 启动器用 AGENT_PURGE_MODE 指定本次启动的初始模式;/purge mode 必须能覆盖它,
264
+ // 否则环境变量会在每轮把热切换改回去。
265
+ const ENV_MODE = process.env.AGENT_PURGE_MODE;
266
+ let modeOverride: "strict" | "replace" | "append" | "off" | null =
267
+ ENV_MODE === "off" ? "off" : ENV_MODE === "strict" || ENV_MODE === "append" ? ENV_MODE : ENV_MODE ? "replace" : null;
248
268
  let cache = { id: null as string | null, body: "" };
249
269
 
250
270
  // 两条来源:启动器点名(FORCE=1),或 state.json 的 piAuto(裸敲 pi 也注入)
@@ -258,8 +278,9 @@ export default function agentPurge(pi: ExtensionAPI) {
258
278
 
259
279
  pi.on("before_agent_start", async (event) => {
260
280
  const st = loadState();
261
- mode = st.mode === "strict" || st.mode === "append" ? st.mode : "replace";
262
- const on = st.mode === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
281
+ const effective = modeOverride ?? st.mode;
282
+ mode = effective === "strict" || effective === "append" ? effective : "replace";
283
+ const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
263
284
  if (on !== "on") return;
264
285
  cache = loadPersona();
265
286
  if (!cache.body) return;
@@ -277,8 +298,8 @@ export default function agentPurge(pi: ExtensionAPI) {
277
298
  try {
278
299
  pi.on("context_with_system", async (event: any) => {
279
300
  const st = loadState();
280
- const m = st.mode === "strict" || st.mode === "append" ? st.mode : "replace";
281
- const on = st.mode === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
301
+ const m = modeOverride ?? st.mode;
302
+ const on = m === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
282
303
  if (on !== "on") return;
283
304
  const p = loadPersona();
284
305
  if (!p.body) return;
@@ -330,7 +351,8 @@ export default function agentPurge(pi: ExtensionAPI) {
330
351
  const describe = () => {
331
352
  const p = loadPersona();
332
353
  const st = loadState();
333
- const on = st.mode === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
354
+ const effective = modeOverride ?? st.mode;
355
+ const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
334
356
  const why =
335
357
  on === "on"
336
358
  ? forced
@@ -338,14 +360,14 @@ export default function agentPurge(pi: ExtensionAPI) {
338
360
  : process.env.AGENT_PURGE_FORCE === "1"
339
361
  ? "启动器要求注入"
340
362
  : "接管裸命令已开"
341
- : st.mode === "off"
363
+ : effective === "off"
342
364
  ? "总电源关着"
343
365
  : st.piAuto
344
366
  ? "裸启动已开,这次是你手动关的"
345
367
  : "只装了会话内热切,裸启动不注入";
346
368
  return [
347
369
  "状态: " + (on === "on" ? "开启" : "关闭") + "(" + why + ")",
348
- "模式: " + st.mode,
370
+ "模式: " + effective,
349
371
  "裸启动也注入: " + (st.piAuto ? "开" : "关") + "(/purge always on|off)",
350
372
  "人设: " + (p.id ?? "(state.json 里没有 persona)") + " " + (p.body.length ? p.body.length + " 字符" : "未加载"),
351
373
  "来源: " + STATE,
@@ -353,7 +375,7 @@ export default function agentPurge(pi: ExtensionAPI) {
353
375
  };
354
376
  const turnOn = () => {
355
377
  const st = loadState();
356
- if (st.mode === "off") return notify("总电源关着(state.default.mode=off):先开电源,/purge 才能注入", "error");
378
+ if ((modeOverride ?? st.mode) === "off") return notify("总电源关着(state.default.mode=off):先开电源,/purge 才能注入", "error");
357
379
  const p = loadPersona();
358
380
  if (!p.body) return notify("开启失败:state.json 里没有可用 persona(先 agent-purge use <id>)", "error");
359
381
  forced = "on";
@@ -366,7 +388,7 @@ export default function agentPurge(pi: ExtensionAPI) {
366
388
  // 直接裸启动 pi 的时候 forced 是 null、注入本来是关的,第一次 /purge 应该是「开」而不是「关」。
367
389
  const st = loadState();
368
390
  const injecting =
369
- st.mode !== "off" && (forced === "on" || (forced === null && Boolean(st.persona) && autoOn(st)));
391
+ (modeOverride ?? st.mode) !== "off" && (forced === "on" || (forced === null && Boolean(st.persona) && autoOn(st)));
370
392
  if (!injecting) return turnOn();
371
393
  forced = "off";
372
394
  return notify("已关闭本会话注入,恢复 pi 原始系统提示词。再输入 /purge 即重新开启。");
@@ -375,19 +397,28 @@ export default function agentPurge(pi: ExtensionAPI) {
375
397
  return turnOn();
376
398
  case "off":
377
399
  forced = "off";
378
- return notify("已关闭本会话注入,恢复 pi 原始系统提示词。"); case "status":
400
+ return notify("已关闭本会话注入,恢复 pi 原始系统提示词。");
401
+ case "status":
379
402
  return notify(describe());
380
403
  case "reload":
381
- forced = null;
404
+ // reload 只重读磁盘;本会话手工开的开关不能被重置
382
405
  cache = loadPersona();
383
- return notify("已重读 state.json(跟随其 mode)。\\n" + describe());
406
+ return notify("已重读 state.json(本会话开关不变)。\\n" + describe());
384
407
  case "mode": {
385
408
  const want = args.trim().split(/\\s+/)[1];
386
409
  if (want !== "strict" && want !== "replace" && want !== "append") {
387
- return notify("用法: /purge mode strict|replace|append\\nstrict=只发人设;replace=清宿主身份句;append=只挂头部", "error");
410
+ return notify("用法: /purge mode strict|replace|append\\nstrict=只发人设;replace=清宿主身份句;append=宿主后追加", "error");
411
+ }
412
+ try {
413
+ // 以前只改内存局部变量,下一轮就被 state.json 覆盖 → 写进去才算真切换
414
+ const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, any>;
415
+ raw.default = { ...(raw.default ?? { harness: "pi" }), mode: want };
416
+ writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
417
+ modeOverride = want;
418
+ return notify("模式 → " + want + "(已写入 state.json,下一轮生效)\\n" + describe());
419
+ } catch (e) {
420
+ return notify("写 state.json 失败: " + String(e), "error");
388
421
  }
389
- mode = want;
390
- return notify("模式 → " + want + "\\n" + describe());
391
422
  }
392
423
  case "always": {
393
424
  const want = args.trim().split(/\\s+/)[1];
@@ -410,3 +441,188 @@ export default function agentPurge(pi: ExtensionAPI) {
410
441
  }
411
442
  `;
412
443
  }
444
+
445
+ /** omp 扩展源码:每轮 before_agent_start 现读 state.json,/purge 热切 */
446
+ function ompExtension(root) {
447
+ const statePath = join(root, "state.json").replace(/\\/g, "/");
448
+ const personasDir = join(root, "personas").replace(/\\/g, "/");
449
+ return `/**
450
+ * agent-purge 生成的 /purge 扩展(omp 版;删除本文件即取消)。
451
+ * 人设不内嵌:每次 before_agent_start 现读 state.json → 换 persona / 改模式下一轮就跟上。
452
+ */
453
+ import { readFileSync, writeFileSync } from "node:fs";
454
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
455
+
456
+ const HOME = process.env.AGENT_PURGE_HOME ? String(process.env.AGENT_PURGE_HOME).replace(/\\\\/g, "/") : null;
457
+ const STATE = (HOME ? HOME + "/state.json" : ${JSON.stringify(statePath)});
458
+ const PERSONAS = (HOME ? HOME + "/personas" : ${JSON.stringify(personasDir)});
459
+
460
+ function stripFrontmatter(raw: string): string {
461
+ if (!raw.startsWith("---")) return raw;
462
+ const end = raw.indexOf("\\n---", 3);
463
+ return end === -1 ? raw : raw.slice(end + 4).replace(/^\\r?\\n/, "");
464
+ }
465
+
466
+ function loadPersona(): { id: string | null; body: string } {
467
+ try {
468
+ const st = JSON.parse(readFileSync(STATE, "utf8")) as { persona?: string };
469
+ const id = process.env.AGENT_PURGE_PERSONA ?? st.persona ?? null;
470
+ if (!id) return { id: null, body: "" };
471
+ return { id, body: stripFrontmatter(readFileSync(PERSONAS + "/" + id + ".md", "utf8")).trim() };
472
+ } catch {
473
+ return { id: process.env.AGENT_PURGE_PERSONA ?? null, body: "" };
474
+ }
475
+ }
476
+
477
+ function loadState(): { persona: string | null; mode: string; ompAuto: boolean } {
478
+ const envPersona = process.env.AGENT_PURGE_PERSONA;
479
+ const envMode = process.env.AGENT_PURGE_MODE;
480
+ try {
481
+ const st = JSON.parse(readFileSync(STATE, "utf8")) as {
482
+ persona?: string;
483
+ default?: { harness?: string; mode?: string };
484
+ ompAuto?: boolean;
485
+ };
486
+ return {
487
+ persona: envPersona ?? st.persona ?? null,
488
+ mode: envMode ?? st.default?.mode ?? "replace",
489
+ ompAuto: st.ompAuto === true,
490
+ };
491
+ } catch {
492
+ return { persona: envPersona ?? null, mode: envMode ?? "replace", ompAuto: process.env.AGENT_PURGE_FORCE === "1" };
493
+ }
494
+ }
495
+
496
+ function toLines(value: unknown): string[] {
497
+ if (Array.isArray(value)) return value.map((v) => String(v));
498
+ return value ? [String(value)] : [];
499
+ }
500
+
501
+ export default function agentPurge(pi: ExtensionAPI) {
502
+ let forced: "on" | "off" | null = null;
503
+ // 启动器的 AGENT_PURGE_MODE 只决定初始模式;/purge mode 必须能盖住它,
504
+ // 否则下一轮又被环境变量改回去。
505
+ const ENV_MODE = process.env.AGENT_PURGE_MODE;
506
+ let modeOverride: "strict" | "replace" | "append" | "off" | null =
507
+ ENV_MODE === "off" ? "off" : ENV_MODE === "strict" || ENV_MODE === "append" ? ENV_MODE : ENV_MODE ? "replace" : null;
508
+ let cache = { id: null as string | null, body: "" };
509
+ const autoOn = (st: { ompAuto?: boolean }) => process.env.AGENT_PURGE_FORCE === "1" || st.ompAuto === true;
510
+
511
+ pi.on("session_start", async () => {
512
+ forced = null;
513
+ cache = { id: null, body: "" };
514
+ });
515
+
516
+ // omp 每轮重拼 system 提示词;这里直接返回新的 systemPrompt(string[])。
517
+ pi.on("before_agent_start", async (event) => {
518
+ const st = loadState();
519
+ const effective = modeOverride ?? st.mode;
520
+ const mode = effective === "strict" || effective === "append" ? effective : "replace";
521
+ const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
522
+ if (on !== "on") return;
523
+ cache = loadPersona();
524
+ if (!cache.body) return;
525
+ const hostBlocks = toLines((event as any).systemPrompt).filter((l: string) => l.length > 0);
526
+ // AGENTS.md 常驻通道可能已经把同一份人设塞进 systemPrompt:前缀相同就别再叠一遍
527
+ if (hostBlocks.some((b: string) => b.includes(cache.body.slice(0, 40)))) return;
528
+ // strict=只发人设;replace=用新人设换掉基础身份块、保留 PROJECT 块;append=原样保留再追加
529
+ if (mode === "strict") return { systemPrompt: [cache.body] };
530
+ if (mode === "append") return { systemPrompt: [...hostBlocks, cache.body] };
531
+ const projectBlocks = hostBlocks.filter((b: string, i: number) => i > 0 || b.trim().startsWith("PROJECT"));
532
+ return { systemPrompt: [cache.body, ...projectBlocks] };
533
+ });
534
+
535
+ pi.registerCommand("purge", {
536
+ description: "会话级接管开关(人设跟随 agent-purge 的 state.json)",
537
+ getArgumentCompletions: (prefix: string) =>
538
+ ["on", "off", "always", "status", "reload", "mode"].filter((v) => v.startsWith(prefix)).map((value) => ({ value, label: value })),
539
+ handler: async (args: string, ctx: any) => {
540
+ const cmd = (args.trim().split(/\\s+/)[0] ?? "").toLowerCase();
541
+ const notify = (t: string, level: "info" | "error" = "info") => ctx.ui.notify(t, level);
542
+ const describe = () => {
543
+ const p = loadPersona();
544
+ const st = loadState();
545
+ const effective = modeOverride ?? st.mode;
546
+ const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
547
+ const why =
548
+ on === "on"
549
+ ? forced
550
+ ? "本会话 /purge 开的"
551
+ : process.env.AGENT_PURGE_FORCE === "1"
552
+ ? "启动器要求注入"
553
+ : "裸启动已开"
554
+ : effective === "off"
555
+ ? "总电源关着"
556
+ : "只装了会话内热切,裸启动不注入";
557
+ return [
558
+ "状态: " + (on === "on" ? "开启" : "关闭") + "(" + why + ")",
559
+ "模式: " + effective,
560
+ "裸启动也注入: " + (st.ompAuto ? "开" : "关") + "(/purge always on|off)",
561
+ "人设: " + (p.id ?? "(state.json 里没有 persona)") + " " + (p.body.length ? p.body.length + " 字符" : "未加载"),
562
+ "来源: " + STATE,
563
+ ].join("\\n");
564
+ };
565
+ const turnOn = () => {
566
+ const st = loadState();
567
+ if ((modeOverride ?? st.mode) === "off") return notify("总电源关着(state.default.mode=off):先开电源,/purge 才能注入", "error");
568
+ const p = loadPersona();
569
+ if (!p.body) return notify("开启失败:state.json 里没有可用 persona(先 agent-purge use <id>)", "error");
570
+ forced = "on";
571
+ cache = p;
572
+ notify("已开启(仅本会话),每轮注入 " + p.body.length + " 字符。\\n" + describe());
573
+ };
574
+ switch (cmd) {
575
+ case "": {
576
+ const st = loadState();
577
+ const injecting =
578
+ (modeOverride ?? st.mode) !== "off" && (forced === "on" || (forced === null && Boolean(st.persona) && autoOn(st)));
579
+ if (!injecting) return turnOn();
580
+ forced = "off";
581
+ return notify("已关闭本会话注入,恢复 omp 原始系统提示词。再输入 /purge 即重新开启。");
582
+ }
583
+ case "on":
584
+ return turnOn();
585
+ case "off":
586
+ forced = "off";
587
+ return notify("已关闭本会话注入,恢复 omp 原始系统提示词。");
588
+ case "status":
589
+ return notify(describe());
590
+ case "reload":
591
+ cache = loadPersona();
592
+ return notify("已重读 state.json(本会话开关不变)。\\n" + describe());
593
+ case "mode": {
594
+ const want = args.trim().split(/\\s+/)[1];
595
+ if (want !== "strict" && want !== "replace" && want !== "append") {
596
+ return notify("用法: /purge mode strict|replace|append\\nstrict=只发人设;replace=换基础提示、保留项目规则;append=宿主后追加", "error");
597
+ }
598
+ try {
599
+ const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, any>;
600
+ raw.default = { ...(raw.default ?? { harness: "omp" }), mode: want };
601
+ writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
602
+ modeOverride = want;
603
+ return notify("模式 → " + want + "(已写入 state.json,下一轮生效)\\n" + describe());
604
+ } catch (e) {
605
+ return notify("写 state.json 失败: " + String(e), "error");
606
+ }
607
+ }
608
+ case "always": {
609
+ const want = args.trim().split(/\\s+/)[1];
610
+ if (want !== "on" && want !== "off")
611
+ return notify("用法: /purge always on|off —— 让裸敲 omp 也自动注入(写 state.json,新会话生效)", "error");
612
+ try {
613
+ const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, unknown>;
614
+ raw.ompAuto = want === "on";
615
+ writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
616
+ return notify("已写入 ompAuto=" + want + "(新会话起生效)\\n" + describe());
617
+ } catch (e) {
618
+ return notify("写 state.json 失败: " + String(e), "error");
619
+ }
620
+ }
621
+ default:
622
+ return notify("用法: /purge [on|off|always on|off|status|reload|mode strict|replace|append]", "error");
623
+ }
624
+ },
625
+ });
626
+ }
627
+ `;
628
+ }
package/lib/spawn.mjs CHANGED
@@ -78,7 +78,9 @@ function cmdShimNodeTarget(file) {
78
78
  }
79
79
 
80
80
  function dispatch(name, args, opts) {
81
- if (!WIN) return { cmd: name, args, opts: { ...opts, shell: false } };
81
+ // 非 Windows 也要解析成真实路径:否则 shim 目录里的同名壳转回 agent-purge 时,
82
+ // spawn("pi") 又会命中最前面的壳,无限套娃。
83
+ if (!WIN) return { cmd: resolveBin(name) ?? name, args, opts: { ...opts, shell: false } };
82
84
  const full = resolveBin(name) ?? name;
83
85
  const ext = extname(full).toLowerCase();
84
86
  if (ext === ".ps1") {
package/lib/state.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // personas/active.md 退化为「派生物」:由 launch/arm 时按 persona 生成,
13
13
  // 供落盘型 harness 与 opencode 插件读取,不再承载状态。
14
- import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
 
17
17
  const FILE = "state.json";
@@ -33,7 +33,7 @@ export function statePath(root) {
33
33
  }
34
34
 
35
35
  function emptyState() {
36
- return { v: 1, persona: null, default: null, armed: {}, piAuto: false, piUserLayer: false, dshProfiles: null };
36
+ return { v: 1, persona: null, default: null, armed: {}, piAuto: false, piUserLayer: false, ompAuto: false, dshProfiles: null };
37
37
  }
38
38
 
39
39
  /**
@@ -81,13 +81,25 @@ export function loadState(root) {
81
81
  }
82
82
 
83
83
  // 迁完立即落盘:否则下次调用看不到旧文件,迁移结果直接丢掉
84
- if (migrated.length) writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
84
+ if (migrated.length) saveState(root, state);
85
85
 
86
86
  return { state, migrated };
87
87
  }
88
88
 
89
89
  export function saveState(root, state) {
90
- writeFileSync(statePath(root), JSON.stringify(state, null, 2) + "\n");
90
+ // 先写临时文件再改名:Web 与 CLI 同时写时,读的一方不会看到半截 JSON
91
+ const file = statePath(root);
92
+ const tmp = `${file}.tmp-${process.pid}`;
93
+ try {
94
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
95
+ renameSync(tmp, file);
96
+ } catch (e) {
97
+ // rename 失败时别把半截临时文件留在 ~/.agent-purge
98
+ try {
99
+ unlinkSync(tmp);
100
+ } catch {}
101
+ throw e;
102
+ }
91
103
  return state;
92
104
  }
93
105
 
@@ -115,6 +127,12 @@ export function setPiAuto(root, on) {
115
127
  return saveState(root, { ...state, piAuto: on === true }).piAuto;
116
128
  }
117
129
 
130
+ /** 裸敲 omp 是否也自动注入(omp 扩展读这个位) */
131
+ export function setOmpAuto(root, on) {
132
+ const { state } = loadState(root);
133
+ return saveState(root, { ...state, ompAuto: on === true }).ompAuto;
134
+ }
135
+
118
136
  /** pi 的人设走哪一层:false = system(默认),true = 额外塞进用户消息(给忽略 system 的模型用) */
119
137
  export function setPiUserLayer(root, on) {
120
138
  const { state } = loadState(root);
package/matrix.json CHANGED
@@ -181,9 +181,9 @@
181
181
  "class": "launch-arg",
182
182
  "override": true,
183
183
  "inject": true,
184
- "hotSwitch": false,
184
+ "hotSwitch": true,
185
185
  "patch": false,
186
- "launcher": "omp --system-prompt <persona>",
186
+ "launcher": "omp(扩展钩子 /purge 热切;--ask 用 --system-prompt)",
187
187
  "defaultSystemChars": 34511,
188
188
  "purgedSystemChars": 10106,
189
189
  "measuredVersion": "17.4.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-purge",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "One persona, many coding agents: replace or inject system prompts across codex, claude, opencode (v1/v2), grok, omp, hermes, pi, zcode, dsh.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,8 +23,7 @@
23
23
  "web": "node bin/agent-purge.mjs web",
24
24
  "matrix": "node bin/agent-purge.mjs matrix",
25
25
  "detect": "node bin/agent-purge.mjs detect",
26
- "test": "node magpie-plugin/selftest.mjs && node magpie-plugin/test-page.mjs && node test/harness-adapters.test.mjs",
27
- "build:exe": "node scripts/build-exe.mjs",
26
+ "test": "node magpie-plugin/selftest.mjs && node magpie-plugin/test-page.mjs && node test/harness-adapters.test.mjs && node test/regressions.test.mjs",
28
27
  "prepack": "node -e \"require('node:fs').rmSync('personas/.build',{recursive:true,force:true})\""
29
28
  },
30
29
  "keywords": [
package/web/server.mjs CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { detect } from "../lib/detect.mjs";
7
7
  import { oneshots } from "../backends/index.mjs";
8
8
  import { createStore } from "../lib/personas.mjs";
9
- import { MODES, armedOf, dshProfilesOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto, setDshProfile } from "../lib/state.mjs";
9
+ import { MODES, armedOf, dshProfilesOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto, setOmpAuto, setDshProfile } from "../lib/state.mjs";
10
10
  import { install, status as persistStatus, uninstall } from "../lib/persist.mjs";
11
11
  import { plans, summary as armorSummary } from "../lib/armor.mjs";
12
12
  import { installShim, shimStatus, uninstallShim } from "../lib/shim.mjs";
@@ -31,7 +31,14 @@ const matrix = JSON.parse(readFileSync(join(ROOT, "matrix.json"), "utf8"));
31
31
  const store = createStore(DATA);
32
32
 
33
33
  export function serveWeb(port = 7788) {
34
- const server = createServer(handle);
34
+ // handle 是 async:任何异常都在这里收口,否则请求会挂着不响应
35
+ const server = createServer((req, res) => {
36
+ handle(req, res).catch((e) => {
37
+ try {
38
+ send(res, 500, { error: `服务端错误: ${e.message}` });
39
+ } catch {}
40
+ });
41
+ });
35
42
  server.on("error", (e) => {
36
43
  // 端口被旧实例占着的时候,--detach 是静默的 → 这里必须说人话,否则用户看到的是旧 UI
37
44
  if (e.code === "EADDRINUSE") {
@@ -51,11 +58,16 @@ async function handle(req, res) {
51
58
  const url = new URL(req.url, "http://127.0.0.1");
52
59
  const p = url.pathname;
53
60
 
61
+ // 防 CSRF:本机 Web 页面能 POST 就能写配置/改人设;跨站页面用 text/plain 也能发“简单请求”。
62
+ // 只放同源:Origin 的 host 必须和本次请求的 Host 完全一致(端口不同也挡住)。
63
+ if (isMutating(req.method) && !sameOrigin(req)) return send(res, 403, { error: "只接受本机同源请求" });
64
+
54
65
  if (req.method === "GET" && (p === "/" || p === "/index.html")) return html(res);
55
66
  if (req.method === "GET" && p === "/api/state") return send(res, 200, await state(url.searchParams.get("versions") === "1"));
56
67
  if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
57
68
  if (req.method === "POST" && p === "/api/defaults") return send(res, 200, saveDefaults(await body(req)));
58
69
  if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, (await body(req)).on === true) });
70
+ if (req.method === "POST" && p === "/api/omp-auto") return send(res, 200, { ompAuto: setOmpAuto(DATA, (await body(req)).on === true) });
59
71
  if (req.method === "POST" && p === "/api/dsh-profile") return send(res, 200, saveDshProfile(await body(req)));
60
72
  if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
61
73
  if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
@@ -144,6 +156,7 @@ async function state(withVersions) {
144
156
  dshAll: DSH_PROFILES,
145
157
  modes: MODES,
146
158
  piAuto: st.piAuto === true,
159
+ ompAuto: st.ompAuto === true,
147
160
  shimDir: shimStatus()[0]?.dir ?? null,
148
161
  slashes: slashes,
149
162
  };
@@ -228,7 +241,8 @@ function savePersona(payload) {
228
241
  function deletePersona(id) {
229
242
  if (!id) return { error: "缺少 id" };
230
243
  if (store.list().length <= 1) return { error: "至少保留一个 persona" };
231
- store.remove(id); // remove 内部会处理“删的就是 active”的指针改道
244
+ if (!store.read(id)) return { error: "找不到这个 persona" };
245
+ if (!store.remove(id)) return { error: "非法 persona id" };
232
246
  return { ok: true, active: store.activeId() };
233
247
  }
234
248
 
@@ -251,6 +265,7 @@ function usePersona(payload) {
251
265
 
252
266
  /** 全局总开关:所有 harness × 选定通道一次折腾完(= `agent-purge 注册 --all` / `disarm --all`)*/
253
267
  function applyArmAll(payload) {
268
+ if (!payload.action) return { error: "缺少 action" };
254
269
  const { action } = payload;
255
270
  const channel = payload.channel ?? "all";
256
271
  const verb = verbOf(action);
@@ -310,6 +325,7 @@ function verbOf(action) {
310
325
 
311
326
  /** 裸命令接管(shim)、/purge(slash)、常驻(install):Web 上一个开关全部搞定 */
312
327
  function applyArm(payload) {
328
+ if (!payload.action) return { error: "缺少 action" };
313
329
  const { harness, channel, action } = payload;
314
330
  const id = payload.persona ?? store.activeId();
315
331
  if (!store.read(id)) return { error: "没有可用 persona" };
@@ -363,21 +379,57 @@ function armChannel(harness, channel, verb, id) {
363
379
  return { error: `未知通道: ${channel}` };
364
380
  }
365
381
 
366
- const send = (res, code, obj) => res.writeHead(code, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(obj));
382
+ const send = (res, code, obj) =>
383
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }).end(JSON.stringify(obj));
367
384
  const html = (res) =>
368
385
  res
369
386
  .writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
370
387
  .end(readFileSync(join(ROOT, "web", "ui.html"), "utf8"));
371
388
 
389
+ function isMutating(method) {
390
+ return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
391
+ }
392
+
393
+ /** 浏览器跨站请求会带 Origin;curl/脚本没有,放行。有 Origin 就必须和 Host 同源。 */
394
+ function sameOrigin(req) {
395
+ // 先看浏览器声明的跨站属性:没有 Origin 的跨站请求(部分 fetch 场景)也要挡住
396
+ const site = req.headers["sec-fetch-site"];
397
+ if (site && site !== "same-origin" && site !== "none") return false;
398
+ const origin = req.headers.origin;
399
+ if (!origin) return true;
400
+ try {
401
+ const u = new URL(origin);
402
+ const loopback = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "::1" || u.hostname === "[::1]";
403
+ return loopback && u.host.toLowerCase() === String(req.headers.host ?? "").toLowerCase();
404
+ } catch {
405
+ return false;
406
+ }
407
+ }
408
+
372
409
  function body(req) {
373
410
  return new Promise((resolve) => {
374
411
  let raw = "";
375
- req.on("data", (c) => (raw += c));
412
+ let size = 0;
413
+ let done = false;
414
+ const finish = (value) => {
415
+ if (done) return;
416
+ done = true;
417
+ resolve(value);
418
+ };
419
+ req.on("data", (c) => {
420
+ size += c.length;
421
+ // 本机也不该接无上限的 body:一个坏页面能靠大 POST 把内存吃光
422
+ if (size > 2 * 1024 * 1024) {
423
+ req.destroy();
424
+ return finish({});
425
+ }
426
+ raw += c;
427
+ });
376
428
  req.on("end", () => {
377
429
  try {
378
- resolve(JSON.parse(raw || "{}"));
430
+ finish(JSON.parse(raw || "{}"));
379
431
  } catch {
380
- resolve({});
432
+ finish({});
381
433
  }
382
434
  });
383
435
  });