@justjoshn/pi-pstack 0.15.36-pi.1 → 0.15.36-pi.7

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.
Files changed (114) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +15 -12
  3. package/agents/ci-watcher.md +27 -0
  4. package/agents/thermo-nuclear-code-quality-review.md +23 -0
  5. package/assets/logo.png +0 -0
  6. package/automations/benny/FOR_AGENTS.md +1 -1
  7. package/automations/benny/README.md +2 -2
  8. package/automations/benny/skills/setup-benny/SKILL.md +5 -5
  9. package/bin/pstack-routines-supervisor +9 -0
  10. package/docs/guide/07-overnight.md +1 -1
  11. package/docs/guide/09-make-it-yours.md +1 -1
  12. package/docs/guide/10-recipes-and-pitfalls.md +1 -1
  13. package/docs/native-gaps.md +11 -5
  14. package/docs/parity-reverse-engineering.md +94 -0
  15. package/package.json +41 -6
  16. package/packaging/install-supervisor-unit.sh +164 -0
  17. package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
  18. package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
  19. package/skills/architect/SKILL.md +1 -1
  20. package/skills/architect/references/runner-prompt.md +1 -1
  21. package/skills/arena/SKILL.md +3 -3
  22. package/skills/check-compiler-errors/SKILL.md +23 -0
  23. package/skills/create-skill/SKILL.md +515 -29
  24. package/skills/fix-ci/SKILL.md +29 -0
  25. package/skills/fix-merge-conflicts/SKILL.md +32 -0
  26. package/skills/get-pr-comments/SKILL.md +23 -0
  27. package/skills/how/SKILL.md +1 -1
  28. package/skills/ketchup-video/SKILL.md +2 -2
  29. package/skills/loop-on-ci/SKILL.md +50 -0
  30. package/skills/make-bot-ui/SKILL.md +2 -2
  31. package/skills/make-pr-easy-to-review/SKILL.md +59 -0
  32. package/skills/new-branch-and-pr/SKILL.md +29 -0
  33. package/skills/no-inline-imports/SKILL.md +12 -0
  34. package/skills/poteto-help/SKILL.md +4 -4
  35. package/skills/poteto-mode/playbooks/multi-phase-plan.md +1 -1
  36. package/skills/poteto-mode/playbooks/orchestrate.md +1 -1
  37. package/skills/pr-review-canvas/SKILL.md +176 -0
  38. package/skills/pr-review-canvas/renderer.js +170 -0
  39. package/skills/pr-review-canvas/styles.css +96 -0
  40. package/skills/pr-review-canvas/template.html +18 -0
  41. package/skills/reflect/SKILL.md +1 -1
  42. package/skills/review-and-ship/SKILL.md +41 -0
  43. package/skills/run-smoke-tests/SKILL.md +42 -0
  44. package/skills/statechart/scripts/test/cli.test.ts +1 -1
  45. package/skills/swarm/SKILL.md +2 -2
  46. package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
  47. package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
  48. package/skills/verify-this/SKILL.md +74 -0
  49. package/skills/weekly-review/SKILL.md +32 -0
  50. package/skills/what-did-i-get-done/SKILL.md +31 -0
  51. package/skills/why/SKILL.md +1 -1
  52. package/skills/workflow-from-chats/SKILL.md +50 -0
  53. package/src/mode.ts +3 -3
  54. package/src/paths.ts +13 -0
  55. package/src/pi-command.ts +12 -0
  56. package/src/rule.ts +4 -4
  57. package/src/sections.ts +2 -1
  58. package/src/subscriptions/durable-store.ts +482 -0
  59. package/src/subscriptions/durable-types.ts +41 -0
  60. package/src/subscriptions/ensure-supervisor.ts +67 -0
  61. package/src/subscriptions/index.ts +231 -271
  62. package/src/subscriptions/supervisor-main.ts +88 -0
  63. package/src/subscriptions/supervisor.ts +271 -0
  64. package/src/subscriptions/webhook.ts +22 -9
  65. package/src/task/card.ts +29 -0
  66. package/src/task/records.ts +3 -8
  67. package/src/task/runner.ts +9 -14
  68. package/src/task/tool.ts +18 -21
  69. package/src/todo.ts +1 -2
  70. package/scripts/live-e2e.mjs +0 -257
  71. package/test/aliases.test.ts +0 -57
  72. package/test/ask.test.ts +0 -85
  73. package/test/autocomplete.test.ts +0 -39
  74. package/test/e2e/aliases.e2e.test.ts +0 -81
  75. package/test/e2e/ask.e2e.test.ts +0 -135
  76. package/test/e2e/harness.ts +0 -179
  77. package/test/e2e/host.e2e.test.ts +0 -193
  78. package/test/e2e/lifecycle.e2e.test.ts +0 -99
  79. package/test/e2e/mode.e2e.test.ts +0 -160
  80. package/test/e2e/package.e2e.test.ts +0 -67
  81. package/test/e2e/task.e2e.test.ts +0 -734
  82. package/test/e2e/todo.e2e.test.ts +0 -59
  83. package/test/fixtures/cursor-askquestion-calls.json +0 -806
  84. package/test/fixtures/fake-pi.mjs +0 -96
  85. package/test/fixtures/image-model.ts +0 -16
  86. package/test/fixtures/list-models.ts +0 -15
  87. package/test/fixtures/readonly-shell-exit.ts +0 -17
  88. package/test/fixtures/scripted-model.ts +0 -113
  89. package/test/loop.test.ts +0 -209
  90. package/test/mode.test.ts +0 -143
  91. package/test/plan.test.ts +0 -126
  92. package/test/readonly-shell-security.test.ts +0 -84
  93. package/test/readonly-shell.test.ts +0 -44
  94. package/test/records.test.ts +0 -143
  95. package/test/registry.test.ts +0 -18
  96. package/test/resources.test.ts +0 -162
  97. package/test/rule.test.ts +0 -92
  98. package/test/scripts.test.ts +0 -182
  99. package/test/sections.test.ts +0 -56
  100. package/test/subscriptions-cron.test.ts +0 -60
  101. package/test/subscriptions.test.ts +0 -598
  102. package/test/task-agents.test.ts +0 -49
  103. package/test/task-card.test.ts +0 -34
  104. package/test/task-fork.test.ts +0 -233
  105. package/test/task-format.test.ts +0 -65
  106. package/test/task-model.test.ts +0 -141
  107. package/test/task-runner.test.ts +0 -259
  108. package/test/task-schema.test.ts +0 -50
  109. package/test/task-status.test.ts +0 -155
  110. package/test/task-worktree.test.ts +0 -106
  111. package/test/todo.test.ts +0 -74
  112. package/test/visualize.test.ts +0 -152
  113. package/tsconfig.json +0 -5
  114. package/vitest.config.ts +0 -16
