pi-usereq 0.6.0 → 0.7.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.
@@ -2,6 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import fs from "node:fs";
5
+ import os from "node:os";
5
6
  import path from "node:path";
6
7
  import piUsereqExtension from "../src/index.ts";
7
8
  import {
@@ -9,8 +10,13 @@ import {
9
10
  getDefaultConfig,
10
11
  getProjectConfigPath,
11
12
  } from "../src/core/config.js";
12
- import { formatRuntimePathForDisplay } from "../src/core/path-context.js";
13
- import { setPiNotifyHttpsRequestForTests } from "../src/core/pi-notify.js";
13
+ import {
14
+ setJsTiktokenModuleLoaderForTests,
15
+ } from "../src/core/token-counter.js";
16
+ import {
17
+ setPiNotifyHttpsRequestForTests,
18
+ setPiNotifySpawnForTests,
19
+ } from "../src/core/pi-notify.js";
14
20
  import {
15
21
  PI_USEREQ_DEFAULT_ENABLED_TOOL_NAMES,
16
22
  PI_USEREQ_EMBEDDED_TOOL_NAMES,
@@ -289,42 +295,43 @@ function formatFakeThemeBackgroundFromForeground(color: string, text: string): s
289
295
  }
290
296
 
291
297
  /**
292
- * @brief Builds the expected fake context-bar payload for assertions.
293
- * @details Resolves the empty-state `◀ CLEAR ▶ ` overlay for unavailable or
294
- * non-positive percent, resolves the centered ` ◀ FULL ▶ ` overlay for percent
295
- * above 90, and otherwise reconstructs the 10-cell bar. Runtime is O(1). No
296
- * external state is mutated.
297
- * @param[in] options {{ filledCells: number; percent?: number | null }} Expected context-bar facts.
298
- * @return {string} Encoded context-bar string.
298
+ * @brief Builds the expected fake context-gauge payload for assertions.
299
+ * @details Resolves the documented icon thresholds for `0-100%` and emits the
300
+ * blinking red overflow icon for percent values above `100`. Runtime is O(1).
301
+ * No external state is mutated.
302
+ * @param[in] options {{ filledCells: number; percent?: number | null }} Expected context-gauge facts.
303
+ * @return {string} Encoded context-gauge string.
299
304
  */
300
305
  function buildExpectedFakeContextBar(options: {
301
306
  filledCells: number;
302
307
  percent?: number | null;
303
308
  }): string {
304
- if (options.percent === undefined || options.percent === null || options.percent <= 0) {
305
- return formatFakeThemeBackgroundFromForeground(
306
- "accent",
307
- formatFakeThemeForeground("warning", "◀ CLEAR ▶ "),
308
- );
309
+ const percent = options.percent;
310
+ if (percent === undefined || percent === null || percent <= 0) {
311
+ return formatFakeThemeForeground("warning", "▕_▏");
309
312
  }
310
- if (options.percent > 90) {
311
- return formatFakeThemeBackgroundFromForeground(
312
- "warning",
313
- formatFakeThemeForeground("error", " ◀ FULL ▶ "),
314
- );
313
+ if (percent > 100) {
314
+ return formatFakeThemeForeground("error", "\u001b[5m▕█▏\u001b[25m");
315
+ }
316
+ if (percent <= 25) {
317
+ return formatFakeThemeForeground("warning", "▕▂▏");
315
318
  }
316
- return Array.from({ length: 10 }, (_value, index) =>
317
- formatFakeThemeBackgroundFromForeground(
318
- "accent",
319
- formatFakeThemeForeground(index < options.filledCells ? "warning" : "dim", "▓"),
320
- ),
321
- ).join("");
319
+ if (percent <= 50) {
320
+ return formatFakeThemeForeground("warning", "▕▄▏");
321
+ }
322
+ if (percent <= 90) {
323
+ return formatFakeThemeForeground("warning", "▕▆▏");
324
+ }
325
+ return formatFakeThemeForeground("warning", "▕█▏");
322
326
  }
323
327
 
324
328
  /**
325
329
  * @brief Builds the expected fake pi-usereq status-bar string for assertions.
326
- * @details Reconstructs the field order, separators, context bar, consolidated elapsed field, and Pushover enable field emitted by the extension using deterministic fake theme markers. Runtime is O(1). No external state is mutated.
327
- * @param[in] options {{ basePath: string; contextFilledCells: number; contextPercent?: number | null; et: string; beep?: string; sound?: string; pushover?: string }} Expected status facts.
330
+ * @details Reconstructs the field order, separators, icon-based context gauge,
331
+ * consolidated elapsed field, and sound field emitted by the extension using
332
+ * deterministic fake theme markers. Runtime is O(1). No external state is
333
+ * mutated.
334
+ * @param[in] options {{ basePath: string; contextFilledCells: number; contextPercent?: number | null; et: string; sound?: string }} Expected status facts.
328
335
  * @return {string} Encoded status-bar string.
329
336
  */
330
337
  function buildExpectedFakeStatusText(options: {
@@ -335,9 +342,7 @@ function buildExpectedFakeStatusText(options: {
335
342
  contextFilledCells: number;
336
343
  contextPercent?: number | null;
337
344
  et: string;
338
- beep?: string;
339
345
  sound?: string;
340
- pushover?: string;
341
346
  }): string {
342
347
  const buildField = (fieldName: string, value: string): string =>
343
348
  `${formatFakeThemeForeground("accent", `${fieldName}:`)}${formatFakeThemeForeground("warning", value)}`;
@@ -349,9 +354,7 @@ function buildExpectedFakeStatusText(options: {
349
354
  buildField("base", options.basePath),
350
355
  `${formatFakeThemeForeground("accent", "context:")}${contextBar}`,
351
356
  buildField("elapsed", options.et),
352
- buildField("beep", options.beep ?? "end,esc,err"),
353
357
  buildField("sound", options.sound ?? "none"),
354
- buildField("pushover", options.pushover ?? "off"),
355
358
  ].join(formatFakeThemeForeground("dim", " • "));
356
359
  }
357
360
 
@@ -367,6 +370,54 @@ function buildExpectedFakeBasePath(cwd: string): string {
367
370
  return path.resolve(cwd).split(path.sep).join("/");
368
371
  }
369
372
 
373
+ /**
374
+ * @brief Formats one expected home-relative base path for notify-template assertions.
375
+ * @details Resolves the supplied cwd, emits `~` or `~/...` when the path is inside the current user home directory, and otherwise returns the slash-normalized absolute path. Runtime is O(p) in path length. No external state is mutated.
376
+ * @param[in] cwd {string} Runtime working directory.
377
+ * @return {string} Home-relative or absolute base path used by notification templates.
378
+ */
379
+ function buildExpectedNotifyBasePath(cwd: string): string {
380
+ const resolvedPath = path.resolve(cwd);
381
+ const homePath = path.resolve(process.env.HOME ?? os.homedir());
382
+ if (resolvedPath === homePath) {
383
+ return "~";
384
+ }
385
+ const relativePath = path.relative(homePath, resolvedPath);
386
+ if (relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
387
+ return `~/${relativePath.split(path.sep).join("/")}`;
388
+ }
389
+ return resolvedPath.split(path.sep).join("/");
390
+ }
391
+
392
+ /**
393
+ * @brief Formats the expected `Show configuration` path value.
394
+ * @details Resolves the current project config path and rewrites a leading home-directory prefix to `~` so top-level menu assertions match the documented display-only substitution without changing stored paths. Runtime is O(p) in path length. No external state is mutated.
395
+ * @param[in] cwd {string} Runtime working directory.
396
+ * @return {string} `~`-relative or absolute config-path display string.
397
+ */
398
+ function buildExpectedShowConfigPath(cwd: string): string {
399
+ return buildExpectedNotifyBasePath(path.dirname(path.dirname(getProjectConfigPath(cwd)))) === buildExpectedNotifyBasePath(cwd)
400
+ ? `${buildExpectedNotifyBasePath(cwd)}/.pi-usereq/config.json`
401
+ : getProjectConfigPath(cwd).split(path.sep).join("/");
402
+ }
403
+
404
+ /**
405
+ * @brief Formats the expected `%%RESULT%%` placeholder value for one terminal stop reason.
406
+ * @details Maps the tested assistant stop reasons to the literal runtime result tokens required by the default notify and Pushover templates. Runtime is O(1). No external state is mutated.
407
+ * @param[in] stopReason {"stop" | "aborted" | "error"} Terminal stop reason under test.
408
+ * @return {string} `successed`, `aborted`, or `failed`.
409
+ */
410
+ function buildExpectedNotifyResult(stopReason: "stop" | "aborted" | "error"): string {
411
+ switch (stopReason) {
412
+ case "aborted":
413
+ return "aborted";
414
+ case "error":
415
+ return "failed";
416
+ default:
417
+ return "successed";
418
+ }
419
+ }
420
+
370
421
  /**
371
422
  * @brief Creates a fake extension command context with scripted UI interactions.
372
423
  * @details Materializes deterministic `select`, shared settings-menu `custom`, `input`, `notify`, `setStatus`, `setEditorText`, `waitForIdle`, `newSession`, `sessionManager`, `getContextUsage`, theme-color behaviors, and captured custom-menu render output backed by in-memory state so menu-oriented and prompt-command tests can assert side effects without a real UI. Optional `onNewSession` and theme overrides let tests emulate runtime replacement and strict token validation. Runtime is O(1) plus queued interaction count and delegated new-session setup cost. Side effects are limited to in-memory state mutation.
@@ -389,7 +440,7 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
389
440
  editorText: "",
390
441
  statuses: new Map<string, string>(),
391
442
  notifications: [] as Array<{ message: string; level: string }>,
392
- selectCalls: [] as Array<{ title: string; items: string[] }>,
443
+ selectCalls: [] as Array<{ title: string; items: string[]; selectedChoiceId?: string }>,
393
444
  customRenderLines: [] as string[][],
394
445
  waitForIdleCalls: 0,
395
446
  newSessions: [] as Array<{ messages: string[] }>,
@@ -460,6 +511,7 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
460
511
  __piUsereqSettingsMenu?: {
461
512
  title: string;
462
513
  choices: Array<{ label: string }>;
514
+ selectedChoiceId?: string;
463
515
  selectByLabel: (label: string) => boolean;
464
516
  cancel: () => void;
465
517
  };
@@ -470,7 +522,11 @@ function createFakeCtx(cwd: string, plan: FakeCtxPlan = { selects: [] }, options
470
522
  state.customRenderLines.push(component.render(120));
471
523
  }
472
524
  const response = selects.shift();
473
- state.selectCalls.push({ title: bridge.title, items: bridge.choices.map((choice) => choice.label) });
525
+ state.selectCalls.push({
526
+ title: bridge.title,
527
+ items: bridge.choices.map((choice) => choice.label),
528
+ selectedChoiceId: bridge.selectedChoiceId,
529
+ });
474
530
  if (response === undefined) {
475
531
  bridge.cancel();
476
532
  } else {
@@ -730,6 +786,46 @@ test("files-tokens returns token-optimized structured source facts", async () =>
730
786
  }
731
787
  });
732
788
 
789
+ test("files-tokens defers js-tiktoken loading until execution and returns structured dependency failures", async () => {
790
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
791
+ const missingModuleError = new Error("Cannot find module 'js-tiktoken'");
792
+ setJsTiktokenModuleLoaderForTests(() => {
793
+ throw missingModuleError;
794
+ });
795
+ try {
796
+ const pi = createFakePi();
797
+ assert.doesNotThrow(() => piUsereqExtension(pi));
798
+ const result = await executeRegisteredTool(pi, "files-tokens", projectBase, {
799
+ files: [`${DEFAULT_DOCS_DIR}/REQUIREMENTS.md`],
800
+ }) as {
801
+ details?: {
802
+ summary: {
803
+ counted_file_count: number;
804
+ skipped_file_count: number;
805
+ processable_file_count: number;
806
+ };
807
+ files: unknown[];
808
+ execution: {
809
+ code: number;
810
+ stderr: string;
811
+ };
812
+ };
813
+ };
814
+
815
+ const payload = result.details!;
816
+ assert.equal(payload.summary.processable_file_count, 0);
817
+ assert.equal(payload.summary.counted_file_count, 0);
818
+ assert.equal(payload.summary.skipped_file_count, 0);
819
+ assert.deepEqual(payload.files, []);
820
+ assert.equal(payload.execution.code, 1);
821
+ assert.match(payload.execution.stderr, /js-tiktoken/);
822
+ assert.match(payload.execution.stderr, /npm ci/);
823
+ } finally {
824
+ setJsTiktokenModuleLoaderForTests(undefined);
825
+ fs.rmSync(projectBase, { recursive: true, force: true });
826
+ }
827
+ });
828
+
733
829
  test("files-references returns structured repository, symbol, and Doxygen facts", async () => {
734
830
  const { projectBase } = initFixtureRepo({ fixtures: [] });
735
831
  const samplePath = path.join(projectBase, "src", "sample.ts");
@@ -1339,37 +1435,144 @@ test("configuration menus expose show-config ordering and omit overview or notif
1339
1435
  piUsereqExtension(pi);
1340
1436
  const command = pi.commands.get("pi-usereq");
1341
1437
  assert.ok(command);
1342
- const ctx = createFakeCtx(cwd, { selects: ["show-config", "notifications", "Back", "Save and close"] });
1438
+ const ctx = createFakeCtx(cwd, { selects: ["show-config", "notifications", "Save and close", "Save and close"] });
1343
1439
 
1344
1440
  await command!.handler("", ctx);
1345
1441
 
1346
1442
  const renderedMenu = (ctx.__state.customRenderLines[0] ?? []).join("\n");
1347
1443
  assert.deepEqual(ctx.__state.selectCalls[0]?.items ?? [], [
1348
- "docs-dir",
1349
- "tests-dir",
1350
- "src-dir",
1351
- "static-check",
1352
- "startup tools",
1353
- "notifications",
1354
- "show-config",
1444
+ "Document directory",
1445
+ "Source-code directories",
1446
+ "Unit tests directory",
1447
+ "Language static code checkers",
1448
+ "Enable tools",
1449
+ "Notifications",
1450
+ "Show configuration",
1355
1451
  "Reset defaults",
1356
1452
  "Save and close",
1357
1453
  ]);
1358
- assert.ok(renderedMenu.includes(formatRuntimePathForDisplay(getProjectConfigPath(cwd))));
1454
+ assert.ok(renderedMenu.includes(buildExpectedShowConfigPath(cwd)));
1455
+ assert.ok(renderedMenu.includes("notification:off • sound:none • pushover:off"));
1456
+ assert.doesNotMatch(renderedMenu, /beep:/);
1359
1457
  assert.deepEqual(ctx.__state.selectCalls[2]?.items ?? [], [
1360
- "Toggle beep on success",
1361
- "Toggle beep on escape",
1362
- "Toggle beep on error",
1363
- "Selected notify command",
1458
+ "Enable notification",
1459
+ "Notification events",
1460
+ "Notify command",
1461
+ "Enable sound",
1462
+ "Sound events",
1364
1463
  "Sound toggle hotkey bind",
1365
- "Notify command (low vol.)",
1366
- "Notify command (mid vol.)",
1367
- "Notify command (high vol.)",
1368
- "Pushover notifications",
1369
- "Back",
1464
+ "Sound command (low vol.)",
1465
+ "Sound command (mid vol.)",
1466
+ "Sound command (high vol.)",
1467
+ "Enable pushover",
1468
+ "Pushover events",
1469
+ "Pushover priority",
1470
+ "Pushover title",
1471
+ "Pushover text",
1472
+ "Pushover User Key/Delivery Group Key",
1473
+ "Pushover Token/API Token Key",
1474
+ "Reset defaults",
1475
+ "Save and close",
1370
1476
  ]);
1371
1477
  });
1372
1478
 
1479
+ test("notifications menu preserves focus on toggled and edited rows", async () => {
1480
+ const cwd = createTempDir("pi-usereq-menu-focus-");
1481
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1482
+ const pi = createFakePi();
1483
+ piUsereqExtension(pi);
1484
+ const command = pi.commands.get("pi-usereq");
1485
+ assert.ok(command);
1486
+ const ctx = createFakeCtx(cwd, {
1487
+ selects: [
1488
+ "notifications",
1489
+ "Notification events",
1490
+ "Prompt failed",
1491
+ "Save and close",
1492
+ "Notify command",
1493
+ "Save and close",
1494
+ "Save and close",
1495
+ ],
1496
+ inputs: ["echo notify"],
1497
+ });
1498
+
1499
+ await command!.handler("", ctx);
1500
+
1501
+ const notificationCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notifications");
1502
+ assert.equal(notificationCalls[0]?.selectedChoiceId, "notify-enabled");
1503
+ assert.equal(notificationCalls[1]?.selectedChoiceId, "notification-events");
1504
+ assert.equal(notificationCalls[2]?.selectedChoiceId, "notify-command");
1505
+
1506
+ const notificationEventCalls = ctx.__state.selectCalls.filter((call) => call.title === "Notification events");
1507
+ assert.deepEqual(notificationEventCalls[0]?.items ?? [], [
1508
+ "Prompt completed",
1509
+ "Prompt interrupted",
1510
+ "Prompt failed",
1511
+ "Reset defaults",
1512
+ "Save and close",
1513
+ ]);
1514
+ assert.equal(notificationEventCalls[0]?.selectedChoiceId, "notify-on-completed");
1515
+ assert.equal(notificationEventCalls[1]?.selectedChoiceId, "notify-on-failed");
1516
+ });
1517
+
1518
+ test("notifications reset defaults preserves non-notification settings", async () => {
1519
+ const cwd = createTempDir("pi-usereq-menu-reset-notifications-");
1520
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1521
+ const pi = createFakePi();
1522
+ piUsereqExtension(pi);
1523
+ const command = pi.commands.get("pi-usereq");
1524
+ assert.ok(command);
1525
+ const ctx = createFakeCtx(cwd, {
1526
+ selects: [
1527
+ "docs-dir",
1528
+ "notifications",
1529
+ "Enable notification",
1530
+ "Enable sound",
1531
+ "high",
1532
+ "Reset defaults",
1533
+ "Save and close",
1534
+ "Save and close",
1535
+ ],
1536
+ inputs: ["docs/custom"],
1537
+ });
1538
+
1539
+ await command!.handler("", ctx);
1540
+
1541
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1542
+ assert.equal(config["docs-dir"], "docs/custom");
1543
+ assert.equal(config["notify-enabled"], false);
1544
+ assert.equal(config["notify-sound"], "none");
1545
+ assert.equal(config["notify-pushover-enabled"], false);
1546
+ });
1547
+
1548
+ test("top-level reset defaults restores all configuration values", async () => {
1549
+ const cwd = createTempDir("pi-usereq-menu-reset-all-");
1550
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1551
+ const pi = createFakePi();
1552
+ piUsereqExtension(pi);
1553
+ const command = pi.commands.get("pi-usereq");
1554
+ assert.ok(command);
1555
+ const ctx = createFakeCtx(cwd, {
1556
+ selects: [
1557
+ "docs-dir",
1558
+ "notifications",
1559
+ "Enable notification",
1560
+ "Save and close",
1561
+ "Reset defaults",
1562
+ "Save and close",
1563
+ ],
1564
+ inputs: ["docs/custom"],
1565
+ });
1566
+
1567
+ await command!.handler("", ctx);
1568
+
1569
+ const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1570
+ assert.equal(config["docs-dir"], DEFAULT_DOCS_DIR);
1571
+ assert.deepEqual(config["src-dir"], ["src"]);
1572
+ assert.equal(config["notify-enabled"], false);
1573
+ assert.equal(config["notify-sound"], "none");
1574
+ });
1575
+
1373
1576
  test("configuration menus reuse CLI settings theme semantics", async () => {
1374
1577
  const cwd = createTempDir("pi-usereq-menu-theme-");
1375
1578
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -1451,12 +1654,12 @@ test("session_start applies configured pi-usereq startup tools", async () => {
1451
1654
  testsDir: "tests",
1452
1655
  srcDir: ["src", "foobar"],
1453
1656
  contextFilledCells: 0,
1454
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1657
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1455
1658
  }),
1456
1659
  );
1457
1660
  });
1458
1661
 
1459
- test("session_start renders the CLEAR overlay when context usage is empty", async () => {
1662
+ test("session_start renders the 0-percent context icon when context usage is empty", async () => {
1460
1663
  const cwd = createTempDir("pi-usereq-context-empty-status-");
1461
1664
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1462
1665
  const pi = createFakePi();
@@ -1476,7 +1679,7 @@ test("session_start renders the CLEAR overlay when context usage is empty", asyn
1476
1679
  srcDir: ["src"],
1477
1680
  contextFilledCells: 0,
1478
1681
  contextPercent: 0,
1479
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1682
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1480
1683
  }),
1481
1684
  );
1482
1685
  });
@@ -1543,12 +1746,12 @@ test("context hook refreshes context usage and rounds progress cells upward", as
1543
1746
  srcDir: ["src"],
1544
1747
  contextFilledCells: 2,
1545
1748
  contextPercent: 19.1,
1546
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1749
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1547
1750
  }),
1548
1751
  );
1549
1752
  });
1550
1753
 
1551
- test("context hook renders the FULL! overlay when usage exceeds ninety percent", async () => {
1754
+ test("context hook renders the full context icon when usage exceeds ninety percent", async () => {
1552
1755
  const cwd = createTempDir("pi-usereq-context-full-status-");
1553
1756
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1554
1757
  const contextUsage = { tokens: 910, contextWindow: 1000, percent: 91 };
@@ -1570,19 +1773,19 @@ test("context hook renders the FULL! overlay when usage exceeds ninety percent",
1570
1773
  srcDir: ["src"],
1571
1774
  contextFilledCells: 10,
1572
1775
  contextPercent: 91,
1573
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1776
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1574
1777
  }),
1575
1778
  );
1576
1779
  });
1577
1780
 
1578
- test("status FULL overlay uses only CLI-supported theme tokens", async () => {
1781
+ test("status overflow context icon uses only CLI-supported theme tokens", async () => {
1579
1782
  const cwd = createTempDir("pi-usereq-context-theme-contract-");
1580
1783
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1581
1784
  const usedColors = new Set<string>();
1582
1785
  const pi = createFakePi();
1583
1786
  piUsereqExtension(pi);
1584
1787
  const ctx = createFakeCtx(cwd, { selects: [] }, {
1585
- getContextUsage: () => ({ tokens: 910, contextWindow: 1000, percent: 91 }),
1788
+ getContextUsage: () => ({ tokens: 1250, contextWindow: 1000, percent: 125 }),
1586
1789
  theme: {
1587
1790
  fg(color: string, text: string) {
1588
1791
  assert.ok(["accent", "warning", "dim", "error"].includes(color));
@@ -1611,8 +1814,8 @@ test("status FULL overlay uses only CLI-supported theme tokens", async () => {
1611
1814
  testsDir: "tests",
1612
1815
  srcDir: ["src"],
1613
1816
  contextFilledCells: 10,
1614
- contextPercent: 91,
1615
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1817
+ contextPercent: 125,
1818
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1616
1819
  }),
1617
1820
  );
1618
1821
  });
@@ -1651,7 +1854,7 @@ test("agent timing status updates et while preserving accumulated completed runt
1651
1854
  testsDir: "tests",
1652
1855
  srcDir: ["src"],
1653
1856
  contextFilledCells: 0,
1654
- et: " ⏱︎ 0:00 ⚑ --:-- ⌛︎ --:--",
1857
+ et: "⏱︎ 0:00 ⚑ --:-- ⌛︎--:--",
1655
1858
  }),
1656
1859
  );
1657
1860
 
@@ -1665,7 +1868,7 @@ test("agent timing status updates et while preserving accumulated completed runt
1665
1868
  testsDir: "tests",
1666
1869
  srcDir: ["src"],
1667
1870
  contextFilledCells: 0,
1668
- et: " ⏱︎ 1:01 ⚑ --:-- ⌛︎ --:--",
1871
+ et: "⏱︎ 1:01 ⚑ --:-- ⌛︎--:--",
1669
1872
  }),
1670
1873
  );
1671
1874
 
@@ -1686,7 +1889,7 @@ test("agent timing status updates et while preserving accumulated completed runt
1686
1889
  testsDir: "tests",
1687
1890
  srcDir: ["src"],
1688
1891
  contextFilledCells: 0,
1689
- et: " ⏱︎ --:-- ⚑ 1:01 ⌛︎ 1:01",
1892
+ et: "⏱︎ --:-- ⚑ 1:01 ⌛︎1:01",
1690
1893
  }),
1691
1894
  );
1692
1895
 
@@ -1710,7 +1913,7 @@ test("agent timing status updates et while preserving accumulated completed runt
1710
1913
  testsDir: "tests",
1711
1914
  srcDir: ["src"],
1712
1915
  contextFilledCells: 0,
1713
- et: " ⏱︎ --:-- ⚑ 1:01 ⌛︎ 1:01",
1916
+ et: "⏱︎ --:-- ⚑ 1:01 ⌛︎1:01",
1714
1917
  }),
1715
1918
  );
1716
1919
  assert.ok(clearedHandles.length >= 2);
@@ -1738,12 +1941,23 @@ test("session_start enables default custom tools and default embedded tools only
1738
1941
  assert.equal(activeTools.has("ls"), false);
1739
1942
  });
1740
1943
 
1741
- test("default configuration enables all pi-notify beep outcomes", () => {
1944
+ test("default configuration applies the documented notify, sound, and pushover defaults", () => {
1742
1945
  const config = getDefaultConfig(createTempDir("pi-usereq-default-notify-"));
1743
1946
 
1744
- assert.equal(config["notify-beep-on-end"], true);
1745
- assert.equal(config["notify-beep-on-esc"], true);
1746
- assert.equal(config["notify-beep-on-error"], true);
1947
+ assert.equal(config["notify-enabled"], false);
1948
+ assert.equal(config["notify-on-completed"], true);
1949
+ assert.equal(config["notify-on-interrupted"], false);
1950
+ assert.equal(config["notify-on-failed"], false);
1951
+ assert.equal(config["notify-sound"], "none");
1952
+ assert.equal(config["notify-sound-on-completed"], true);
1953
+ assert.equal(config["notify-sound-on-interrupted"], false);
1954
+ assert.equal(config["notify-sound-on-failed"], false);
1955
+ assert.equal(config["notify-pushover-enabled"], false);
1956
+ assert.equal(config["notify-pushover-on-completed"], true);
1957
+ assert.equal(config["notify-pushover-on-interrupted"], false);
1958
+ assert.equal(config["notify-pushover-on-failed"], false);
1959
+ assert.equal(config.PI_NOTIFY_CMD, 'notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a "PI-useReq" "%%PROMT%% @ %%BASE%% [%%TIME%%]" "%%RESULT%%"');
1960
+ assert.equal(config["notify-pushover-text"], "%%RESULT%%\n%%ARGS%%");
1747
1961
  });
1748
1962
 
1749
1963
  test("configuration menu can enable embedded builtin tools", async () => {
@@ -1757,7 +1971,7 @@ test("configuration menu can enable embedded builtin tools", async () => {
1757
1971
  await command!.handler(
1758
1972
  "",
1759
1973
  createFakeCtx(cwd, {
1760
- selects: ["startup tools", "Toggle tool", "grep", "Back", "Save and close"],
1974
+ selects: ["Enable tools", "Enable tools", "grep", "Save and close", "Save and close"],
1761
1975
  }),
1762
1976
  );
1763
1977
 
@@ -1777,7 +1991,7 @@ test("configuration menu can disable configurable active tools", async () => {
1777
1991
  await command!.handler(
1778
1992
  "",
1779
1993
  createFakeCtx(cwd, {
1780
- selects: ["startup tools", "Disable all configurable tools", "Back", "Save and close"],
1994
+ selects: ["Enable tools", "Disable all configurable tools", "Save and close", "Save and close"],
1781
1995
  }),
1782
1996
  );
1783
1997
 
@@ -1786,7 +2000,7 @@ test("configuration menu can disable configurable active tools", async () => {
1786
2000
  assert.deepEqual(pi.getActiveTools().filter((toolName: string) => PI_USEREQ_STARTUP_TOOL_NAMES.includes(toolName as never)), []);
1787
2001
  });
1788
2002
 
1789
- test("configuration menu can persist pi-notify settings", async () => {
2003
+ test("configuration menu can persist notify and sound settings", async () => {
1790
2004
  const cwd = createTempDir("pi-usereq-menu-notify-");
1791
2005
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1792
2006
  const pi = createFakePi();
@@ -1796,19 +2010,26 @@ test("configuration menu can persist pi-notify settings", async () => {
1796
2010
  const ctx = createFakeCtx(cwd, {
1797
2011
  selects: [
1798
2012
  "notifications",
1799
- "Toggle beep on success",
1800
- "Toggle beep on error",
1801
- "Selected notify command",
2013
+ "Enable notification",
2014
+ "Notification events",
2015
+ "Prompt failed",
2016
+ "Save and close",
2017
+ "Enable sound",
1802
2018
  "high",
2019
+ "Sound events",
2020
+ "Prompt interrupted",
2021
+ "Save and close",
1803
2022
  "Sound toggle hotkey bind",
1804
- "Notify command (low vol.)",
1805
- "Notify command (mid vol.)",
1806
- "Notify command (high vol.)",
1807
- "Back",
2023
+ "Notify command",
2024
+ "Sound command (low vol.)",
2025
+ "Sound command (mid vol.)",
2026
+ "Sound command (high vol.)",
2027
+ "Save and close",
1808
2028
  "Save and close",
1809
2029
  ],
1810
2030
  inputs: [
1811
2031
  "alt+shift+s",
2032
+ "notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a \"PI-useReq\" \"%%PROMT%% @ %%BASE%% [%%TIME%%]\" \"%%RESULT%%\"",
1812
2033
  "echo low %%INSTALLATION_PATH%%",
1813
2034
  "echo mid %%INSTALLATION_PATH%%",
1814
2035
  "echo high %%INSTALLATION_PATH%%",
@@ -1818,11 +2039,16 @@ test("configuration menu can persist pi-notify settings", async () => {
1818
2039
  await command!.handler("", ctx);
1819
2040
 
1820
2041
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1821
- assert.equal(config["notify-beep-on-end"], false);
1822
- assert.equal(config["notify-beep-on-error"], false);
1823
- assert.equal(config["notify-beep-on-esc"], true);
2042
+ assert.equal(config["notify-enabled"], true);
2043
+ assert.equal(config["notify-on-completed"], true);
2044
+ assert.equal(config["notify-on-interrupted"], false);
2045
+ assert.equal(config["notify-on-failed"], true);
1824
2046
  assert.equal(config["notify-sound"], "high");
2047
+ assert.equal(config["notify-sound-on-completed"], true);
2048
+ assert.equal(config["notify-sound-on-interrupted"], true);
2049
+ assert.equal(config["notify-sound-on-failed"], false);
1825
2050
  assert.equal(config["notify-sound-toggle-shortcut"], "alt+shift+s");
2051
+ assert.equal(config.PI_NOTIFY_CMD, "notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a \"PI-useReq\" \"%%PROMT%% @ %%BASE%% [%%TIME%%]\" \"%%RESULT%%\"");
1826
2052
  assert.equal(config.PI_NOTIFY_SOUND_LOW_CMD, "echo low %%INSTALLATION_PATH%%");
1827
2053
  assert.equal(config.PI_NOTIFY_SOUND_MID_CMD, "echo mid %%INSTALLATION_PATH%%");
1828
2054
  assert.equal(config.PI_NOTIFY_SOUND_HIGH_CMD, "echo high %%INSTALLATION_PATH%%");
@@ -1834,8 +2060,7 @@ test("configuration menu can persist pi-notify settings", async () => {
1834
2060
  testsDir: "tests",
1835
2061
  srcDir: ["src"],
1836
2062
  contextFilledCells: 0,
1837
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1838
- beep: "esc",
2063
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
1839
2064
  sound: "high",
1840
2065
  }),
1841
2066
  );
@@ -1851,17 +2076,22 @@ test("configuration menu can persist pushover settings", async () => {
1851
2076
  const ctx = createFakeCtx(cwd, {
1852
2077
  selects: [
1853
2078
  "notifications",
1854
- "Pushover notifications",
1855
- "Notify on success",
1856
- "User Key/Delivery Group Key",
1857
- "Token/API Token Key",
1858
- "Priority",
1859
- "1=High Priority",
1860
- "Back",
1861
- "Back",
2079
+ "Enable pushover",
2080
+ "Pushover events",
2081
+ "Prompt failed",
2082
+ "Save and close",
2083
+ "Pushover priority",
2084
+ "High",
2085
+ "Pushover title",
2086
+ "Pushover text",
2087
+ "Pushover User Key/Delivery Group Key",
2088
+ "Pushover Token/API Token Key",
2089
+ "Save and close",
1862
2090
  "Save and close",
1863
2091
  ],
1864
2092
  inputs: [
2093
+ "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2094
+ "%%RESULT%%\n%%ARGS%%",
1865
2095
  "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
1866
2096
  "ah6bf5u2sj63mcvou6qamiabeoubbe",
1867
2097
  ],
@@ -1870,11 +2100,15 @@ test("configuration menu can persist pushover settings", async () => {
1870
2100
  await command!.handler("", ctx);
1871
2101
 
1872
2102
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
1873
- assert.equal(config["notify-pushover-global-disable"], false);
1874
- assert.equal(config["notify-pushover-on-success"], true);
2103
+ assert.equal(config["notify-pushover-enabled"], true);
2104
+ assert.equal(config["notify-pushover-on-completed"], true);
2105
+ assert.equal(config["notify-pushover-on-interrupted"], false);
2106
+ assert.equal(config["notify-pushover-on-failed"], true);
1875
2107
  assert.equal(config["notify-pushover-user-key"], "gzfjjvp1xxmhibqwzh9m7i1zwvf83j");
1876
2108
  assert.equal(config["notify-pushover-api-token"], "ah6bf5u2sj63mcvou6qamiabeoubbe");
1877
2109
  assert.equal(config["notify-pushover-priority"], 1);
2110
+ assert.equal(config["notify-pushover-title"], "%%PROMT%% @ %%BASE%% [%%TIME%%]");
2111
+ assert.equal(config["notify-pushover-text"], "%%RESULT%%\n%%ARGS%%");
1878
2112
  assert.equal(
1879
2113
  ctx.__state.statuses.get("pi-usereq"),
1880
2114
  buildExpectedFakeStatusText({
@@ -1883,13 +2117,13 @@ test("configuration menu can persist pushover settings", async () => {
1883
2117
  testsDir: "tests",
1884
2118
  srcDir: ["src"],
1885
2119
  contextFilledCells: 0,
1886
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
1887
- pushover: "on",
2120
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2121
+ sound: "none",
1888
2122
  }),
1889
2123
  );
1890
2124
  });
1891
2125
 
1892
- test("successful req prompt completion sends pushover requests and suppresses invalid scenarios", async () => {
2126
+ test("prompt-end pushover requests honor global enable, event toggles, credentials, priority, and templates", async () => {
1893
2127
  const cwd = createTempDir("pi-usereq-pushover-run-");
1894
2128
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
1895
2129
  const configPath = getProjectConfigPath(cwd);
@@ -1932,10 +2166,13 @@ test("successful req prompt completion sends pushover requests and suppresses in
1932
2166
 
1933
2167
  try {
1934
2168
  writeConfig({
1935
- "notify-pushover-on-success": true,
2169
+ "notify-pushover-enabled": true,
2170
+ "notify-pushover-on-completed": true,
1936
2171
  "notify-pushover-user-key": "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
1937
2172
  "notify-pushover-api-token": "ah6bf5u2sj63mcvou6qamiabeoubbe",
1938
2173
  "notify-pushover-priority": 1,
2174
+ "notify-pushover-title": "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2175
+ "notify-pushover-text": "%%RESULT%%\n%%ARGS%%",
1939
2176
  });
1940
2177
  await pi.emit("session_start", { reason: "startup" }, ctx);
1941
2178
  recordedRequests.length = 0;
@@ -1959,8 +2196,8 @@ test("successful req prompt completion sends pushover requests and suppresses in
1959
2196
  assert.equal(successParams.get("token"), "ah6bf5u2sj63mcvou6qamiabeoubbe");
1960
2197
  assert.equal(successParams.get("user"), "gzfjjvp1xxmhibqwzh9m7i1zwvf83j");
1961
2198
  assert.equal(successParams.get("priority"), "1");
1962
- assert.equal(successParams.get("message"), "Inspect src/index.ts");
1963
- assert.equal(successParams.get("title"), `analyze @ ${buildExpectedFakeBasePath(cwd)} [0:05]`);
2199
+ assert.equal(successParams.get("message"), `${buildExpectedNotifyResult("stop")}\nInspect src/index.ts`);
2200
+ assert.equal(successParams.get("title"), `analyze @ ${buildExpectedNotifyBasePath(cwd)} [0:05]`);
1964
2201
 
1965
2202
  recordedRequests.length = 0;
1966
2203
  nowMs = 10_000;
@@ -1995,17 +2232,69 @@ test("successful req prompt completion sends pushover requests and suppresses in
1995
2232
  assert.equal(recordedRequests.length, 0);
1996
2233
 
1997
2234
  writeConfig({
1998
- "notify-pushover-on-success": true,
2235
+ "notify-pushover-enabled": true,
2236
+ "notify-pushover-on-interrupted": true,
2237
+ "notify-pushover-on-failed": true,
1999
2238
  "notify-pushover-user-key": "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
2000
- "notify-pushover-api-token": "",
2239
+ "notify-pushover-api-token": "ah6bf5u2sj63mcvou6qamiabeoubbe",
2001
2240
  "notify-pushover-priority": 1,
2241
+ "notify-pushover-title": "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2242
+ "notify-pushover-text": "%%RESULT%%\n%%ARGS%%",
2002
2243
  });
2003
2244
  await pi.emit("session_start", { reason: "startup" }, ctx);
2004
2245
  recordedRequests.length = 0;
2005
2246
  nowMs = 30_000;
2006
- await pi.commands.get("req-write")!.handler("Write docs", ctx);
2247
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2007
2248
  await pi.emit("agent_start", {}, ctx);
2008
2249
  nowMs = 32_000;
2250
+ await pi.emit("agent_end", {
2251
+ messages: [
2252
+ {
2253
+ role: "assistant",
2254
+ stopReason: "aborted",
2255
+ content: [],
2256
+ },
2257
+ ],
2258
+ }, ctx);
2259
+ assert.equal(recordedRequests.length, 1);
2260
+ const abortedParams = new URLSearchParams(recordedRequests[0]?.body ?? "");
2261
+ assert.equal(abortedParams.get("title"), `change @ ${buildExpectedNotifyBasePath(cwd)} [0:02]`);
2262
+ assert.equal(abortedParams.get("message"), `${buildExpectedNotifyResult("aborted")}\nAdjust docs`);
2263
+
2264
+ recordedRequests.length = 0;
2265
+ nowMs = 40_000;
2266
+ await pi.commands.get("req-write")!.handler("Write docs", ctx);
2267
+ await pi.emit("agent_start", {}, ctx);
2268
+ nowMs = 42_000;
2269
+ await pi.emit("agent_end", {
2270
+ messages: [
2271
+ {
2272
+ role: "assistant",
2273
+ stopReason: "error",
2274
+ content: [],
2275
+ },
2276
+ ],
2277
+ }, ctx);
2278
+ assert.equal(recordedRequests.length, 1);
2279
+ const errorParams = new URLSearchParams(recordedRequests[0]?.body ?? "");
2280
+ assert.equal(errorParams.get("title"), `write @ ${buildExpectedNotifyBasePath(cwd)} [0:02]`);
2281
+ assert.equal(errorParams.get("message"), `${buildExpectedNotifyResult("error")}\nWrite docs`);
2282
+
2283
+ writeConfig({
2284
+ "notify-pushover-enabled": true,
2285
+ "notify-pushover-on-completed": true,
2286
+ "notify-pushover-user-key": "gzfjjvp1xxmhibqwzh9m7i1zwvf83j",
2287
+ "notify-pushover-api-token": "",
2288
+ "notify-pushover-priority": 1,
2289
+ "notify-pushover-title": "%%PROMT%% @ %%BASE%% [%%TIME%%]",
2290
+ "notify-pushover-text": "%%RESULT%%\n%%ARGS%%",
2291
+ });
2292
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2293
+ recordedRequests.length = 0;
2294
+ nowMs = 50_000;
2295
+ await pi.commands.get("req-check")!.handler("Check docs", ctx);
2296
+ await pi.emit("agent_start", {}, ctx);
2297
+ nowMs = 53_000;
2009
2298
  await pi.emit("agent_end", {
2010
2299
  messages: [
2011
2300
  {
@@ -2022,7 +2311,7 @@ test("successful req prompt completion sends pushover requests and suppresses in
2022
2311
  }
2023
2312
  });
2024
2313
 
2025
- test("pushover global disable suppresses delivery without clearing enabled state", async () => {
2314
+ test("pushover global enable controls status and suppresses delivery when disabled", async () => {
2026
2315
  const cwd = createTempDir("pi-usereq-pushover-disabled-");
2027
2316
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2028
2317
  const pi = createFakePi();
@@ -2032,13 +2321,9 @@ test("pushover global disable suppresses delivery without clearing enabled state
2032
2321
  const ctx = createFakeCtx(cwd, {
2033
2322
  selects: [
2034
2323
  "notifications",
2035
- "Pushover notifications",
2036
- "Notify on success",
2037
- "Global disable",
2038
- "User Key/Delivery Group Key",
2039
- "Token/API Token Key",
2040
- "Back",
2041
- "Back",
2324
+ "Pushover User Key/Delivery Group Key",
2325
+ "Pushover Token/API Token Key",
2326
+ "Save and close",
2042
2327
  "Save and close",
2043
2328
  ],
2044
2329
  inputs: [
@@ -2050,8 +2335,8 @@ test("pushover global disable suppresses delivery without clearing enabled state
2050
2335
  await command!.handler("", ctx);
2051
2336
 
2052
2337
  const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2053
- assert.equal(config["notify-pushover-on-success"], true);
2054
- assert.equal(config["notify-pushover-global-disable"], true);
2338
+ assert.equal(config["notify-pushover-enabled"], false);
2339
+ assert.equal(config["notify-pushover-on-completed"], true);
2055
2340
  assert.equal(
2056
2341
  ctx.__state.statuses.get("pi-usereq"),
2057
2342
  buildExpectedFakeStatusText({
@@ -2060,8 +2345,8 @@ test("pushover global disable suppresses delivery without clearing enabled state
2060
2345
  testsDir: "tests",
2061
2346
  srcDir: ["src"],
2062
2347
  contextFilledCells: 0,
2063
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
2064
- pushover: "on",
2348
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2349
+ sound: "none",
2065
2350
  }),
2066
2351
  );
2067
2352
 
@@ -2108,6 +2393,201 @@ test("pushover global disable suppresses delivery without clearing enabled state
2108
2393
  }
2109
2394
  });
2110
2395
 
2396
+ test("command notify routes through PI_NOTIFY_CMD placeholders and per-event toggles", async () => {
2397
+ const cwd = createTempDir("pi-usereq-command-notify-");
2398
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2399
+ const configPath = getProjectConfigPath(cwd);
2400
+ const writeConfig = (overrides: Record<string, unknown>) => {
2401
+ const config = {
2402
+ ...getDefaultConfig(cwd),
2403
+ ...overrides,
2404
+ };
2405
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
2406
+ };
2407
+ const pi = createFakePi();
2408
+ piUsereqExtension(pi);
2409
+ const ctx = createFakeCtx(cwd);
2410
+ const originalDateNow = Date.now;
2411
+ const recordedCommands: string[] = [];
2412
+ let nowMs = 0;
2413
+ setPiNotifySpawnForTests(((command: string, args?: readonly string[] | undefined, _options?: Record<string, unknown>) => {
2414
+ void command;
2415
+ recordedCommands.push(String(args && args.length > 0 ? args[args.length - 1] : ""));
2416
+ const fakeChild = {
2417
+ once(_eventName: string, _handler: (...handlerArgs: unknown[]) => void) {
2418
+ return fakeChild;
2419
+ },
2420
+ unref() {
2421
+ return undefined;
2422
+ },
2423
+ };
2424
+ return fakeChild as unknown as ReturnType<typeof import("node:child_process").spawn>;
2425
+ }) as typeof import("node:child_process").spawn);
2426
+ Date.now = () => nowMs;
2427
+
2428
+ try {
2429
+ writeConfig({
2430
+ "notify-enabled": true,
2431
+ "notify-on-completed": true,
2432
+ "notify-on-interrupted": false,
2433
+ "notify-on-failed": true,
2434
+ "notify-sound": "none",
2435
+ PI_NOTIFY_CMD: 'notify-send "%%PROMT%% @ %%BASE%% [%%TIME%%]" "%%RESULT%%"',
2436
+ });
2437
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2438
+ recordedCommands.length = 0;
2439
+ nowMs = 0;
2440
+ await pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx);
2441
+ await pi.emit("agent_start", {}, ctx);
2442
+ nowMs = 5_000;
2443
+ await pi.emit("agent_end", {
2444
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2445
+ }, ctx);
2446
+
2447
+ assert.equal(recordedCommands.length, 1);
2448
+ assert.match(
2449
+ recordedCommands[0] ?? "",
2450
+ new RegExp(`analyze @ ${buildExpectedNotifyBasePath(cwd).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\[0:05\\]`),
2451
+ );
2452
+ assert.match(recordedCommands[0] ?? "", new RegExp(buildExpectedNotifyResult("stop")));
2453
+
2454
+ recordedCommands.length = 0;
2455
+ nowMs = 10_000;
2456
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2457
+ await pi.emit("agent_start", {}, ctx);
2458
+ nowMs = 12_000;
2459
+ await pi.emit("agent_end", {
2460
+ messages: [{ role: "assistant", stopReason: "aborted", content: [] }],
2461
+ }, ctx);
2462
+ assert.equal(recordedCommands.length, 0);
2463
+
2464
+ writeConfig({
2465
+ "notify-enabled": true,
2466
+ "notify-on-completed": true,
2467
+ "notify-on-interrupted": true,
2468
+ "notify-on-failed": true,
2469
+ "notify-sound": "none",
2470
+ PI_NOTIFY_CMD: 'notify-send "%%PROMT%% @ %%BASE%% [%%TIME%%]" "%%RESULT%%"',
2471
+ });
2472
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2473
+ recordedCommands.length = 0;
2474
+ nowMs = 20_000;
2475
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2476
+ await pi.emit("agent_start", {}, ctx);
2477
+ nowMs = 22_000;
2478
+ await pi.emit("agent_end", {
2479
+ messages: [{ role: "assistant", stopReason: "aborted", content: [] }],
2480
+ }, ctx);
2481
+ assert.equal(recordedCommands.length, 1);
2482
+ assert.match(
2483
+ recordedCommands[0] ?? "",
2484
+ new RegExp(`change @ ${buildExpectedNotifyBasePath(cwd).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\[0:02\\]`),
2485
+ );
2486
+ assert.match(recordedCommands[0] ?? "", new RegExp(buildExpectedNotifyResult("aborted")));
2487
+
2488
+ recordedCommands.length = 0;
2489
+ nowMs = 30_000;
2490
+ await pi.commands.get("req-write")!.handler("Write docs", ctx);
2491
+ await pi.emit("agent_start", {}, ctx);
2492
+ nowMs = 33_000;
2493
+ await pi.emit("agent_end", {
2494
+ messages: [{ role: "assistant", stopReason: "error", content: [] }],
2495
+ }, ctx);
2496
+ assert.equal(recordedCommands.length, 1);
2497
+ assert.match(
2498
+ recordedCommands[0] ?? "",
2499
+ new RegExp(`write @ ${buildExpectedNotifyBasePath(cwd).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\[0:03\\]`),
2500
+ );
2501
+ assert.match(recordedCommands[0] ?? "", new RegExp(buildExpectedNotifyResult("error")));
2502
+ } finally {
2503
+ setPiNotifySpawnForTests(undefined);
2504
+ Date.now = originalDateNow;
2505
+ }
2506
+ });
2507
+
2508
+ test("sound routing honors the selected sound state and per-event toggles", async () => {
2509
+ const cwd = createTempDir("pi-usereq-sound-routing-");
2510
+ fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2511
+ const configPath = getProjectConfigPath(cwd);
2512
+ const writeConfig = (overrides: Record<string, unknown>) => {
2513
+ const config = {
2514
+ ...getDefaultConfig(cwd),
2515
+ ...overrides,
2516
+ };
2517
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
2518
+ };
2519
+ const pi = createFakePi();
2520
+ piUsereqExtension(pi);
2521
+ const ctx = createFakeCtx(cwd);
2522
+ const recordedCommands: string[] = [];
2523
+ setPiNotifySpawnForTests(((command: string, args?: readonly string[] | undefined, _options?: Record<string, unknown>) => {
2524
+ void command;
2525
+ recordedCommands.push(String(args && args.length > 0 ? args[args.length - 1] : ""));
2526
+ const fakeChild = {
2527
+ once(_eventName: string, _handler: (...handlerArgs: unknown[]) => void) {
2528
+ return fakeChild;
2529
+ },
2530
+ unref() {
2531
+ return undefined;
2532
+ },
2533
+ };
2534
+ return fakeChild as unknown as ReturnType<typeof import("node:child_process").spawn>;
2535
+ }) as typeof import("node:child_process").spawn);
2536
+
2537
+ try {
2538
+ writeConfig({
2539
+ "notify-enabled": false,
2540
+ "notify-sound": "none",
2541
+ "notify-sound-on-completed": true,
2542
+ PI_NOTIFY_SOUND_HIGH_CMD: "echo high %%INSTALLATION_PATH%%",
2543
+ });
2544
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2545
+ recordedCommands.length = 0;
2546
+ await pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx);
2547
+ await pi.emit("agent_start", {}, ctx);
2548
+ await pi.emit("agent_end", {
2549
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
2550
+ }, ctx);
2551
+ assert.deepEqual(recordedCommands, []);
2552
+
2553
+ writeConfig({
2554
+ "notify-enabled": false,
2555
+ "notify-sound": "high",
2556
+ "notify-sound-on-completed": false,
2557
+ "notify-sound-on-interrupted": true,
2558
+ "notify-sound-on-failed": false,
2559
+ PI_NOTIFY_SOUND_HIGH_CMD: "echo high %%INSTALLATION_PATH%%",
2560
+ });
2561
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2562
+ recordedCommands.length = 0;
2563
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
2564
+ await pi.emit("agent_start", {}, ctx);
2565
+ await pi.emit("agent_end", {
2566
+ messages: [{ role: "assistant", stopReason: "aborted", content: [] }],
2567
+ }, ctx);
2568
+ assert.equal(recordedCommands.length, 1);
2569
+
2570
+ writeConfig({
2571
+ "notify-enabled": false,
2572
+ "notify-sound": "high",
2573
+ "notify-sound-on-completed": false,
2574
+ "notify-sound-on-interrupted": false,
2575
+ "notify-sound-on-failed": true,
2576
+ PI_NOTIFY_SOUND_HIGH_CMD: "echo high %%INSTALLATION_PATH%%",
2577
+ });
2578
+ await pi.emit("session_start", { reason: "startup" }, ctx);
2579
+ recordedCommands.length = 0;
2580
+ await pi.commands.get("req-write")!.handler("Write docs", ctx);
2581
+ await pi.emit("agent_start", {}, ctx);
2582
+ await pi.emit("agent_end", {
2583
+ messages: [{ role: "assistant", stopReason: "error", content: [] }],
2584
+ }, ctx);
2585
+ assert.equal(recordedCommands.length, 1);
2586
+ } finally {
2587
+ setPiNotifySpawnForTests(undefined);
2588
+ }
2589
+ });
2590
+
2111
2591
  test("sound toggle shortcut cycles persisted pi-notify sound levels", async () => {
2112
2592
  const cwd = createTempDir("pi-usereq-shortcut-notify-");
2113
2593
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
@@ -2137,7 +2617,7 @@ test("sound toggle shortcut cycles persisted pi-notify sound levels", async () =
2137
2617
  testsDir: "tests",
2138
2618
  srcDir: ["src"],
2139
2619
  contextFilledCells: 0,
2140
- et: " ⏱︎ --:-- ⚑ --:-- ⌛︎ --:--",
2620
+ et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
2141
2621
  sound: expectedSound,
2142
2622
  }),
2143
2623
  );
@@ -2176,24 +2656,24 @@ test("static-check exposes the supported programming languages", () => {
2176
2656
  });
2177
2657
  });
2178
2658
 
2179
- test("configuration menu can add static-check entries from raw specs", async () => {
2659
+ test("configuration menu omits removed static-check raw-spec and reference actions", async () => {
2180
2660
  const cwd = createTempDir("pi-usereq-menu-sc-");
2181
2661
  fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
2182
2662
  const pi = createFakePi();
2183
2663
  piUsereqExtension(pi);
2184
2664
  const command = pi.commands.get("pi-usereq");
2185
2665
  assert.ok(command);
2666
+ const ctx = createFakeCtx(cwd, {
2667
+ selects: ["static-check"],
2668
+ });
2186
2669
 
2187
- await command!.handler(
2188
- "",
2189
- createFakeCtx(cwd, {
2190
- selects: ["static-check", "Add entry from LANG=MODULE[,CMD[,PARAM...]]", "Back", "Save and close"],
2191
- inputs: ["Python=Command,true"],
2192
- }),
2193
- );
2670
+ await command!.handler("", ctx);
2194
2671
 
2195
- const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
2196
- assert.deepEqual(config["static-check"].Python, [{ module: "Command", cmd: "true" }]);
2672
+ const renderedStaticCheckMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
2673
+ assert.match(renderedStaticCheckMenu, /Add static code checker/);
2674
+ assert.match(renderedStaticCheckMenu, /Remove static code checker/);
2675
+ assert.doesNotMatch(renderedStaticCheckMenu, /Add entry from LANG=MODULE\[,CMD\[,PARAM\.\.\.\]\]/);
2676
+ assert.doesNotMatch(renderedStaticCheckMenu, /Show supported languages/);
2197
2677
  });
2198
2678
 
2199
2679
  test("configuration menu hides removed static-check modules from user-facing actions", async () => {
@@ -2210,7 +2690,7 @@ test("configuration menu hides removed static-check modules from user-facing act
2210
2690
  await command!.handler("", ctx);
2211
2691
 
2212
2692
  const renderedStaticCheckMenu = (ctx.__state.customRenderLines[1] ?? []).join("\n");
2213
- assert.match(renderedStaticCheckMenu, /Add entry for supported language/);
2693
+ assert.match(renderedStaticCheckMenu, /Add static code checker/);
2214
2694
  assert.doesNotMatch(renderedStaticCheckMenu, /Pylance/);
2215
2695
  assert.doesNotMatch(renderedStaticCheckMenu, /Ruff/);
2216
2696
  assert.doesNotMatch(renderedStaticCheckMenu, /Dummy/);
@@ -2229,9 +2709,9 @@ test("configuration menu can add guided static-check entries for explicit suppor
2229
2709
  createFakeCtx(cwd, {
2230
2710
  selects: [
2231
2711
  "static-check",
2232
- "Add entry for supported language",
2712
+ "Add static code checker",
2233
2713
  "Python",
2234
- "Back",
2714
+ "Save and close",
2235
2715
  "Save and close",
2236
2716
  ],
2237
2717
  inputs: ["true", ""],