@dungle-scrubs/tallow 0.8.26 → 0.8.28
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/README.md +42 -1
- package/dist/cli.js +7 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/interactive-mode-patch.d.ts +1 -0
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +40 -1
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/model-metadata-overrides.d.ts +2 -5
- package/dist/model-metadata-overrides.d.ts.map +1 -1
- package/dist/model-metadata-overrides.js +23 -12
- package/dist/model-metadata-overrides.js.map +1 -1
- package/dist/pid-manager.d.ts +2 -9
- package/dist/pid-manager.d.ts.map +1 -1
- package/dist/pid-manager.js +1 -58
- package/dist/pid-manager.js.map +1 -1
- package/dist/pid-schema.d.ts +51 -0
- package/dist/pid-schema.d.ts.map +1 -0
- package/dist/pid-schema.js +70 -0
- package/dist/pid-schema.js.map +1 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +24 -17
- package/dist/sdk.js.map +1 -1
- package/dist/workspace-transition-interactive.d.ts.map +1 -1
- package/dist/workspace-transition-interactive.js +53 -3
- package/dist/workspace-transition-interactive.js.map +1 -1
- package/dist/workspace-transition.d.ts +2 -1
- package/dist/workspace-transition.d.ts.map +1 -1
- package/dist/workspace-transition.js +16 -4
- package/dist/workspace-transition.js.map +1 -1
- package/extensions/__integration__/audit-findings.test.ts +309 -0
- package/extensions/__integration__/cd-tool-guidelines.test.ts +46 -0
- package/extensions/__integration__/tasks-runtime.test.ts +63 -12
- package/extensions/__integration__/welcome-screen.test.ts +240 -0
- package/extensions/_shared/lazy-init.ts +88 -3
- package/extensions/_shared/pid-registry.ts +8 -82
- package/extensions/background-task-tool/index.ts +1 -1
- package/extensions/cd-tool/index.ts +4 -1
- package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
- package/extensions/clear/__tests__/clear.test.ts +38 -0
- package/extensions/edit-tool-enhanced/index.ts +3 -1
- package/extensions/git-status/__tests__/git-status.test.ts +32 -0
- package/extensions/health/__tests__/diagnostics.test.ts +25 -0
- package/extensions/health/index.ts +61 -0
- package/extensions/loop/__tests__/loop.test.ts +365 -1
- package/extensions/loop/index.ts +213 -3
- package/extensions/mcp-adapter-tool/index.ts +1 -1
- package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
- package/extensions/permissions/__tests__/permissions.test.ts +213 -0
- package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
- package/extensions/prompt-suggestions/__tests__/autocomplete.test.ts +111 -3
- package/extensions/prompt-suggestions/autocomplete.ts +23 -5
- package/extensions/prompt-suggestions/index.ts +62 -3
- package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
- package/extensions/read-tool-enhanced/index.ts +5 -1
- package/extensions/session-memory/index.ts +1 -1
- package/extensions/session-namer/index.ts +1 -1
- package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
- package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +9 -8
- package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
- package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
- package/extensions/subagent-tool/formatting.ts +2 -0
- package/extensions/subagent-tool/index.ts +160 -97
- package/extensions/subagent-tool/process.ts +152 -40
- package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
- package/extensions/tasks/extension.json +1 -0
- package/extensions/tasks/index.ts +2 -12
- package/extensions/tasks/state/index.ts +26 -0
- package/extensions/teams-tool/dashboard.ts +13 -1
- package/extensions/teams-tool/sessions/spawn.ts +2 -2
- package/extensions/teams-tool/tools/register-extension.ts +10 -2
- package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
- package/extensions/welcome-screen/__tests__/welcome-screen.test.ts +35 -0
- package/extensions/welcome-screen/extension.json +20 -0
- package/extensions/welcome-screen/index.ts +189 -0
- package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
- package/extensions/wezterm-notify/index.ts +5 -3
- package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +2 -2
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/index.js +2 -2
- package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +309 -25
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +392 -72
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +30 -0
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.js +50 -6
- package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +27 -0
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js +59 -4
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +9 -0
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.js +50 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/package.json +1 -1
- package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +134 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tmux-compat.test.ts +204 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +49 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +2 -0
- package/node_modules/@mariozechner/pi-tui/src/index.ts +11 -0
- package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +478 -140
- package/node_modules/@mariozechner/pi-tui/src/keys.ts +84 -6
- package/node_modules/@mariozechner/pi-tui/src/terminal.ts +69 -4
- package/node_modules/@mariozechner/pi-tui/src/tui.ts +64 -1
- package/package.json +11 -10
- package/runtime/config.ts +7 -0
- package/runtime/model-metadata-overrides.ts +7 -0
- package/runtime/pid-schema.ts +13 -0
- package/skills/tallow-expert/SKILL.md +7 -5
package/extensions/loop/index.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - `until "<condition>"` — the model evaluates the condition each
|
|
11
11
|
* iteration and calls the `loop_stop` tool when it's met
|
|
12
12
|
*
|
|
13
|
-
* Usage:
|
|
13
|
+
* Usage (strict syntax):
|
|
14
14
|
* /loop 5m check the deploy status
|
|
15
15
|
* /loop 1m x10 run the test suite
|
|
16
16
|
* /loop 2m until "build is done" check fuse index progress
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* /loop 30s /stats
|
|
19
19
|
* /loop stop
|
|
20
20
|
* /loop status
|
|
21
|
+
*
|
|
22
|
+
* Natural language (auto-parsed → prefilled for confirmation):
|
|
23
|
+
* /loop check ci every 2 minutes until it passes
|
|
24
|
+
* /loop run tests every 30 seconds, stop when they pass
|
|
25
|
+
* /loop monitor deploy health every minute, max 20 tries
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
28
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
@@ -277,6 +282,201 @@ export function parseLoopArgs(args: string): LoopArgs | { action: "error"; messa
|
|
|
277
282
|
};
|
|
278
283
|
}
|
|
279
284
|
|
|
285
|
+
// ── Natural Language Parsing ──────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/** Map from human-readable unit names to short suffixes. */
|
|
288
|
+
const UNIT_ALIASES: Readonly<Record<string, string>> = {
|
|
289
|
+
s: "s",
|
|
290
|
+
sec: "s",
|
|
291
|
+
secs: "s",
|
|
292
|
+
second: "s",
|
|
293
|
+
seconds: "s",
|
|
294
|
+
m: "m",
|
|
295
|
+
min: "m",
|
|
296
|
+
mins: "m",
|
|
297
|
+
minute: "m",
|
|
298
|
+
minutes: "m",
|
|
299
|
+
h: "h",
|
|
300
|
+
hr: "h",
|
|
301
|
+
hrs: "h",
|
|
302
|
+
hour: "h",
|
|
303
|
+
hours: "h",
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Attempt to parse a natural-language loop description into structured params.
|
|
308
|
+
*
|
|
309
|
+
* Extracts interval, condition, max iterations, and prompt from free-form
|
|
310
|
+
* text using regex heuristics. Returns null if no interval can be identified.
|
|
311
|
+
*
|
|
312
|
+
* This is a best-effort parser — the result is prefilled in the editor for
|
|
313
|
+
* the user to review, not executed directly.
|
|
314
|
+
*
|
|
315
|
+
* @param text - Free-form loop description
|
|
316
|
+
* @returns Parsed start params, or null if no interval was found
|
|
317
|
+
*/
|
|
318
|
+
export function parseNaturalLanguageLoop(
|
|
319
|
+
text: string
|
|
320
|
+
): Extract<LoopArgs, { action: "start" }> | null {
|
|
321
|
+
let work = text;
|
|
322
|
+
|
|
323
|
+
// ── Interval extraction ──────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
let intervalMs: number | null = null;
|
|
326
|
+
let intervalLabel: string | null = null;
|
|
327
|
+
|
|
328
|
+
// "every <N> <unit-word>" — e.g. "every 2 minutes", "every 30 seconds"
|
|
329
|
+
const everyLong = work.match(
|
|
330
|
+
/\bevery\s+(\d+)\s+(seconds?|secs?|sec|minutes?|mins?|min|hours?|hrs?|hr)\b/i
|
|
331
|
+
);
|
|
332
|
+
if (everyLong) {
|
|
333
|
+
const val = parseInt(everyLong[1], 10);
|
|
334
|
+
const unit = UNIT_ALIASES[everyLong[2].toLowerCase()];
|
|
335
|
+
if (unit && val > 0) {
|
|
336
|
+
intervalMs = val * UNIT_MS[unit];
|
|
337
|
+
intervalLabel = `${val}${unit}`;
|
|
338
|
+
work = work.replace(everyLong[0], " ");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// "every <unit-word>" without number → 1 unit. e.g. "every minute"
|
|
343
|
+
if (!intervalMs) {
|
|
344
|
+
const everyBare = work.match(/\bevery\s+(second|minute|hour)\b/i);
|
|
345
|
+
if (everyBare) {
|
|
346
|
+
const unit = UNIT_ALIASES[everyBare[1].toLowerCase()];
|
|
347
|
+
if (unit) {
|
|
348
|
+
intervalMs = UNIT_MS[unit];
|
|
349
|
+
intervalLabel = `1${unit}`;
|
|
350
|
+
work = work.replace(everyBare[0], " ");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// "every <N><short-unit>" — e.g. "every 2m"
|
|
356
|
+
if (!intervalMs) {
|
|
357
|
+
const everyShort = work.match(/\bevery\s+(\d+)(s|m|h)\b/i);
|
|
358
|
+
if (everyShort) {
|
|
359
|
+
const val = parseInt(everyShort[1], 10);
|
|
360
|
+
const unit = everyShort[2].toLowerCase();
|
|
361
|
+
if (val > 0) {
|
|
362
|
+
intervalMs = val * UNIT_MS[unit];
|
|
363
|
+
intervalLabel = `${val}${unit}`;
|
|
364
|
+
work = work.replace(everyShort[0], " ");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Bare "<N><short-unit>" without "every" — e.g. "2m"
|
|
370
|
+
if (!intervalMs) {
|
|
371
|
+
const bare = work.match(/\b(\d+)(s|m|h)\b/);
|
|
372
|
+
if (bare) {
|
|
373
|
+
const val = parseInt(bare[1], 10);
|
|
374
|
+
const unit = bare[2].toLowerCase();
|
|
375
|
+
if (val > 0) {
|
|
376
|
+
intervalMs = val * UNIT_MS[unit];
|
|
377
|
+
intervalLabel = `${val}${unit}`;
|
|
378
|
+
work = work.replace(bare[0], " ");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!intervalMs || !intervalLabel) return null;
|
|
384
|
+
|
|
385
|
+
// ── Max iterations extraction ────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
let maxIterations: number | null = null;
|
|
388
|
+
|
|
389
|
+
// "x<N>"
|
|
390
|
+
const xN = work.match(/\bx(\d+)\b/);
|
|
391
|
+
if (xN) {
|
|
392
|
+
const n = parseInt(xN[1], 10);
|
|
393
|
+
if (n > 0) {
|
|
394
|
+
maxIterations = n;
|
|
395
|
+
work = work.replace(xN[0], " ");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// "<N> times" / "max <N> [times|tries|iterations]" / "at most <N> [...]"
|
|
400
|
+
if (maxIterations === null) {
|
|
401
|
+
const alt = work.match(
|
|
402
|
+
/\b(?:(\d+)\s+times|max\s+(\d+)(?:\s+(?:times|tries|iterations))?|at\s+most\s+(\d+)(?:\s+(?:times|tries|iterations))?)\b/i
|
|
403
|
+
);
|
|
404
|
+
if (alt) {
|
|
405
|
+
const n = parseInt(alt[1] || alt[2] || alt[3], 10);
|
|
406
|
+
if (n > 0) {
|
|
407
|
+
maxIterations = n;
|
|
408
|
+
work = work.replace(alt[0], " ");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Condition extraction ─────────────────────────────────────────
|
|
414
|
+
// Uses greedy match for the prompt prefix so multi-"until" text picks
|
|
415
|
+
// the last occurrence (the actual condition marker).
|
|
416
|
+
|
|
417
|
+
let untilCondition: string | null = null;
|
|
418
|
+
let promptText: string;
|
|
419
|
+
|
|
420
|
+
// Case 1: condition at end (most common) — "check ci until it passes"
|
|
421
|
+
const endMatch = work.match(/^(.*)\s*[,;]?\s*\b(until|stop\s+when)\s+(.+?)[\s,;.!]*$/i);
|
|
422
|
+
|
|
423
|
+
if (endMatch?.[1].trim()) {
|
|
424
|
+
promptText = endMatch[1];
|
|
425
|
+
untilCondition = endMatch[3].replace(/^["']|["']$/g, "");
|
|
426
|
+
} else {
|
|
427
|
+
// Case 2: condition at start with comma — "until it passes, check ci"
|
|
428
|
+
const startMatch = work.match(/^\s*\b(until|stop\s+when)\s+(.+?)\s*[,;]\s+(.+?)[\s,;.!]*$/i);
|
|
429
|
+
|
|
430
|
+
if (startMatch) {
|
|
431
|
+
untilCondition = startMatch[2].replace(/^["']|["']$/g, "");
|
|
432
|
+
promptText = startMatch[3];
|
|
433
|
+
} else {
|
|
434
|
+
// No condition marker, or condition-only with no prompt
|
|
435
|
+
promptText = work;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Clean up ─────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
const prompt = promptText.replace(/[,;]+/g, " ").replace(/\s+/g, " ").trim();
|
|
442
|
+
|
|
443
|
+
if (!prompt) return null;
|
|
444
|
+
|
|
445
|
+
if (untilCondition) {
|
|
446
|
+
untilCondition = untilCondition.replace(/\s+/g, " ").trim();
|
|
447
|
+
if (!untilCondition) untilCondition = null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
action: "start",
|
|
452
|
+
intervalMs,
|
|
453
|
+
intervalLabel,
|
|
454
|
+
prompt,
|
|
455
|
+
maxIterations,
|
|
456
|
+
untilCondition,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Build a `/loop` command string from structured params.
|
|
462
|
+
*
|
|
463
|
+
* Produces valid strict syntax that `parseLoopArgs` can parse.
|
|
464
|
+
*
|
|
465
|
+
* @param params - Parsed loop start parameters
|
|
466
|
+
* @returns Command string like `/loop 2m until "it passes" check ci`
|
|
467
|
+
*/
|
|
468
|
+
export function buildLoopCommand(params: Extract<LoopArgs, { action: "start" }>): string {
|
|
469
|
+
let cmd = `/loop ${params.intervalLabel}`;
|
|
470
|
+
if (params.maxIterations !== null) {
|
|
471
|
+
cmd += ` x${params.maxIterations}`;
|
|
472
|
+
}
|
|
473
|
+
if (params.untilCondition) {
|
|
474
|
+
cmd += ` until "${params.untilCondition}"`;
|
|
475
|
+
}
|
|
476
|
+
cmd += ` ${params.prompt}`;
|
|
477
|
+
return cmd;
|
|
478
|
+
}
|
|
479
|
+
|
|
280
480
|
// ── Loop Lifecycle ───────────────────────────────────────────────────────────
|
|
281
481
|
|
|
282
482
|
/**
|
|
@@ -469,14 +669,24 @@ export default function loopExtension(pi: ExtensionAPI): void {
|
|
|
469
669
|
pi.registerCommand("loop", {
|
|
470
670
|
description:
|
|
471
671
|
"Run a prompt on a recurring interval. " +
|
|
472
|
-
'Syntax: /loop <interval> [x<N>] [until "<condition>"] <prompt>'
|
|
672
|
+
'Syntax: /loop <interval> [x<N>] [until "<condition>"] <prompt> — ' +
|
|
673
|
+
"or describe in natural language: /loop check ci every 2m until it passes",
|
|
473
674
|
handler: async (args, ctx) => {
|
|
474
675
|
const parsed = parseLoopArgs(args);
|
|
475
676
|
|
|
476
677
|
switch (parsed.action) {
|
|
477
|
-
case "error":
|
|
678
|
+
case "error": {
|
|
679
|
+
// Try natural-language fallback before showing the error
|
|
680
|
+
const nlResult = parseNaturalLanguageLoop(args);
|
|
681
|
+
if (nlResult) {
|
|
682
|
+
const command = buildLoopCommand(nlResult);
|
|
683
|
+
ctx.ui.setEditorText(command);
|
|
684
|
+
ctx.ui.notify("Loop command generated — review and press Enter to start", "info");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
478
687
|
ctx.ui.notify(parsed.message, "error");
|
|
479
688
|
return;
|
|
689
|
+
}
|
|
480
690
|
|
|
481
691
|
case "stop":
|
|
482
692
|
if (!activeLoop) {
|
|
@@ -1752,7 +1752,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
1752
1752
|
return { systemPrompt: event.systemPrompt + lines.join("\n") };
|
|
1753
1753
|
});
|
|
1754
1754
|
|
|
1755
|
-
pi.on("session_shutdown"
|
|
1755
|
+
pi.on("session_shutdown", async () => {
|
|
1756
1756
|
resetRuntimeState();
|
|
1757
1757
|
});
|
|
1758
1758
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import minimalSkillDisplay from "../index.js";
|
|
4
|
+
|
|
5
|
+
describe("minimal-skill-display extension", () => {
|
|
6
|
+
test("registers session_start and input handlers", () => {
|
|
7
|
+
const events: string[] = [];
|
|
8
|
+
const pi = {
|
|
9
|
+
on: (event: string) => {
|
|
10
|
+
events.push(event);
|
|
11
|
+
},
|
|
12
|
+
registerCommand: () => {},
|
|
13
|
+
registerMessageRenderer: () => {},
|
|
14
|
+
} as unknown as ExtensionAPI;
|
|
15
|
+
|
|
16
|
+
minimalSkillDisplay(pi);
|
|
17
|
+
expect(events).toContain("session_start");
|
|
18
|
+
expect(events).toContain("input");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the permissions extension registration and wiring.
|
|
3
|
+
*
|
|
4
|
+
* Uses the ExtensionHarness to avoid mock.module() — which contaminates
|
|
5
|
+
* other test files in the same Bun worker. Tests cover command/handler
|
|
6
|
+
* registration and event handler presence without mocking the _shared modules.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
|
|
11
|
+
import registerPermissions from "../index.js";
|
|
12
|
+
|
|
13
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
type EventName = "session_start" | "tool_call";
|
|
16
|
+
type EventHandler = (event: unknown, ctx: ExtensionContext) => Promise<unknown>;
|
|
17
|
+
type CommandHandler = (args: string | undefined, ctx: unknown) => Promise<void>;
|
|
18
|
+
|
|
19
|
+
interface CapturedPi {
|
|
20
|
+
handlers: Partial<Record<EventName, EventHandler>>;
|
|
21
|
+
commands: Record<string, { description: string; handler: CommandHandler }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run the extension against a spy pi to capture what it registers.
|
|
26
|
+
*
|
|
27
|
+
* @returns Captured event handlers and commands
|
|
28
|
+
*/
|
|
29
|
+
function captureRegistrations(): CapturedPi {
|
|
30
|
+
const handlers: Partial<Record<EventName, EventHandler>> = {};
|
|
31
|
+
const commands: Record<string, { description: string; handler: CommandHandler }> = {};
|
|
32
|
+
const pi = {
|
|
33
|
+
on: (event: string, handler: EventHandler) => {
|
|
34
|
+
handlers[event as EventName] = handler;
|
|
35
|
+
},
|
|
36
|
+
registerCommand: (name: string, opts: { description: string; handler: CommandHandler }) => {
|
|
37
|
+
commands[name] = opts;
|
|
38
|
+
},
|
|
39
|
+
} as unknown as ExtensionAPI;
|
|
40
|
+
|
|
41
|
+
registerPermissions(pi);
|
|
42
|
+
return { handlers, commands };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ════════════════════════════════════════════════════════════════
|
|
46
|
+
// Registration
|
|
47
|
+
// ════════════════════════════════════════════════════════════════
|
|
48
|
+
|
|
49
|
+
describe("permissions extension registration", () => {
|
|
50
|
+
test("registers session_start handler", () => {
|
|
51
|
+
const { handlers } = captureRegistrations();
|
|
52
|
+
expect(handlers.session_start).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("registers tool_call handler", () => {
|
|
56
|
+
const { handlers } = captureRegistrations();
|
|
57
|
+
expect(handlers.tool_call).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("registers /permissions command", () => {
|
|
61
|
+
const { commands } = captureRegistrations();
|
|
62
|
+
expect(commands.permissions).toBeDefined();
|
|
63
|
+
expect(commands.permissions.description).toBeTruthy();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ════════════════════════════════════════════════════════════════
|
|
68
|
+
// tool_call handler wiring
|
|
69
|
+
// ════════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
describe("tool_call handler", () => {
|
|
72
|
+
test("skips bash tool (handled by shell-policy)", async () => {
|
|
73
|
+
const { handlers } = captureRegistrations();
|
|
74
|
+
const result = await handlers.tool_call!(
|
|
75
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "t1", input: { command: "ls" } },
|
|
76
|
+
{ cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
|
|
77
|
+
);
|
|
78
|
+
// No block/error — should be undefined (pass-through)
|
|
79
|
+
expect(result).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("skips bg_bash tool (handled by shell-policy)", async () => {
|
|
83
|
+
const { handlers } = captureRegistrations();
|
|
84
|
+
const result = await handlers.tool_call!(
|
|
85
|
+
{ type: "tool_call", toolName: "bg_bash", toolCallId: "t2", input: { command: "ls" } },
|
|
86
|
+
{ cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
|
|
87
|
+
);
|
|
88
|
+
expect(result).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("skips when no rules configured", async () => {
|
|
92
|
+
const { handlers } = captureRegistrations();
|
|
93
|
+
// Session-start hasn't been called yet, so currentCwd is "", and
|
|
94
|
+
// getPermissions("") returns empty rules → skip
|
|
95
|
+
const result = await handlers.tool_call!(
|
|
96
|
+
{
|
|
97
|
+
type: "tool_call",
|
|
98
|
+
toolName: "read",
|
|
99
|
+
toolCallId: "t3",
|
|
100
|
+
input: { path: "/etc/passwd" },
|
|
101
|
+
},
|
|
102
|
+
{ cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
|
|
103
|
+
);
|
|
104
|
+
expect(result).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ════════════════════════════════════════════════════════════════
|
|
109
|
+
// /permissions command wiring
|
|
110
|
+
// ════════════════════════════════════════════════════════════════
|
|
111
|
+
|
|
112
|
+
describe("/permissions command", () => {
|
|
113
|
+
test("reload subcommand calls reloadPermissions", async () => {
|
|
114
|
+
const { commands } = captureRegistrations();
|
|
115
|
+
const notifications: Array<{ msg: string; type: string }> = [];
|
|
116
|
+
const ctx = {
|
|
117
|
+
cwd: "/tmp",
|
|
118
|
+
ui: {
|
|
119
|
+
notify: (msg: string, type: string) => {
|
|
120
|
+
notifications.push({ msg, type });
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
await commands.permissions.handler("reload", ctx);
|
|
126
|
+
// Should have notified about reload
|
|
127
|
+
expect(notifications.length).toBeGreaterThan(0);
|
|
128
|
+
expect(notifications[0].msg).toContain("Reloaded");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("no args shows rules (or 'no rules' when none configured)", async () => {
|
|
132
|
+
const { commands } = captureRegistrations();
|
|
133
|
+
const notifications: Array<{ msg: string; type: string }> = [];
|
|
134
|
+
const ctx = {
|
|
135
|
+
cwd: "/tmp",
|
|
136
|
+
ui: {
|
|
137
|
+
notify: (msg: string, type: string) => {
|
|
138
|
+
notifications.push({ msg, type });
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
await commands.permissions.handler("", ctx);
|
|
144
|
+
expect(notifications.length).toBeGreaterThan(0);
|
|
145
|
+
// Should show either "No permission rules" or "Active Permission Rules"
|
|
146
|
+
const msg = notifications[0].msg;
|
|
147
|
+
expect(msg.includes("No permission rules") || msg.includes("Permission Rules")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("test subcommand evaluates Tool(specifier) format", async () => {
|
|
151
|
+
const { commands } = captureRegistrations();
|
|
152
|
+
const notifications: Array<{ msg: string; type: string }> = [];
|
|
153
|
+
const ctx = {
|
|
154
|
+
cwd: "/tmp",
|
|
155
|
+
ui: {
|
|
156
|
+
notify: (msg: string, type: string) => {
|
|
157
|
+
notifications.push({ msg, type });
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
await commands.permissions.handler("test Bash(ls)", ctx);
|
|
163
|
+
expect(notifications.length).toBeGreaterThan(0);
|
|
164
|
+
// Should contain action verdict info
|
|
165
|
+
expect(notifications[0].msg).toContain("Action:");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("test subcommand handles bare tool name", async () => {
|
|
169
|
+
const { commands } = captureRegistrations();
|
|
170
|
+
const notifications: Array<{ msg: string; type: string }> = [];
|
|
171
|
+
const ctx = {
|
|
172
|
+
cwd: "/tmp",
|
|
173
|
+
ui: {
|
|
174
|
+
notify: (msg: string, type: string) => {
|
|
175
|
+
notifications.push({ msg, type });
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
await commands.permissions.handler("test read", ctx);
|
|
181
|
+
expect(notifications.length).toBeGreaterThan(0);
|
|
182
|
+
expect(notifications[0].msg).toContain("Action:");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ════════════════════════════════════════════════════════════════
|
|
187
|
+
// ExtensionHarness integration
|
|
188
|
+
// ════════════════════════════════════════════════════════════════
|
|
189
|
+
|
|
190
|
+
describe("permissions via ExtensionHarness", () => {
|
|
191
|
+
test("tool_call event handler is registered", async () => {
|
|
192
|
+
const harness = ExtensionHarness.create();
|
|
193
|
+
await harness.loadExtension(registerPermissions);
|
|
194
|
+
// Verify the extension registered a tool_call handler by firing one
|
|
195
|
+
const results = await harness.fireEvent("tool_call", {
|
|
196
|
+
type: "tool_call",
|
|
197
|
+
toolName: "read",
|
|
198
|
+
toolCallId: "h-1",
|
|
199
|
+
input: { path: "foo.txt" },
|
|
200
|
+
});
|
|
201
|
+
// No rules configured → should not block
|
|
202
|
+
const blocked = results.some(
|
|
203
|
+
(r) => r && typeof r === "object" && (r as { block?: boolean }).block
|
|
204
|
+
);
|
|
205
|
+
expect(blocked).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("/permissions command is registered", async () => {
|
|
209
|
+
const harness = ExtensionHarness.create();
|
|
210
|
+
await harness.loadExtension(registerPermissions);
|
|
211
|
+
expect(harness.commands.has("permissions")).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import progressIndicator from "../index.js";
|
|
4
|
+
|
|
5
|
+
describe("progress-indicator extension", () => {
|
|
6
|
+
let originalIsTTY: boolean;
|
|
7
|
+
let written: string[];
|
|
8
|
+
let originalWrite: typeof process.stdout.write;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
originalIsTTY = process.stdout.isTTY;
|
|
12
|
+
originalWrite = process.stdout.write;
|
|
13
|
+
written = [];
|
|
14
|
+
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
|
15
|
+
process.stdout.write = ((chunk: string) => {
|
|
16
|
+
written.push(chunk);
|
|
17
|
+
return true;
|
|
18
|
+
}) as typeof process.stdout.write;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
23
|
+
value: originalIsTTY,
|
|
24
|
+
configurable: true,
|
|
25
|
+
});
|
|
26
|
+
process.stdout.write = originalWrite;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("registers turn_start, turn_end, agent_end, session_shutdown handlers", () => {
|
|
30
|
+
const events: string[] = [];
|
|
31
|
+
const pi = {
|
|
32
|
+
on: (event: string) => {
|
|
33
|
+
events.push(event);
|
|
34
|
+
},
|
|
35
|
+
} as unknown as ExtensionAPI;
|
|
36
|
+
|
|
37
|
+
progressIndicator(pi);
|
|
38
|
+
expect(events).toContain("turn_start");
|
|
39
|
+
expect(events).toContain("turn_end");
|
|
40
|
+
expect(events).toContain("agent_end");
|
|
41
|
+
expect(events).toContain("session_shutdown");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("turn_start writes indeterminate OSC sequence", () => {
|
|
45
|
+
const handlers: Record<string, () => void> = {};
|
|
46
|
+
const pi = {
|
|
47
|
+
on: (event: string, handler: () => void) => {
|
|
48
|
+
handlers[event] = handler;
|
|
49
|
+
},
|
|
50
|
+
} as unknown as ExtensionAPI;
|
|
51
|
+
|
|
52
|
+
progressIndicator(pi);
|
|
53
|
+
handlers.turn_start();
|
|
54
|
+
|
|
55
|
+
expect(written).toHaveLength(1);
|
|
56
|
+
expect(written[0]).toContain("9;4;3");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("turn_end writes clear OSC sequence", () => {
|
|
60
|
+
const handlers: Record<string, () => void> = {};
|
|
61
|
+
const pi = {
|
|
62
|
+
on: (event: string, handler: () => void) => {
|
|
63
|
+
handlers[event] = handler;
|
|
64
|
+
},
|
|
65
|
+
} as unknown as ExtensionAPI;
|
|
66
|
+
|
|
67
|
+
progressIndicator(pi);
|
|
68
|
+
handlers.turn_end();
|
|
69
|
+
|
|
70
|
+
expect(written).toHaveLength(1);
|
|
71
|
+
expect(written[0]).toContain("9;4;0");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("agent_end writes clear OSC sequence", () => {
|
|
75
|
+
const handlers: Record<string, () => void> = {};
|
|
76
|
+
const pi = {
|
|
77
|
+
on: (event: string, handler: () => void) => {
|
|
78
|
+
handlers[event] = handler;
|
|
79
|
+
},
|
|
80
|
+
} as unknown as ExtensionAPI;
|
|
81
|
+
|
|
82
|
+
progressIndicator(pi);
|
|
83
|
+
handlers.agent_end();
|
|
84
|
+
|
|
85
|
+
expect(written).toHaveLength(1);
|
|
86
|
+
expect(written[0]).toContain("9;4;0");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("skips write when stdout is not a TTY", () => {
|
|
90
|
+
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
|
91
|
+
|
|
92
|
+
const handlers: Record<string, () => void> = {};
|
|
93
|
+
const pi = {
|
|
94
|
+
on: (event: string, handler: () => void) => {
|
|
95
|
+
handlers[event] = handler;
|
|
96
|
+
},
|
|
97
|
+
} as unknown as ExtensionAPI;
|
|
98
|
+
|
|
99
|
+
progressIndicator(pi);
|
|
100
|
+
handlers.turn_start();
|
|
101
|
+
|
|
102
|
+
expect(written).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
});
|