@@ -1,81 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
-
5
- import { afterEach, beforeEach, expect, test } from 'vitest';
6
- import { PACKAGE_ROOT } from '../../src/paths.ts';
7
- import { assistantTexts, contentText, makeSandbox, messages, piInvocation, runJson, type Sandbox } from './harness.ts';
8
-
9
- let sandbox: Sandbox;
10
- beforeEach(() => {
11
- sandbox = makeSandbox();
12
- });
13
- afterEach(() => sandbox.dispose());
14
-
15
- test('/how attaches the skill body, path, and raw arguments to one message (CMD-001)', async () => {
16
- const run = await runJson(sandbox, '/how why "quoted" $1 stays\n@@say ALIAS-OK', ['--no-session']);
17
- const [user] = messages(run.events, 'user').map((message) => contentText(message.content));
18
- const skillFile = join(PACKAGE_ROOT, 'skills/how/SKILL.md');
19
- expect(user).toContain(`<skill name="how" location="${skillFile}">`);
20
- expect(user).toContain(`References are relative to ${join(PACKAGE_ROOT, 'skills/how')}.`);
21
- expect(user).toContain('# How\n\nExplore the codebase');
22
- expect(user?.endsWith('</skill>\n\nwhy "quoted" $1 stays\n@@say ALIAS-OK')).toBe(true);
23
- expect(assistantTexts(run.events)).toEqual(['ALIAS-OK']);
24
- });
25
-
26
- test('the skill does not ride along on the next message (CMD-001)', async () => {
27
- const run = await runJson(sandbox, ['/ketchup @@say FIRST', 'next turn\n@@say SECOND'], ['--no-session']);
28
- const users = messages(run.events, 'user').map((message) => contentText(message.content));
29
- expect(users).toHaveLength(2);
30
- expect(users[0]).toContain('<skill name="ketchup"');
31
- expect(users[1]).toBe('next turn\n@@say SECOND');
32
- expect(assistantTexts(run.events)).toEqual(['FIRST', 'SECOND']);
33
- });
34
-
35
- test('aliases work in print mode, where extension commands cannot start a run (CMD-001)', () => {
36
- const result = spawnSync(...piInvocation(['-p', '--offline', '--no-session', '--model', 'scripted/echo', '/principle-prove-it-works @@say PRINT-OK']), {
37
- cwd: sandbox.workDir,
38
- env: sandbox.env,
39
- encoding: 'utf8',
40
- timeout: 45_000,
41
- });
42
- expect(result.stdout.trim()).toBe('PRINT-OK');
43
- expect(result.status).toBe(0);
44
- });
45
-
46
- test("a user's own prompt template of the same name wins over the alias (CMD-002)", async () => {
47
- mkdirSync(join(sandbox.agentDir, 'prompts'));
48
- writeFileSync(join(sandbox.agentDir, 'prompts/how.md'), '---\ndescription: my how\n---\nMY-TEMPLATE $@\n');
49
- const run = await runJson(sandbox, '/how hello', ['--no-session']);
50
- expect(assistantTexts(run.events)).toEqual(['ECHO: MY-TEMPLATE hello']);
51
- });
52
-
53
- test('aliases work when the package is installed through a symlink (recheck 1)', async () => {
54
- const link = join(sandbox.root, 'pkglink');
55
- symlinkSync(PACKAGE_ROOT, link);
56
- writeFileSync(join(sandbox.agentDir, 'settings.json'), JSON.stringify({ packages: [link], extensions: [join(PACKAGE_ROOT, 'test/fixtures/scripted-model.ts')] }));
57
- const run = await runJson(sandbox, '/ketchup now\n@@say LINKED', ['--no-session']);
58
- const [user] = messages(run.events, 'user').map((message) => contentText(message.content));
59
- expect([user?.startsWith('<skill name="ketchup"'), assistantTexts(run.events)]).toEqual([true, ['LINKED']]);
60
- });
61
-
62
- test("a skill of the user's own gets a /name alias too, as in Cursor (review r2: parity 1)", async () => {
63
- mkdirSync(join(sandbox.agentDir, 'skills/jay-mode'), { recursive: true });
64
- writeFileSync(join(sandbox.agentDir, 'skills/jay-mode/SKILL.md'), '---\nname: jay-mode\ndescription: Work in Jay style.\ndisable-model-invocation: true\n---\nJAY BODY\n');
65
- const run = await runJson(sandbox, '/jay-mode hello\n@@say JAY', ['--no-session']);
66
- const [user] = messages(run.events, 'user').map((message) => contentText(message.content));
67
- expect([user?.startsWith('<skill name="jay-mode"'), assistantTexts(run.events)]).toEqual([true, ['JAY']]);
68
- });
69
-
70
- test('aliases work when skill commands are hidden from the / menu', async () => {
71
- const settings = JSON.parse(readFileSync(join(sandbox.agentDir, 'settings.json'), 'utf8')) as Record<string, unknown>;
72
- writeFileSync(join(sandbox.agentDir, 'settings.json'), JSON.stringify({ ...settings, enableSkillCommands: false }));
73
- const run = await runJson(sandbox, '/how now\n@@say HIDDEN', ['--no-session']);
74
- const [user] = messages(run.events, 'user').map((message) => contentText(message.content));
75
- expect([user?.startsWith('<skill name="how"'), assistantTexts(run.events)]).toEqual([true, ['HIDDEN']]);
76
- });
77
-
78
- test('non-alias slash text reaches the model unchanged (CMD-002)', async () => {
79
- const run = await runJson(sandbox, '/howdy partner', ['--no-session']);
80
- expect(assistantTexts(run.events)).toEqual(['ECHO: /howdy partner']);
81
- });
@@ -1,135 +0,0 @@
1
- import { afterEach, beforeEach, expect, test } from 'vitest';
2
- import { makeSandbox, type PiEvent, runJson, type Sandbox, script, startRpc, toolEnds } from './harness.ts';
3
-
4
- let sandbox: Sandbox;
5
- beforeEach(() => {
6
- sandbox = makeSandbox();
7
- });
8
- afterEach(() => sandbox.dispose());
9
-
10
- const budget = {
11
- id: 'budget',
12
- prompt: 'Pick a reasoning budget',
13
- options: [
14
- { id: 'unlimited', label: 'unlimited — max reasoning' },
15
- { id: 'large', label: 'large — xhigh reasoning' },
16
- ],
17
- };
18
- const areas = {
19
- id: 'areas',
20
- prompt: 'Which areas matter most?',
21
- allow_multiple: true,
22
- options: [
23
- { id: 'style', label: 'Response style' },
24
- { id: 'subagents', label: 'Subagents' },
25
- { id: 'process', label: 'Process' },
26
- ],
27
- };
28
-
29
- test('asks each question through UI dialogs and returns option IDs (HOST-005)', async () => {
30
- const rpc = startRpc(sandbox, ['--no-session']);
31
- try {
32
- const answers = ['large — xhigh reasoning', 'Subagents', 'Process', '✓ Done selecting'];
33
- const seen: PiEvent[] = [];
34
- rpc.child.stdout?.on('data', () => {
35
- for (const event of rpc.events.filter((candidate) => candidate.type === 'extension_ui_request' && candidate['method'] === 'select' && !seen.includes(candidate))) {
36
- seen.push(event);
37
- rpc.send({ type: 'extension_ui_response', id: event['id'], value: answers.shift() });
38
- }
39
- });
40
- rpc.send({ type: 'prompt', message: script([{ tool: 'AskQuestion', args: { questions: [budget, areas] } }]) });
41
- const end = await rpc.waitFor((event) => event.type === 'tool_execution_end' && event['toolName'] === 'AskQuestion');
42
- expect(seen.map((event) => [event['title'], event['options']])).toEqual([
43
- ['Pick a reasoning budget', ['unlimited — max reasoning', 'large — xhigh reasoning', 'Other: (type to answer)']],
44
- ['Which areas matter most? (pick one at a time, then ✓ Done selecting)', ['Response style', 'Subagents', 'Process', 'Other: (type to answer)', '✓ Done selecting']],
45
- ['Which areas matter most? (picked: Subagents)', ['Response style', 'Process', 'Other: (type to answer)', '✓ Done selecting']],
46
- ['Which areas matter most? (picked: Subagents, Process)', ['Response style', 'Other: (type to answer)', '✓ Done selecting']],
47
- ]);
48
- const result = end['result'] as { content: { text: string }[]; details: unknown };
49
- expect(result.content[0]?.text).toBe('budget: large (large — xhigh reasoning)\nareas: subagents (Subagents), process (Process)');
50
- expect(result.details).toEqual({ answers: { budget: ['large'], areas: ['subagents', 'process'] } });
51
- } finally {
52
- await rpc.close();
53
- }
54
- });
55
-
56
- test('shows the title and option descriptions of a Cursor-shaped call and still returns IDs (HOST-005)', async () => {
57
- // Shape observed in Cursor IDE AskQuestion calls: a top-level title and optional option descriptions.
58
- const confirm = {
59
- id: 'confirm',
60
- prompt: 'Write the rule with this table?',
61
- options: [
62
- { id: 'accept', label: 'Accept as-is (Recommended)', description: 'Keep all roles on inherit-parent.' },
63
- { id: 'change', label: 'Change specific roles' },
64
- ],
65
- };
66
- const rpc = startRpc(sandbox, ['--no-session']);
67
- try {
68
- const seen: PiEvent[] = [];
69
- rpc.child.stdout?.on('data', () => {
70
- for (const event of rpc.events.filter((candidate) => candidate.type === 'extension_ui_request' && candidate['method'] === 'select' && !seen.includes(candidate))) {
71
- seen.push(event);
72
- rpc.send({ type: 'extension_ui_response', id: event['id'], value: (event['options'] as string[])[0] });
73
- }
74
- });
75
- rpc.send({ type: 'prompt', message: script([{ tool: 'AskQuestion', args: { title: 'Confirm pstack rule', questions: [confirm] } }]) });
76
- const end = await rpc.waitFor((event) => event.type === 'tool_execution_end' && event['toolName'] === 'AskQuestion');
77
- expect(seen.map((event) => [event['title'], event['options']])).toEqual([
78
- ['Confirm pstack rule: Write the rule with this table?', ['Accept as-is (Recommended) — Keep all roles on inherit-parent.', 'Change specific roles', 'Other: (type to answer)']],
79
- ]);
80
- expect((end['result'] as { details: unknown }).details).toEqual({ answers: { confirm: ['accept'] } });
81
- } finally {
82
- await rpc.close();
83
- }
84
- });
85
-
86
- test('a dismissed question is reported, not guessed (HOST-005)', async () => {
87
- const rpc = startRpc(sandbox, ['--no-session']);
88
- try {
89
- let responded = false;
90
- rpc.child.stdout?.on('data', () => {
91
- const request = rpc.events.find((event) => event.type === 'extension_ui_request' && event['method'] === 'select');
92
- if (request === undefined || responded) return;
93
- responded = true;
94
- rpc.send({ type: 'extension_ui_response', id: request['id'], cancelled: true });
95
- });
96
- rpc.send({ type: 'prompt', message: script([{ tool: 'AskQuestion', args: { questions: [budget] } }]) });
97
- const end = await rpc.waitFor((event) => event.type === 'tool_execution_end' && event['toolName'] === 'AskQuestion');
98
- expect((end['result'] as { content: { text: string }[] }).content[0]?.text).toBe('budget: no answer (the user dismissed the question)');
99
- } finally {
100
- await rpc.close();
101
- }
102
- });
103
-
104
- test('without a UI the questions resolve as skipped, as Cursor print mode reports (HOST-005)', async () => {
105
- // Observed: cursor-agent -p called AskQuestion and its final reply said the user skipped the question
106
- // (parity/pi-pstack-two/evidence/cursor-delegate-v21-resume-run1.json).
107
- const run = await runJson(sandbox, script([{ tool: 'AskQuestion', args: { questions: [budget] } }]), ['--no-session']);
108
- const [end] = toolEnds(run.events, 'AskQuestion');
109
- expect(end?.isError).toBe(false);
110
- expect(end?.text).toBe('budget: skipped (no one can answer questions in this session; decide yourself and say what you chose)');
111
- expect(end?.details).toEqual({ answers: {}, skipped: ['budget'] });
112
- });
113
-
114
- test('every question offers a typed Other answer, as the Cursor question dialog does (HOST-005)', async () => {
115
- const rpc = startRpc(sandbox, ['--no-session']);
116
- try {
117
- const seen: PiEvent[] = [];
118
- rpc.child.stdout?.on('data', () => {
119
- for (const event of rpc.events.filter((candidate) => candidate.type === 'extension_ui_request' && !seen.includes(candidate))) {
120
- seen.push(event);
121
- if (event['method'] === 'select') rpc.send({ type: 'extension_ui_response', id: event['id'], value: 'Other: (type to answer)' });
122
- if (event['method'] === 'input') rpc.send({ type: 'extension_ui_response', id: event['id'], value: 'xhigh but cheaper' });
123
- }
124
- });
125
- rpc.send({ type: 'prompt', message: script([{ tool: 'AskQuestion', args: { questions: [budget] } }]) });
126
- const end = await rpc.waitFor((event) => event.type === 'tool_execution_end' && event['toolName'] === 'AskQuestion');
127
- const select = seen.find((event) => event['method'] === 'select');
128
- expect((select?.['options'] as string[] | undefined)?.at(-1)).toBe('Other: (type to answer)');
129
- const result = end['result'] as { content: { text: string }[]; details: unknown };
130
- expect(result.content[0]?.text).toBe('budget: other ("xhigh but cheaper")');
131
- expect(result.details).toEqual({ answers: { budget: ['other'] }, other: { budget: 'xhigh but cheaper' } });
132
- } finally {
133
- await rpc.close();
134
- }
135
- });
@@ -1,179 +0,0 @@
1
- // Drives the real Pi CLI against an isolated agent directory that loads this package and the scripted test model.
2
- import { type ChildProcess, spawn } from 'node:child_process';
3
- import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
-
7
- import { PACKAGE_ROOT } from '../../src/paths.ts';
8
-
9
- const PI_CLI = join(PACKAGE_ROOT, 'node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js');
10
- const SCRIPTED_MODEL = join(PACKAGE_ROOT, 'test/fixtures/scripted-model.ts');
11
- // PSTACK_E2E_PI=<pi executable> runs the suite against an installed Pi, such as the user's launcher, instead of the dev dependency.
12
- const INSTALLED_PI = process.env['PSTACK_E2E_PI'];
13
-
14
- /** The command and argv that run Pi with these arguments. */
15
- export function piInvocation(args: readonly string[]): [string, string[]] {
16
- return INSTALLED_PI === undefined || INSTALLED_PI === '' ? [process.execPath, [PI_CLI, ...args]] : [INSTALLED_PI, [...args]];
17
- }
18
-
19
- export interface Sandbox {
20
- root: string;
21
- agentDir: string;
22
- workDir: string;
23
- env: Record<string, string>;
24
- dispose: () => void;
25
- }
26
-
27
- export type PiEvent = { type: string } & Record<string, unknown>;
28
-
29
- export interface RunResult {
30
- events: PiEvent[];
31
- exitCode: number | null;
32
- stderr: string;
33
- }
34
-
35
- export function makeSandbox(settings: Record<string, unknown> = {}): Sandbox {
36
- const root = realpathSync(mkdtempSync(join(tmpdir(), 'pi-pstack-e2e-')));
37
- const agentDir = join(root, 'agent');
38
- const workDir = join(root, 'work');
39
- mkdirSync(agentDir);
40
- mkdirSync(workDir);
41
- writeFileSync(join(agentDir, 'settings.json'), JSON.stringify({ packages: [PACKAGE_ROOT], extensions: [SCRIPTED_MODEL], quietStartup: true, ...settings }));
42
- // HOME moves too, so user-level resources such as ~/.agents/skills stay out of the run.
43
- const env = { ...process.env, HOME: root, PI_CODING_AGENT_DIR: agentDir, PI_OFFLINE: '1', PI_SKIP_VERSION_CHECK: '1' } as Record<string, string>;
44
- // Variables that would point a run at the user's real state, or give the parent a subagent identity.
45
- for (const name of ['PI_CODING_AGENT_SESSION_DIR', 'PI_PSTACK_DEPTH', 'PI_PSTACK_AGENT', 'PI_PSTACK_PARENT_PID']) delete env[name];
46
- return { root, agentDir, workDir, env, dispose: () => rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) };
47
- }
48
-
49
- /** Splits a byte stream on LF only, as Pi's JSONL framing requires. */
50
- export function lineSplitter(onLine: (line: string) => void): (chunk: Buffer) => void {
51
- let buffer = '';
52
- return (chunk) => {
53
- buffer += chunk.toString('utf8');
54
- let index = buffer.indexOf('\n');
55
- while (index !== -1) {
56
- const line = buffer.slice(0, index).replace(/\r$/, '');
57
- buffer = buffer.slice(index + 1);
58
- if (line.trim() !== '') onLine(line);
59
- index = buffer.indexOf('\n');
60
- }
61
- };
62
- }
63
-
64
- export function runJson(sandbox: Sandbox, prompts: string | string[], args: string[] = [], timeoutMs = 45_000): Promise<RunResult> {
65
- return new Promise((resolve, reject) => {
66
- const child = spawn(...piInvocation(['--mode', 'json', '--offline', '--model', 'scripted/echo', ...args, ...[prompts].flat()]), { cwd: sandbox.workDir, env: sandbox.env, stdio: ['ignore', 'pipe', 'pipe'] });
67
- const events: PiEvent[] = [];
68
- let stderr = '';
69
- const timer = setTimeout(() => {
70
- child.kill('SIGKILL');
71
- reject(new Error(`pi --mode json timed out after ${timeoutMs}ms\n${stderr}`));
72
- }, timeoutMs);
73
- child.stdout.on(
74
- 'data',
75
- lineSplitter((line) => events.push(JSON.parse(line) as PiEvent)),
76
- );
77
- child.stderr.on('data', (chunk: Buffer) => {
78
- stderr += chunk.toString('utf8');
79
- });
80
- child.on('error', reject);
81
- child.on('close', (exitCode) => {
82
- clearTimeout(timer);
83
- resolve({ events, exitCode, stderr });
84
- });
85
- });
86
- }
87
-
88
- type Content = string | { type: string; text?: string }[];
89
- export interface EventMessage {
90
- role: string;
91
- content: Content;
92
- customType?: string;
93
- toolName?: string;
94
- isError?: boolean;
95
- details?: unknown;
96
- usage?: { totalTokens: number };
97
- }
98
-
99
- export const contentText = (content: Content): string => (typeof content === 'string' ? content : content.map((part) => part.text ?? '').join(''));
100
-
101
- export function messages(events: PiEvent[], role?: string): EventMessage[] {
102
- return events
103
- .filter((event) => event.type === 'message_end')
104
- .map((event) => event['message'] as EventMessage)
105
- .filter((message) => role === undefined || message.role === role);
106
- }
107
-
108
- export const assistantTexts = (events: PiEvent[]): string[] => messages(events, 'assistant').map((message) => contentText(message.content));
109
-
110
- export interface ToolEnd {
111
- toolName: string;
112
- isError: boolean;
113
- text: string;
114
- details: unknown;
115
- usage?: { totalTokens: number };
116
- }
117
-
118
- export function toolEnds(events: PiEvent[], toolName: string): ToolEnd[] {
119
- return events
120
- .filter((event) => event.type === 'tool_execution_end' && event['toolName'] === toolName)
121
- .map((event) => {
122
- const result = event['result'] as { content: Content; details: unknown; usage?: { totalTokens: number } };
123
- return { toolName, isError: event['isError'] === true, text: contentText(result.content), details: result.details, ...(result.usage === undefined ? {} : { usage: result.usage }) };
124
- });
125
- }
126
-
127
- /** Pi reads a positional argument that starts with @ as a file, so the directive goes on the second line. */
128
- export const script = (steps: unknown[]): string => `run the script\n@@script ${JSON.stringify(steps)}`;
129
-
130
- export interface RpcSession {
131
- child: ChildProcess;
132
- events: PiEvent[];
133
- send: (command: Record<string, unknown>) => void;
134
- waitFor: (predicate: (event: PiEvent) => boolean, timeoutMs?: number) => Promise<PiEvent>;
135
- close: () => Promise<void>;
136
- }
137
-
138
- export function startRpc(sandbox: Sandbox, args: string[] = []): RpcSession {
139
- const child = spawn(...piInvocation(['--mode', 'rpc', '--offline', '--model', 'scripted/echo', ...args]), { cwd: sandbox.workDir, env: sandbox.env, stdio: ['pipe', 'pipe', 'pipe'] });
140
- const events: PiEvent[] = [];
141
- const waiters: { predicate: (event: PiEvent) => boolean; resolve: (event: PiEvent) => void }[] = [];
142
- child.stdout?.on(
143
- 'data',
144
- lineSplitter((line) => {
145
- const event = JSON.parse(line) as PiEvent;
146
- events.push(event);
147
- for (const waiter of waiters.filter((candidate) => candidate.predicate(event))) {
148
- waiters.splice(waiters.indexOf(waiter), 1);
149
- waiter.resolve(event);
150
- }
151
- }),
152
- );
153
- let stderr = '';
154
- child.stderr?.on('data', (chunk: Buffer) => {
155
- stderr += chunk.toString('utf8');
156
- });
157
- const waitFor = (predicate: (event: PiEvent) => boolean, timeoutMs = 30_000) =>
158
- new Promise<PiEvent>((resolve, reject) => {
159
- const seen = events.find(predicate);
160
- if (seen) return resolve(seen);
161
- const timer = setTimeout(() => reject(new Error(`timed out waiting for an RPC event\n${stderr}`)), timeoutMs);
162
- waiters.push({
163
- predicate,
164
- resolve: (event) => {
165
- clearTimeout(timer);
166
- resolve(event);
167
- },
168
- });
169
- });
170
- const send = (command: Record<string, unknown>) => child.stdin?.write(`${JSON.stringify(command)}\n`);
171
- const close = () =>
172
- new Promise<void>((resolve) => {
173
- if (child.exitCode !== null) return resolve();
174
- child.once('close', () => resolve());
175
- child.stdin?.end();
176
- setTimeout(() => child.kill('SIGKILL'), 10_000).unref();
177
- });
178
- return { child, events, send, waitFor, close };
179
- }
@@ -1,193 +0,0 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
-
4
- import { afterEach, beforeEach, describe, expect, test } from 'vitest';
5
- import { PACKAGE_ROOT } from '../../src/paths.ts';
6
- import { assistantTexts, contentText, makeSandbox, runJson, type Sandbox, script, startRpc, toolEnds } from './harness.ts';
7
-
8
- let sandbox: Sandbox;
9
- beforeEach(() => {
10
- sandbox = makeSandbox();
11
- });
12
- afterEach(() => sandbox.dispose());
13
-
14
- const section = (prompt: string, name: string) => {
15
- const match = new RegExp(`<${name}>\\n([\\s\\S]*?)\\n</${name}>`).exec(prompt);
16
- if (!match) throw new Error(`system prompt has no <${name}> section`);
17
- return match[1] ?? '';
18
- };
19
-
20
- async function systemPrompt(args: string[] = []): Promise<string> {
21
- const run = await runJson(sandbox, 'show me\n@@sys', args);
22
- const [text] = assistantTexts(run.events);
23
- if (text === undefined) throw new Error(`no reply\n${run.stderr}`);
24
- return text;
25
- }
26
-
27
- /** The system prompt of a long-lived RPC session, where loops can run. */
28
- async function rpcSystemPrompt(): Promise<string> {
29
- const rpc = startRpc(sandbox, ['--no-session']);
30
- try {
31
- rpc.send({ type: 'prompt', message: 'show me\n@@sys' });
32
- const reply = await rpc.waitFor((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'assistant');
33
- return contentText((reply['message'] as { content: { type: string; text?: string }[] }).content);
34
- } finally {
35
- await rpc.close();
36
- }
37
- }
38
-
39
- describe('host map (HOST-001)', () => {
40
- test('names the skills, rule, transcript, store, project, and subagent locations in Pi terms', async () => {
41
- const host = section(await systemPrompt(), 'pstack_host');
42
- const skills = join(PACKAGE_ROOT, 'skills');
43
- const workspace = join(sandbox.agentDir, 'pstack/workspaces', `--${sandbox.workDir.replace(/^\//, '').replaceAll('/', '-')}--`);
44
- expect(host).toContain(`pstack runs here through the pi-pstack package at ${PACKAGE_ROOT}.`);
45
- expect(host).toContain(`<SKILLS> = ${skills}`);
46
- expect(host).toContain('"the X skill", "/X", and "principle-X" mean <SKILLS>/X/SKILL.md');
47
- expect(host).toContain('a delegate that needs poteto-mode reads <SKILLS>/poteto-mode/SKILL.md');
48
- expect(host).toContain(`- The model rule is ${join(sandbox.agentDir, 'pstack/pstack-models.md')}. Its current content is in <pstack_models>.`);
49
- expect(host).toContain(`- Skill folders: project \`.pi/skills/\`, personal \`${join(sandbox.agentDir, 'skills')}/\`.`);
50
- expect(host).toContain(`Subagent transcripts for this workspace: ${join(workspace, 'subagents')}/*_<agent id>.jsonl. A subagent is still running while ${join(workspace, 'subagents')}/<agent id>.lock exists and names a live pid.`);
51
- expect(host).toMatch(new RegExp(`"The current agent's store" is ${join(workspace, 'store')}/[0-9a-f-]+\\. Create it when a playbook needs it\\.`));
52
- expect(host).toContain(`- Projects: a program's Project is ${join(workspace, 'projects')}/<project-slug>/, with notes.md as its Project tracker and store/ as its Project store.`);
53
- expect(host).toMatch(new RegExp(`- Files a tool makes for the user, such as visualize charts, go in ${join(workspace, 'artifacts')}/[0-9a-f-]+\\.`));
54
- expect(host).toContain(`- Transcripts: this workspace's Pi session files are in ${join(sandbox.agentDir, 'sessions')}/--${sandbox.workDir.replace(/^\//, '').replaceAll('/', '-')}--.`);
55
- expect(host).toContain('Installed skills (62): architect, arena, automate-me,');
56
- expect(host).toContain("agent_status reads a subagent's state without waking it.");
57
- expect(host).toContain('- The user keeps a skill on every turn with `/mode <skill>` (exit `/mode off`) and plans without edits with `/plan` (exit `/plan off`).');
58
- expect(host).toContain('- MCP tools are named mcp__<server>__<tool>; `pi mcp list` shows the servers.');
59
- expect(host).toContain('- Isolated workers (Task environment "cloud") run on this machine in their own git worktree on branch pstack/<agent id>');
60
- });
61
-
62
- test('names no Cursor host feature (HOST-008)', async () => {
63
- const prompt = await systemPrompt(['--no-session']);
64
- const names = ['pstack_host', 'pstack_subagents', 'pstack_models', 'pstack_mode', 'pstack_plan', 'pstack_loop'];
65
- const pstack = names.map((name) => new RegExp(`<${name}>\\n([\\s\\S]*?)\\n</${name}>`).exec(prompt)?.[1] ?? '').join('\n');
66
- expect(pstack).not.toMatch(/cursor/i);
67
- });
68
-
69
- test('tells a one-shot run it cannot subscribe, and a long-lived session how subscriptions wake it (HOST-010)', async () => {
70
- const [rpc, oneShot] = [section(await rpcSystemPrompt(), 'pstack_host'), section(await systemPrompt(['--no-session']), 'pstack_host')];
71
- expect([rpc.includes('- Subscriptions (subscribe_github_pr, subscribe_timer, subscribe_webhook) wake this chat'), oneShot.includes('- Subscriptions: this run ends when it settles, so nothing can wake it')]).toEqual([true, true]);
72
- });
73
-
74
- test("names the main chat's own store as the root agent's store", async () => {
75
- const host = section(await systemPrompt(['--no-session']), 'pstack_host');
76
- const current = /"The current agent's store" is (\S+)\. /.exec(host)?.[1];
77
- expect(host).toContain(`"The root agent's store" is ${current}, the store of the main chat that started this subagent tree; in the main chat it is the current agent's store.`);
78
- });
79
-
80
- test('names AskQuestion only where a user can answer (HOST-005; review r2: parity 4)', async () => {
81
- const [rpc, oneShot] = [section(await rpcSystemPrompt(), 'pstack_host'), section(await systemPrompt(['--no-session']), 'pstack_host')];
82
- expect([
83
- rpc.includes('- Todo lists: the TodoWrite tool. Structured questions: the AskQuestion tool.'),
84
- oneShot.includes('- Todo lists: the TodoWrite tool. Structured questions: the AskQuestion tool, but nobody can answer in this run, so every question comes back skipped; decide yourself and say what you chose.'),
85
- ]).toEqual([true, true]);
86
- });
87
-
88
- test('tells a long-lived session how to arm /loop (HOST-004)', async () => {
89
- const host = section(await rpcSystemPrompt(), 'pstack_host');
90
- expect(host).toContain(
91
- '- /loop: the user starts `/loop [interval] <prompt>`. When a playbook says to arm /loop, call the Loop tool with prompt plus interval (fixed cadence) or wake_in (agent-paced). Inside a loop, Loop with wake_in schedules the next run and stop: true ends it.',
92
- );
93
- });
94
-
95
- test('tells a one-shot run, such as a subagent, that it cannot arm /loop (HOST-004, recheck 2)', async () => {
96
- const host = section(await systemPrompt(['--no-session']), 'pstack_host');
97
- expect(host).toContain(
98
- '- /loop: this run ends when it settles (a subagent, or a print or JSON run), so it has no Loop tool and cannot arm a loop. Keep working until the goal is met, and name in the final reply anything the parent chat should loop on.',
99
- );
100
- });
101
-
102
- test('frames an attached skill as the workflow to follow', async () => {
103
- const host = section(await systemPrompt(), 'pstack_host');
104
- expect(host).toContain(
105
- '- A <skill> block in a user message means the user attached that skill to this request. Follow its workflow from its first step, including the steps it says to take before answering, instead of answering directly.',
106
- );
107
- });
108
-
109
- test('names the persisted transcript of this chat', async () => {
110
- const host = section(await systemPrompt(['--session-dir', join(sandbox.root, 'sessions')]), 'pstack_host');
111
- expect(host).toMatch(new RegExp(`This chat's transcript is ${join(sandbox.root, 'sessions')}/[^ ]+\\.jsonl\\.`));
112
- });
113
-
114
- test('says when the chat is not persisted', async () => {
115
- const host = section(await systemPrompt(['--no-session']), 'pstack_host');
116
- expect(host).toContain('This chat is not persisted (--no-session), so it has no transcript file.');
117
- });
118
- });
119
-
120
- describe('subagent catalog (HOST-002)', () => {
121
- test('lists the subagent types and the models Task accepts', async () => {
122
- const catalog = section(await systemPrompt(['--no-session']), 'pstack_subagents');
123
- expect(catalog).toContain('- generalPurpose (default): ');
124
- expect(catalog).toContain('- poteto-agent (runs in the background unless run_in_background is false): Delegate subagent for code-writing and helper tasks spawned during a `/poteto-mode` run.');
125
- expect(catalog).toContain('- comment-sicko: A deranged comment-hater that savors deletion and condemns workaround code.');
126
- expect(catalog).toContain('- inherit (default): the parent model and thinking level. auto and inherit-parent mean the same.');
127
- expect(catalog).toMatch(/^- This chat runs scripted\/echo(:\w+)?\. pstack's model families \(claude-\*, grok-\*\) go by the model id after its provider\.$/m);
128
- expect(catalog).toContain('Available models: scripted/echo (thinking: off, minimal, low, medium, high), scripted/plain');
129
- expect(catalog).toContain('- A Pi model <provider>/<model-id> or <model-id>, optionally with :<level> from the thinking levels the model lists below. A level the model does not list is rejected.');
130
- });
131
- });
132
-
133
- describe('capability-aware sections (C-M4)', () => {
134
- test('a readonly subagent gets no subagent catalog and no Task or stop_agent lines; its parent gets both', async () => {
135
- const run = await runJson(sandbox, script([{ tool: 'Task', args: { description: 'probe', prompt: 'go\n@@sys', readonly: true } }]), ['--no-session']);
136
- const child = toolEnds(run.events, 'Task')[0]?.text ?? '';
137
- const parentHost = section(await systemPrompt(['--no-session']), 'pstack_host');
138
- expect([child.includes('<pstack_host>'), child.includes('<pstack_subagents>'), section(child, 'pstack_host').includes('Task'), section(child, 'pstack_host').includes('stop_agent')]).toEqual([true, false, false, false]);
139
- expect([parentHost.includes('- Subagents: call the Task tool'), parentHost.includes('stop_agent stops a running agent.')]).toEqual([true, true]);
140
- });
141
- });
142
-
143
- describe('model rule (RULE-001)', () => {
144
- test('reports the default when no rule exists', async () => {
145
- const models = section(await systemPrompt(['--no-session']), 'pstack_models');
146
- expect(models).toBe(`No pstack model rule exists at ${join(sandbox.agentDir, 'pstack/pstack-models.md')}. Every role uses its skill default until /setup-pstack writes one.`);
147
- });
148
-
149
- test('applies the rule on every request when it exists', async () => {
150
- mkdirSync(join(sandbox.agentDir, 'pstack'), { recursive: true });
151
- writeFileSync(join(sandbox.agentDir, 'pstack/pstack-models.md'), '# budget: small (medium)\nswarm workers: scripted/plain\n');
152
- const run = await runJson(sandbox, ['first\n@@sys', 'second\n@@sys'], ['--no-session']);
153
- const prompts = assistantTexts(run.events);
154
- expect(prompts).toHaveLength(2);
155
- for (const prompt of prompts) expect(section(prompt, 'pstack_models')).toBe(`Path: ${join(sandbox.agentDir, 'pstack/pstack-models.md')}\n# budget: small (medium)\nswarm workers: scripted/plain`);
156
- });
157
- });
158
-
159
- describe('tool listing', () => {
160
- test('lists the pstack tools a one-shot run can use, and the todolist guideline (HOST-001, TODO-001, recheck 2)', async () => {
161
- const prompt = await systemPrompt(['--no-session']);
162
- const tools = /<tools>\n([\s\S]*?)\n<\/tools>/.exec(prompt)?.[1] ?? '';
163
- expect(
164
- tools
165
- .split('\n')
166
- .filter((line) => /^- (Task|TodoWrite|AskQuestion|Loop):/.test(line))
167
- .toSorted(),
168
- ).toEqual([
169
- '- AskQuestion: Structured multiple-choice questions for the user',
170
- '- Task: Launch subagents (foreground, background with completion notifications, resume by agent ID)',
171
- '- TodoWrite: Create and update the todo list for this session',
172
- ]);
173
- expect(prompt).toContain('- When a skill or playbook says to open a todolist or todo list, call TodoWrite before other work, with the steps it names as the first items.');
174
- });
175
- });
176
-
177
- describe('image generation (HOST-006)', () => {
178
- test('the path the host section names generates an image when an image model is configured', async () => {
179
- const images = makeSandbox({ extensions: [join(PACKAGE_ROOT, 'test/fixtures/scripted-model.ts'), join(PACKAGE_ROOT, 'test/fixtures/image-model.ts')] });
180
- try {
181
- // The script follows the host section line: codemode's models.generateImages(), shown with image().
182
- const code =
183
- 'const [painter] = await models.getAvailableOfType("image");\nconst result = await models.generateImages(painter, { input: [{ type: "text", text: "a fox" }] });\nfor (const block of result.output) if (block.type === "image") image(block);\nreturn painter.provider + "/" + painter.id + " " + result.stopReason;';
184
- const run = await runJson(images, script([{ tool: 'codemode', args: { code } }]), ['--no-session', '--tools', '+codemode']);
185
- const [end] = toolEnds(run.events, 'codemode');
186
- expect(end?.isError).toBe(false);
187
- expect(end?.text).toContain('testimg/painter stop');
188
- expect(JSON.stringify(run.events.find((event) => event.type === 'tool_execution_end' && event['toolName'] === 'codemode'))).toContain('"type":"image"');
189
- } finally {
190
- images.dispose();
191
- }
192
- });
193
- });