refactor: notify/footer 持久配置收编到 config/,chrome bridge 地址固定

仓库规则要求 config/ 为唯一配置来源、不以环境变量作持久默认值:
- pi-notify 改读 config/pi-notify.json(逐字段校验回退,复用原 parsers 钳制)
- pi-minimal-footer 改读 config/pi-minimal-footer.json
- pi-chrome 移除 PI_CHROME_BRIDGE_HOST/PORT,固定 127.0.0.1:17318
  (浏览器端 service_worker.js 硬编码同一地址,单侧可配只会断连)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-21 19:16:47 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 0c45b1a438
commit e996f59469
19 changed files with 366 additions and 121 deletions
+24 -34
View File
@@ -1,28 +1,18 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import { describe, it } from "node:test";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { getConfig, shouldNotify } from "../src/config.ts";
import type { NotifySettings } from "../src/settings.ts";
const envKeys = [
"PI_NOTIFY_ENABLED",
"PI_NOTIFY_TITLE",
"PI_NOTIFY_BODY",
"PI_NOTIFY_MESSAGE_SOURCE",
"PI_NOTIFY_MESSAGE_MAX",
"PI_NOTIFY_MIN_SECONDS",
"PI_NOTIFY_VISIBILITY",
"PI_NOTIFY_ACTION",
"PI_NOTIFY_SOUND_CMD",
] as const;
const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
afterEach(() => {
for (const key of envKeys) {
const value = savedEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
const defaultSettings = (): NotifySettings => ({
enabled: true,
title: "π - {project}",
body: "{message}",
messageSource: "assistant",
messageMax: 80,
minDurationMs: 3_000,
visibility: "always",
action: "focus",
});
function context(): ExtensionContext {
@@ -45,6 +35,7 @@ describe("config", () => {
],
runtime,
5_000,
defaultSettings(),
);
assert.deepEqual(config, {
@@ -62,19 +53,20 @@ describe("config", () => {
});
it("supports privacy, threshold, focus, and status template settings", () => {
process.env.PI_NOTIFY_ENABLED = "off";
process.env.PI_NOTIFY_TITLE = "{status_icon} {status} {tool_error_count}";
process.env.PI_NOTIFY_BODY = "{message}";
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none";
process.env.PI_NOTIFY_MIN_SECONDS = "10.5";
process.env.PI_NOTIFY_VISIBILITY = "invisible";
process.env.PI_NOTIFY_ACTION = "none";
process.env.PI_NOTIFY_SOUND_CMD = " say done ";
const config = getConfig(context(), 1_000, [{ role: "assistant", content: "secret" }], {
notificationId: "pi-session",
toolErrorCount: 2,
}, 2_000);
}, 2_000, {
...defaultSettings(),
enabled: false,
title: "{status_icon} {status} {tool_error_count}",
body: "{message}",
messageSource: "none",
minDurationMs: 10_500,
visibility: "invisible",
action: "none",
soundCommand: "say done",
});
assert.equal(config.title, "⚠ ready · 2 tool errors 2");
assert.equal(config.body, "Ready for input");
@@ -88,15 +80,13 @@ describe("config", () => {
});
it("applies selected-message truncation and a session override", () => {
process.env.PI_NOTIFY_MESSAGE_SOURCE = "user";
process.env.PI_NOTIFY_MESSAGE_MAX = "5";
process.env.PI_NOTIFY_MIN_SECONDS = "0";
const config = getConfig(
context(),
1_000,
[{ role: "user", content: "hello world" }],
runtime,
2_000,
{ ...defaultSettings(), messageSource: "user", messageMax: 5, minDurationMs: 0 },
);
assert.equal(config.body, "hell…");
+17 -18
View File
@@ -1,23 +1,24 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import { describe, it } from "node:test";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { createPiNotifyExtension } from "../src/index.ts";
import type { NotifySettings } from "../src/settings.ts";
import type { NotifyConfig } from "../src/types.ts";
type Handler = (...args: any[]) => any;
const savedEnv = new Map(
["PI_NOTIFY_MIN_SECONDS", "PI_NOTIFY_MESSAGE_SOURCE"].map((key) => [key, process.env[key]]),
);
const DEFAULT_SETTINGS: NotifySettings = {
enabled: true,
title: "π - {project}",
body: "{message}",
messageSource: "assistant",
messageMax: 80,
minDurationMs: 3_000,
visibility: "always",
action: "focus",
};
afterEach(() => {
for (const [key, value] of savedEnv) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function harness(mode: ExtensionContext["mode"] = "tui") {
function harness(mode: ExtensionContext["mode"] = "tui", settings: Partial<NotifySettings> = {}) {
const handlers = new Map<string, Handler>();
const eventHandlers = new Map<string, Handler>();
const commands = new Map<string, { handler: Handler }>();
@@ -45,6 +46,7 @@ function harness(mode: ExtensionContext["mode"] = "tui") {
createNotificationId: () => "pi-session-test",
notify: (config) => notifications.push(config),
getBackend: () => "kitty",
settings: { ...DEFAULT_SETTINGS, ...settings },
})(pi);
const ctx = {
@@ -93,9 +95,8 @@ describe("pi-notify extension", () => {
});
it("does not write terminal notifications in RPC or non-interactive modes", async () => {
process.env.PI_NOTIFY_MIN_SECONDS = "0";
for (const mode of ["rpc", "json", "print"] as const) {
const h = harness(mode);
const h = harness(mode, { minDurationMs: 0 });
await h.handlers.get("session_start")?.({}, h.ctx);
await h.handlers.get("agent_start")?.({}, h.ctx);
await h.handlers.get("agent_settled")?.({}, h.ctx);
@@ -123,8 +124,7 @@ describe("pi-notify extension", () => {
});
it("notifies immediately when permission confirmation needs human input", async () => {
process.env.PI_NOTIFY_MIN_SECONDS = "3600";
const h = harness();
const h = harness("tui", { minDurationMs: 3_600_000 });
await h.handlers.get("session_start")?.({}, h.ctx);
h.eventHandlers.get("permissions:ui_prompt")?.({
@@ -144,8 +144,7 @@ describe("pi-notify extension", () => {
});
it("keeps permission details private and honors the session off override", async () => {
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none";
const h = harness();
const h = harness("tui", { messageSource: "none" });
await h.handlers.get("session_start")?.({}, h.ctx);
const prompt = { requestId: "perm-secret", surface: "bash", value: "cat private-file" };
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, it } from "node:test";
import { readNotifySettings } from "../src/settings.ts";
let tempDir: string | undefined;
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = undefined;
}
});
function configPath(contents: string): string {
tempDir = mkdtempSync(join(tmpdir(), "pi-notify-settings-"));
const path = join(tempDir, "pi-notify.json");
writeFileSync(path, contents);
return path;
}
const expectedDefaults = {
enabled: true,
title: "π - {project}",
body: "{message}",
messageSource: "assistant",
messageMax: 80,
minDurationMs: 3_000,
visibility: "always",
action: "focus",
soundCommand: undefined,
};
describe("readNotifySettings", () => {
it("returns all defaults when the config file is missing", () => {
assert.deepEqual(readNotifySettings("/does/not/exist/pi-notify.json"), expectedDefaults);
});
it("returns all defaults for malformed or non-object JSON", () => {
for (const contents of ["not json", "[1,2]", "\"hi\"", "42"]) {
assert.deepEqual(readNotifySettings(configPath(contents)), expectedDefaults, contents);
}
});
it("adopts valid values from the config file", () => {
const settings = readNotifySettings(configPath(JSON.stringify({
enabled: false,
title: "π {status_icon} · {project}",
body: "{status}",
messageSource: "none",
messageMax: 120,
minSeconds: 30,
visibility: "unfocused",
action: "none",
soundCommand: "afplay /done.aiff",
})));
assert.deepEqual(settings, {
enabled: false,
title: "π {status_icon} · {project}",
body: "{status}",
messageSource: "none",
messageMax: 120,
minDurationMs: 30_000,
visibility: "unfocused",
action: "none",
soundCommand: "afplay /done.aiff",
});
});
it("falls back per-field when values have the wrong type or value", () => {
const settings = readNotifySettings(configPath(JSON.stringify({
enabled: {},
title: "",
body: null,
messageSource: ["assistant"],
messageMax: "bogus",
minSeconds: "bogus",
visibility: "bogus",
action: {},
})));
assert.deepEqual(settings, expectedDefaults);
});
it("clamps messageMax and minSeconds to their bounded ranges", () => {
const settings = readNotifySettings(configPath(JSON.stringify({ messageMax: 999, minSeconds: 4000 })));
assert.equal(settings.messageMax, 500);
assert.equal(settings.minDurationMs, 3_600_000);
const floor = readNotifySettings(configPath(JSON.stringify({ messageMax: -5, minSeconds: -1 })));
assert.equal(floor.messageMax, 0);
assert.equal(floor.minDurationMs, 0);
});
it("treats an empty or whitespace soundCommand as absent and trims values", () => {
for (const value of ["", " "]) {
assert.equal(readNotifySettings(configPath(JSON.stringify({ soundCommand: value }))).soundCommand, undefined);
}
assert.equal(
readNotifySettings(configPath(JSON.stringify({ soundCommand: " afplay /done.aiff " }))).soundCommand,
"afplay /done.aiff",
);
});
});