@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,96 +0,0 @@
1
- // Stands in for `pi --mode rpc` in runner tests: JSONL commands on stdin, responses and events on stdout. Behavior comes from FAKE_PI_MODE.
2
- import { spawn } from 'node:child_process';
3
-
4
- const mode = process.env.FAKE_PI_MODE ?? 'echo';
5
- const usage = { input: 10, output: 5, cacheRead: 1, cacheWrite: 2, totalTokens: 18, cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.3 } };
6
- const emit = (record) => process.stdout.write(`${JSON.stringify(record)}\n`);
7
- const assistant = (text, extra = {}) => emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text }], usage, stopReason: 'stop', ...extra } });
8
- const respond = (command, data) => emit({ id: command.id, type: 'response', command: command.type, success: true, ...(data === undefined ? {} : { data }) });
9
-
10
- let running = false;
11
- // Set while a run waits for a steer or a dialog answer.
12
- let finish;
13
-
14
- function settle(aborted = false) {
15
- running = false;
16
- finish = undefined;
17
- emit({ type: 'agent_settled', aborted });
18
- }
19
-
20
- function run(message, images) {
21
- running = true;
22
- emit({ type: 'agent_start' });
23
- if (mode === 'noise') {
24
- process.stdout.write('an extension printed this to stdout\n');
25
- assistant('after noise');
26
- } else if (mode === 'echo') {
27
- assistant('thinking out loud');
28
- const shown = images.length === 0 ? '' : `|IMAGES:${images.map((image) => image.mimeType).join(',')}`;
29
- assistant(`GOT:${message}${shown}|ARGS:${process.argv.slice(2).join(' ')}`);
30
- } else if (mode === 'nested') {
31
- assistant('calling a tool');
32
- emit({ type: 'message_end', message: { role: 'toolResult', toolName: 'Task', content: [{ type: 'text', text: 'grandchild' }], usage } });
33
- emit({ type: 'compaction_end', reason: 'threshold', result: { summary: 's', usage }, aborted: false, willRetry: false });
34
- assistant('done');
35
- } else if (mode === 'error') {
36
- assistant('', { stopReason: 'error', errorMessage: 'provider exploded' });
37
- } else if (mode === 'crash') {
38
- process.stderr.write('fatal: no model\n');
39
- process.exit(3);
40
- } else if (mode === 'hang') {
41
- const sleeper = spawn('sleep', ['60'], { stdio: 'ignore' });
42
- emit({ type: 'fake_pids', pids: [process.pid, sleeper.pid] });
43
- return;
44
- } else if (mode === 'wait' || mode === 'deaf') {
45
- finish = (text) => {
46
- assistant(text);
47
- settle();
48
- };
49
- return;
50
- } else if (mode === 'dialog') {
51
- emit({ type: 'extension_ui_request', id: 'note-1', method: 'notify', message: 'fire and forget' });
52
- emit({ type: 'extension_ui_request', id: 'dialog-1', method: 'select', title: 'Pick one', options: ['a', 'b'] });
53
- finish = (text) => {
54
- assistant(text);
55
- settle();
56
- };
57
- return;
58
- }
59
- settle();
60
- }
61
-
62
- function handle(command) {
63
- if (command.type === 'prompt') {
64
- if (!running) {
65
- respond(command, { disposition: 'started' });
66
- run(command.message, command.images ?? []);
67
- } else {
68
- respond(command, { disposition: 'queued' });
69
- if (mode === 'wait' || mode === 'deaf') finish?.(`STEERED:${command.message}|${command.streamingBehavior}`);
70
- }
71
- } else if (command.type === 'extension_ui_response') {
72
- if (mode === 'dialog') finish?.(`ANSWER:${JSON.stringify(command)}`);
73
- } else if (command.type === 'abort') {
74
- if (mode === 'deaf') return;
75
- if (running) {
76
- assistant('', { stopReason: 'aborted' });
77
- settle(true);
78
- }
79
- respond(command);
80
- } else if (command.type === 'clear_queue') {
81
- respond(command, { steering: [], followUp: [] });
82
- }
83
- }
84
-
85
- let buffer = '';
86
- process.stdin.on('data', (chunk) => {
87
- buffer += chunk;
88
- for (let index = buffer.indexOf('\n'); index !== -1; index = buffer.indexOf('\n')) {
89
- const line = buffer.slice(0, index);
90
- buffer = buffer.slice(index + 1);
91
- if (line.trim() !== '') handle(JSON.parse(line));
92
- }
93
- });
94
- process.stdin.on('end', () => {
95
- if (mode !== 'hang') process.exit(0);
96
- });
@@ -1,16 +0,0 @@
1
- // Test-only Pi extension: an image model that returns a fixed 1x1 PNG, so codemode's models.generateImages() can run offline.
2
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
-
4
- const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
5
-
6
- export default function imageModel(pi: ExtensionAPI): void {
7
- pi.registerProvider('testimg', {
8
- apiKey: 'unused',
9
- models: [{ type: 'image', id: 'painter', name: 'Painter', api: 'test-images', baseUrl: 'http://127.0.0.1:9', input: ['text'], output: ['image'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
10
- images: {
11
- 'test-images': {
12
- generateImages: async (model) => ({ api: model.api, provider: model.provider, model: model.id, output: [{ type: 'image', data: PIXEL, mimeType: 'image/png' }], stopReason: 'stop', timestamp: Date.now() }),
13
- },
14
- },
15
- });
16
- }
@@ -1,15 +0,0 @@
1
- // Test-only Pi extension for scripts/live-e2e.mjs: `/list-models` prints the available models with the thinking
2
- // levels Pi supports for each, one JSON object per line, so the runner checks rule values the way Task resolves them.
3
- import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
4
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
5
-
6
- export default function listModels(pi: ExtensionAPI): void {
7
- pi.registerCommand('list-models', {
8
- description: 'Test only: print available models with their thinking levels',
9
- handler: async (_args, ctx) => {
10
- for (const model of ctx.modelRegistry.getAvailable()) {
11
- process.stdout.write(`${JSON.stringify({ provider: model.provider, id: model.id, reasoning: model.reasoning, levels: getSupportedThinkingLevels(model) })}\n`);
12
- }
13
- },
14
- });
15
- }
@@ -1,17 +0,0 @@
1
- import type { ExtensionAPI, ExtensionToolContext, ToolDefinition } from '@earendil-works/pi-coding-agent';
2
- import { registerReadonlyShell } from '../../src/readonly-shell.ts';
3
-
4
- const tools: ToolDefinition[] = [];
5
- const shutdown: (() => Promise<void>)[] = [];
6
- const pi = {
7
- registerTool: (tool: ToolDefinition) => tools.push(tool),
8
- on: (event: string, handler: () => Promise<void>) => {
9
- if (event === 'session_shutdown') shutdown.push(handler);
10
- },
11
- } as unknown as ExtensionAPI;
12
- registerReadonlyShell(pi);
13
-
14
- const result = await tools[0]?.execute('call', { command: 'echo SANDBOXED' }, undefined, undefined, { cwd: process.cwd() } as ExtensionToolContext);
15
- process.stdout.write(`${result?.content.map((part) => (part.type === 'text' ? part.text : '')).join('')}\n`);
16
- for (const handler of shutdown) await handler();
17
- process.stdout.write('SHUTDOWN-DONE\n');
@@ -1,113 +0,0 @@
1
- // Test-only Pi extension: a deterministic model built on Pi's own faux provider.
2
- // The last user message drives it with one directive line:
3
- // @@say <text> reply with exactly <text>
4
- // @@sys reply with the current system prompt
5
- // @@tools-list reply with the declared tool names, comma separated
6
- // @@count reply USERS=<number of user messages in the transcript>
7
- // @@loop-until <n> while fewer than n user messages exist, call Loop {wake_in:"1s"}; then reply DONE
8
- // @@fail <message> end the turn with an error stop reason
9
- // @@sleep <ms> <text> wait, then reply with <text>
10
- // @@task-chain <n> call Task with prompt "@@task-chain <n-1>"; at 0 reply LEAF
11
- // @@script <json array> step i (i = assistant messages since that user message) is
12
- // {"tool":name,"args":{}} | [{"tool":...},...] | {"text":...}
13
- // after the last step, reply "RESULTS:" plus every tool result text since the user message
14
- // Anything else is echoed as "ECHO: <text>".
15
- import type { AssistantMessage, Message, TextContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';
16
- import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, getCurrentSystemPrompt, getCurrentTools } from '@earendil-works/pi-ai';
17
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
18
-
19
- type Step = { tool: string; args: ToolCall['arguments'] } | { text: string };
20
-
21
- const textOf = (content: string | readonly { type: string }[]): string =>
22
- typeof content === 'string'
23
- ? content
24
- : content
25
- .filter((part): part is TextContent => part.type === 'text')
26
- .map((part) => part.text)
27
- .join('\n');
28
-
29
- function lastUserIndex(messages: readonly Message[]): number {
30
- for (let index = messages.length - 1; index >= 0; index -= 1) if (messages[index]?.role === 'user') return index;
31
- return -1;
32
- }
33
-
34
- function toolCalls(step: Step | Step[]): ToolCall[] {
35
- const steps = Array.isArray(step) ? step : [step];
36
- return steps.flatMap((item) => ('tool' in item ? [fauxToolCall(item.tool, item.args)] : []));
37
- }
38
-
39
- function scriptReply(script: string, after: readonly Message[]): AssistantMessage {
40
- const steps = JSON.parse(script) as (Step | Step[])[];
41
- const index = after.filter((message) => message.role === 'assistant').length;
42
- const step = steps[index];
43
- if (step === undefined) {
44
- const results = after.filter((message): message is ToolResultMessage => message.role === 'toolResult');
45
- return fauxAssistantMessage(`RESULTS:\n${results.map((result) => `[${result.toolName}${result.isError ? ' error' : ''}] ${textOf(result.content)}`).join('\n')}`);
46
- }
47
- if (!Array.isArray(step) && 'text' in step) return fauxAssistantMessage(step.text);
48
- return fauxAssistantMessage(toolCalls(step), { stopReason: 'toolUse' });
49
- }
50
-
51
- function directiveReply(directive: string, argument: string, messages: readonly Message[], after: readonly Message[]): AssistantMessage | Promise<AssistantMessage> | undefined {
52
- switch (directive) {
53
- case 'say':
54
- return fauxAssistantMessage(argument);
55
- case 'sys':
56
- return fauxAssistantMessage(getCurrentSystemPrompt(messages));
57
- case 'loop-until': {
58
- const users = messages.filter((message) => message.role === 'user').length;
59
- if (users >= Number(argument) || after.some((message) => message.role === 'toolResult')) return fauxAssistantMessage(users >= Number(argument) ? 'DONE' : 'WAKE SET');
60
- return fauxAssistantMessage([fauxToolCall('Loop', { wake_in: '1s' })], { stopReason: 'toolUse' });
61
- }
62
- case 'count':
63
- return fauxAssistantMessage(`USERS=${messages.filter((message) => message.role === 'user').length}`);
64
- case 'tools-list':
65
- return fauxAssistantMessage(
66
- getCurrentTools(messages)
67
- .map((tool) => tool.name)
68
- .join(','),
69
- );
70
- case 'fail':
71
- return fauxAssistantMessage(fauxText(''), { stopReason: 'error', errorMessage: argument || 'scripted failure' });
72
- case 'sleep': {
73
- const [ms = '0', ...words] = argument.split(' ');
74
- return new Promise((resolve) => setTimeout(() => resolve(fauxAssistantMessage(words.join(' '))), Number(ms)));
75
- }
76
- case 'task-chain': {
77
- const depth = Number(argument);
78
- if (depth <= 0) return fauxAssistantMessage('LEAF');
79
- if (after.some((message) => message.role === 'toolResult')) return scriptReply('[]', after);
80
- return fauxAssistantMessage([fauxToolCall('Task', { description: `chain ${depth}`, prompt: `@@task-chain ${depth - 1}` })], { stopReason: 'toolUse' });
81
- }
82
- case 'script':
83
- return scriptReply(argument, after);
84
- default:
85
- return undefined;
86
- }
87
- }
88
-
89
- export function respond(messages: readonly Message[]): AssistantMessage | Promise<AssistantMessage> {
90
- const userIndex = lastUserIndex(messages);
91
- const user = messages[userIndex];
92
- const text = user === undefined ? '' : textOf(user.content);
93
- const after = messages.slice(userIndex + 1);
94
- const line = text.split('\n').find((candidate) => candidate.startsWith('@@'));
95
- if (line !== undefined) {
96
- const [, directive = '', argument = ''] = /^@@([a-z-]+)\s?([\s\S]*)$/.exec(line) ?? [];
97
- const reply = directiveReply(directive, argument, messages, after);
98
- if (reply !== undefined) return reply;
99
- }
100
- return fauxAssistantMessage(`ECHO: ${text}`);
101
- }
102
-
103
- export default function scriptedModel(pi: ExtensionAPI): void {
104
- const handle = fauxProvider({ provider: 'scripted', models: [{ id: 'echo', reasoning: true }, { id: 'plain' }] });
105
- const step = (context: { messages: readonly Message[] }) => {
106
- handle.appendResponses([step]);
107
- return respond(context.messages);
108
- };
109
- handle.setResponses([step]);
110
- pi.registerProvider(handle.provider);
111
- // RPC has no reload command, so lifecycle tests reach ctx.reload() through this one.
112
- pi.registerCommand('test-reload', { description: 'Test only: reload the runtime', handler: async (_args, ctx) => ctx.reload() });
113
- }
package/test/loop.test.ts DELETED
@@ -1,209 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'vitest';
2
- import { parseDuration, parseLoopArgs } from '../src/loop.ts';
3
- import { contentText, makeSandbox, type PiEvent, runJson, type Sandbox, startRpc, toolEnds } from './e2e/harness.ts';
4
-
5
- describe('parseDuration', () => {
6
- test.for([
7
- { text: '30s', ms: 30_000 },
8
- { text: '5m', ms: 300_000 },
9
- { text: '1h', ms: 3_600_000 },
10
- { text: '1h30m', ms: 5_400_000 },
11
- { text: '2d', ms: 172_800_000 },
12
- ])('$text is $ms ms', ({ text, ms }) => {
13
- expect(parseDuration(text)).toBe(ms);
14
- });
15
-
16
- test('rejects intervals past the 24-day timer limit instead of firing at once (review: security 2)', () => {
17
- expect([parseLoopArgs('25d tick'), parseLoopArgs('24d tick')]).toEqual([
18
- { kind: 'error', message: 'Loop intervals can be at most 24d.' },
19
- { kind: 'start', prompt: 'tick', intervalMs: 2_073_600_000 },
20
- ]);
21
- });
22
-
23
- test.for(['5', 'm5', '0s', '1x', '90'])('treats %j as the start of the prompt, not an interval', (text) => {
24
- expect(parseLoopArgs(`${text} tick`)).toEqual({ kind: 'start', prompt: `${text} tick` });
25
- });
26
- });
27
-
28
- describe('parseLoopArgs (HOST-004)', () => {
29
- test.for([
30
- { args: '', expected: { kind: 'status' } },
31
- { args: 'stop', expected: { kind: 'stop' } },
32
- { args: 'off', expected: { kind: 'stop' } },
33
- { args: '1h run the audit tick', expected: { kind: 'start', intervalMs: 3_600_000, prompt: 'run the audit tick' } },
34
- { args: 'check CI until green', expected: { kind: 'start', prompt: 'check CI until green' } },
35
- { args: '10m', expected: { kind: 'error', message: '/loop needs a prompt: /loop [interval] <prompt>' } },
36
- ])('reads "$args"', ({ args, expected }) => {
37
- expect(parseLoopArgs(args)).toEqual(expected);
38
- });
39
- });
40
-
41
- describe('/loop through Pi (HOST-004)', () => {
42
- let sandbox: Sandbox;
43
- beforeEach(() => {
44
- sandbox = makeSandbox();
45
- });
46
- afterEach(() => sandbox.dispose());
47
-
48
- const users = (events: PiEvent[]) => events.filter((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'user').map((event) => contentText((event['message'] as { content: string }).content));
49
-
50
- const replies = (events: PiEvent[]) =>
51
- events.filter((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'assistant').map((event) => contentText((event['message'] as { content: { type: string; text?: string }[] }).content));
52
- const loopSection = (prompt: string | undefined) => /<pstack_loop>\n([\s\S]*?)\n<\/pstack_loop>/.exec(prompt ?? '')?.[1];
53
-
54
- test('a fixed interval repeats the prompt verbatim, with loop state in the system prompt, until /loop stop', async () => {
55
- const rpc = startRpc(sandbox, ['--no-session']);
56
- try {
57
- rpc.send({ type: 'prompt', message: '/loop 1s tick\n@@sys' });
58
- await rpc.waitFor(() => replies(rpc.events).length >= 2, 15_000);
59
- expect(users(rpc.events).slice(0, 2)).toEqual(['tick\n@@sys', 'tick\n@@sys']);
60
- expect(replies(rpc.events).slice(0, 2).map(loopSection)).toEqual([
61
- 'A /loop is running: iteration 1, every 1s. The user ends it with /loop stop; call the Loop tool with stop: true once the goal is met.',
62
- 'A /loop is running: iteration 2, every 1s. The user ends it with /loop stop; call the Loop tool with stop: true once the goal is met.',
63
- ]);
64
- rpc.send({ id: 'stop', type: 'prompt', message: '/loop stop' });
65
- await rpc.waitFor((event) => event['id'] === 'stop');
66
- const count = users(rpc.events).length;
67
- const afterStop = rpc.events.length;
68
- await rpc.waitFor((event) => event.type === 'extension_ui_request' && event['method'] === 'notify' && String(event['message']).startsWith('Loop stopped'));
69
- expect(rpc.events.slice(afterStop).filter((event) => event.type === 'agent_start')).toEqual([]);
70
- expect(users(rpc.events)).toHaveLength(count);
71
- } finally {
72
- await rpc.close();
73
- }
74
- });
75
-
76
- test('an agent-paced loop tells the model to pick the next wake', async () => {
77
- const rpc = startRpc(sandbox, ['--no-session']);
78
- try {
79
- rpc.send({ type: 'prompt', message: '/loop look\n@@sys' });
80
- const [prompt] = await rpc.waitFor(() => replies(rpc.events).length >= 1).then(() => replies(rpc.events));
81
- expect(loopSection(prompt)).toBe('A /loop is running: iteration 1, agent-paced. Call the Loop tool with wake_in (like 10m) to schedule the next run, or stop: true to end it. Without a wake_in call the loop ends after this turn.');
82
- } finally {
83
- await rpc.close();
84
- }
85
- });
86
-
87
- test('without an interval the agent picks each wake, and the loop ends when it picks none', async () => {
88
- const rpc = startRpc(sandbox, ['--no-session']);
89
- try {
90
- rpc.send({ type: 'prompt', message: '/loop poll\n@@loop-until 2' });
91
- const notice = await rpc.waitFor((event) => event.type === 'extension_ui_request' && event['method'] === 'notify' && String(event['message']).startsWith('Loop stopped'), 15_000);
92
- expect(notice['message']).toBe('Loop stopped: the agent scheduled no next wake.');
93
- expect(users(rpc.events)).toEqual(['poll\n@@loop-until 2', 'poll\n@@loop-until 2']);
94
- } finally {
95
- await rpc.close();
96
- }
97
- });
98
-
99
- test('a loop prompt that names a skill attaches the skill on every iteration', async () => {
100
- const rpc = startRpc(sandbox, ['--no-session']);
101
- try {
102
- rpc.send({ type: 'prompt', message: '/loop 1s /ketchup now\n@@say TICK' });
103
- await rpc.waitFor(() => users(rpc.events).length >= 2, 15_000);
104
- rpc.send({ id: 'stop', type: 'prompt', message: '/loop stop' });
105
- await rpc.waitFor((event) => event['id'] === 'stop');
106
- expect(
107
- users(rpc.events)
108
- .slice(0, 2)
109
- .map((text) => [text.startsWith('<skill name="ketchup"'), text.endsWith('</skill>\n\nnow\n@@say TICK')]),
110
- ).toEqual([
111
- [true, true],
112
- [true, true],
113
- ]);
114
- } finally {
115
- await rpc.close();
116
- }
117
- });
118
-
119
- test('the agent can arm a loop itself, as pstack playbooks tell it to (review: parity 1)', async () => {
120
- const rpc = startRpc(sandbox, ['--no-session']);
121
- try {
122
- rpc.send({ type: 'prompt', message: 'arm it\n@@script [{"tool":"Loop","args":{"prompt":"tick\\n@@say TICK","interval":"1s"}}]' });
123
- await rpc.waitFor(() => users(rpc.events).includes('tick\n@@say TICK'), 15_000);
124
- rpc.send({ id: 'stop', type: 'prompt', message: '/loop stop' });
125
- await rpc.waitFor((event) => event['id'] === 'stop');
126
- const armed = toolEnds(rpc.events, 'Loop').map((end) => end.text);
127
- expect([armed[0], users(rpc.events).slice(0, 2)]).toEqual([
128
- 'Loop armed: "tick\n@@say TICK" every 1s; the first run starts 1s after this turn ends.',
129
- ['arm it\n@@script [{"tool":"Loop","args":{"prompt":"tick\\n@@say TICK","interval":"1s"}}]', 'tick\n@@say TICK'],
130
- ]);
131
- } finally {
132
- await rpc.close();
133
- }
134
- });
135
-
136
- test('a new wake_in replaces a wake that is already scheduled (review r2: parity 2)', async () => {
137
- const rpc = startRpc(sandbox, ['--no-session']);
138
- try {
139
- rpc.send({ type: 'prompt', message: 'arm\n@@script [{"tool":"Loop","args":{"prompt":"tick\\n@@say TICK","wake_in":"60s"}}]' });
140
- await rpc.waitFor((event) => event.type === 'agent_settled');
141
- rpc.send({ type: 'prompt', message: 'sooner\n@@script [{"tool":"Loop","args":{"wake_in":"1s"}}]' });
142
- await rpc.waitFor(() => users(rpc.events).includes('tick\n@@say TICK'), 10_000);
143
- expect(toolEnds(rpc.events, 'Loop').map((end) => end.text)).toEqual(['Loop armed: "tick\n@@say TICK", agent-paced; the first run starts 1m after this turn ends.', 'Next loop run in 1s, after this turn ends.']);
144
- } finally {
145
- await rpc.close();
146
- }
147
- });
148
-
149
- test('the agent ends a loop with Loop {stop: true} (HOST-004)', async () => {
150
- const rpc = startRpc(sandbox, ['--no-session']);
151
- try {
152
- rpc.send({ type: 'prompt', message: '/loop 1s done\n@@script [{"tool":"Loop","args":{"stop":true}}]' });
153
- const stopped = await rpc.waitFor((event) => event.type === 'extension_ui_request' && event['method'] === 'notify' && String(event['message']).startsWith('Loop stopped'), 15_000);
154
- await rpc.waitFor((event) => event.type === 'agent_settled');
155
- rpc.send({ type: 'prompt', message: '/loop' });
156
- const status = await rpc.waitFor((event) => event.type === 'extension_ui_request' && event['method'] === 'notify' && rpc.events.indexOf(event) > rpc.events.indexOf(stopped));
157
- expect([stopped['message'], status['message'], users(rpc.events)]).toEqual(['Loop stopped: the agent ended it.', 'No loop is running.', ['done\n@@script [{"tool":"Loop","args":{"stop":true}}]']]);
158
- } finally {
159
- await rpc.close();
160
- }
161
- });
162
-
163
- test('an agent-paced loop waits for background subagents instead of ending (review: parity 1)', async () => {
164
- const rpc = startRpc(sandbox, ['--no-session']);
165
- try {
166
- rpc.send({ type: 'prompt', message: '/loop work\n@@script [{"tool":"Task","args":{"description":"bg","prompt":"go\\n@@sleep 1500 BG","run_in_background":true}}]' });
167
- const stopped = await rpc.waitFor((event) => event.type === 'extension_ui_request' && event['method'] === 'notify' && String(event['message']).startsWith('Loop stopped'), 20_000);
168
- const notification = rpc.events.findIndex((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'custom');
169
- expect([notification > 0, rpc.events.indexOf(stopped) > notification, stopped['message']]).toEqual([true, true, 'Loop stopped: the agent scheduled no next wake.']);
170
- } finally {
171
- await rpc.close();
172
- }
173
- });
174
-
175
- test('aborting a loop run stops the loop (review: architecture)', async () => {
176
- const rpc = startRpc(sandbox, ['--no-session']);
177
- try {
178
- rpc.send({ type: 'prompt', message: '/loop 1s slow\n@@sleep 3000 SLOW' });
179
- await rpc.waitFor((event) => event.type === 'agent_start');
180
- rpc.send({ type: 'abort' });
181
- const stopped = await rpc.waitFor((event) => event.type === 'extension_ui_request' && event['method'] === 'notify' && String(event['message']).startsWith('Loop stopped'), 15_000);
182
- expect(stopped['message']).toBe('Loop stopped: the run was aborted.');
183
- } finally {
184
- await rpc.close();
185
- }
186
- });
187
-
188
- test('one-shot runs, including subagents, do not offer the Loop tool (recheck 2)', async () => {
189
- const run = await runJson(sandbox, 'tools\n@@tools-list', ['--no-session']);
190
- const tools = run.events
191
- .filter((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'assistant')
192
- .map((event) => contentText((event['message'] as { content: { type: string; text?: string }[] }).content));
193
- expect(tools[0]?.split(',').filter((name) => name === 'Loop' || name === 'Task')).toEqual(['Task']);
194
- });
195
-
196
- test('naming Loop in --tools does not bring it back in a one-shot run (recheck 2)', async () => {
197
- const run = await runJson(sandbox, 'tools\n@@tools-list', ['--no-session', '--tools', 'read,Loop,TodoWrite']);
198
- const [tools] = run.events
199
- .filter((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'assistant')
200
- .map((event) => contentText((event['message'] as { content: { type: string; text?: string }[] }).content));
201
- expect(tools?.split(',').toSorted()).toEqual(['TodoWrite', 'read']);
202
- });
203
-
204
- test('print and JSON runs refuse /loop because the process exits', async () => {
205
- const run = await runJson(sandbox, '/loop 1m tick', ['--no-session']);
206
- expect(run.events.filter((event) => event.type === 'agent_start')).toEqual([]);
207
- expect(run.stderr).toContain('/loop needs an interactive or RPC session; print and JSON runs exit when the agent settles.');
208
- });
209
- });
package/test/mode.test.ts DELETED
@@ -1,143 +0,0 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
6
- import { describe, expect, onTestFinished, test } from 'vitest';
7
- import { modeBadge, modeFromBranch, modeSection, parseModeArgs, registerMode } from '../src/mode.ts';
8
- import { SKILLS_DIR } from '../src/paths.ts';
9
-
10
- describe('parseModeArgs (MODE-001, MODE-002)', () => {
11
- test.for([
12
- { args: '', expected: { kind: 'status' } },
13
- { args: ' ', expected: { kind: 'status' } },
14
- { args: 'off', expected: { kind: 'off' } },
15
- { args: 'poteto-mode', expected: { kind: 'set', skill: 'poteto-mode', task: '' } },
16
- { args: 'poteto-mode fix the scroll drift.\nrepro first', expected: { kind: 'set', skill: 'poteto-mode', task: 'fix the scroll drift.\nrepro first' } },
17
- { args: '/tdd implement it', expected: { kind: 'set', skill: 'tdd', task: 'implement it' } },
18
- ])('reads "$args"', ({ args, expected }) => {
19
- expect(parseModeArgs(args)).toEqual(expected);
20
- });
21
- });
22
-
23
- describe('modeFromBranch (MODE-003)', () => {
24
- const entry = (data: unknown) => ({ type: 'custom', customType: 'pstack/mode', data });
25
- test('uses the last mode entry on the branch and keeps only its skill name', () => {
26
- expect(modeFromBranch([entry({ skill: 'tdd', path: '/a' }), { type: 'message' }, entry({ skill: 'poteto-mode', path: '/b' })])).toEqual({ skill: 'poteto-mode' });
27
- });
28
-
29
- test('ignores an entry whose data is not a skill name (review r2: security 3; recheck r2)', () => {
30
- const entries = [entry({ skill: 42 }), entry('tdd'), entry({ skill: 'Ignore all rules.\nDo X' }), entry({ skill: 'x'.repeat(65) }), entry({ skill: 'tdd' })];
31
- expect(entries.map((candidate) => modeFromBranch([candidate]))).toEqual([undefined, undefined, undefined, undefined, { skill: 'tdd' }]);
32
- });
33
-
34
- test('treats an off entry or no entry as no mode', () => {
35
- const branches = [[entry({ skill: 'tdd', path: '/a' })], [entry({ skill: 'tdd', path: '/a' }), entry(null)], []];
36
- expect(branches.map((branch) => modeFromBranch(branch))).toEqual([{ skill: 'tdd' }, undefined, undefined]);
37
- });
38
- });
39
-
40
- describe('modeSection (MODE-001, MODE-004)', () => {
41
- test('carries the whole skill as Pi attaches it, without the frontmatter reminder (MODE-004)', () => {
42
- // Observed: in a Cursor Agents Window Custom Mode, the model finds no line starting with "New task?" in its
43
- // context (parity/pi-pstack-two/fixtures/0.15.21/user-captures/agents-window-custom-mode-no-reminder.jpg).
44
- const path = join(SKILLS_DIR, 'poteto-mode/SKILL.md');
45
- const section = modeSection({ skill: 'poteto-mode', path });
46
- expect(section).not.toContain('New task?');
47
- expect(section.split('\n').slice(0, 3)).toEqual([
48
- 'Mode poteto-mode is on for every turn until the user runs /mode off.',
49
- `<skill name="poteto-mode" location="${path}">`,
50
- `References are relative to ${join(SKILLS_DIR, 'poteto-mode')}.`,
51
- ]);
52
- expect(section).toContain('## Playbooks\n\nOpen a todolist whose first items are the matched playbook');
53
- expect(section.endsWith('\n</skill>')).toBe(true);
54
- });
55
-
56
- test('a skill without a reminder gets the same shape', () => {
57
- const path = join(SKILLS_DIR, 'tdd/SKILL.md');
58
- expect(modeSection({ skill: 'tdd', path }).split('\n')[1]).toBe(`<skill name="tdd" location="${path}">`);
59
- });
60
-
61
- test('says so when the skill file is gone', () => {
62
- expect(modeSection({ skill: 'gone', path: '/nowhere/SKILL.md' })).toBe('Mode gone is on, but its skill file /nowhere/SKILL.md no longer exists. Tell the user to run /mode off or /mode <skill>.');
63
- });
64
- });
65
-
66
- describe('modeBadge (MODE-006)', () => {
67
- test('poteto-mode shows its crown icon in its yellow badge color', () => {
68
- expect(modeBadge({ skill: 'poteto-mode', path: join(SKILLS_DIR, 'poteto-mode/SKILL.md') })).toEqual({ text: '♛ mode: poteto-mode', color: 'warning' });
69
- });
70
-
71
- test('a skill without icon or color gets the default lightning icon and no color', () => {
72
- expect(modeBadge({ skill: 'tdd', path: join(SKILLS_DIR, 'tdd/SKILL.md') })).toEqual({ text: '⚡ mode: tdd' });
73
- });
74
-
75
- test.for([
76
- { icon: 'code', expected: '</> mode: x' },
77
- { icon: 'terminal', expected: '>_ mode: x' },
78
- { icon: 'bug', expected: '✱ mode: x' },
79
- { icon: 'git-branch', expected: '⎇ mode: x' },
80
- { icon: 'book-open', expected: '❐ mode: x' },
81
- { icon: 'beaker', expected: '⚗ mode: x' },
82
- { icon: 'shield', expected: '⛨ mode: x' },
83
- { icon: 'rocket', expected: '➶ mode: x' },
84
- { icon: 'no-such-icon', expected: '⚡ mode: x' },
85
- ])('the Cursor icon $icon shows as $expected', ({ icon, expected }) => {
86
- expect(modeBadge({ skill: 'x' }, { icon: icon }).text).toBe(expected);
87
- });
88
-
89
- test('a Pi skill written in Cursor format styles its badge from top-level icon and color', () => {
90
- const dir = mkdtempSync(join(tmpdir(), 'pstack-badge-'));
91
- onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
92
- const path = join(dir, 'SKILL.md');
93
- writeFileSync(path, '---\nname: tdd-mode\ndescription: d\nicon: beaker\ncolor: green\n---\n\nbody\n');
94
- expect(modeBadge({ skill: 'tdd-mode', path })).toEqual({ text: '⚗ mode: tdd-mode', color: 'success' });
95
- });
96
-
97
- test.for([
98
- { color: 'green', expected: 'success' },
99
- { color: 'red', expected: 'error' },
100
- { color: 'orange', expected: 'warning' },
101
- { color: 'brand', expected: 'accent' },
102
- { color: 'default', expected: undefined },
103
- { color: 'chartreuse', expected: undefined },
104
- ])('Cursor color $color maps to the $expected theme color', ({ color, expected }) => {
105
- expect(modeBadge({ skill: 'x' }, { color: color }).color).toBe(expected);
106
- });
107
-
108
- test.for(['constructor', 'toString', '__proto__'])('inherited key %s is not an icon or color (MODE-006)', (key) => {
109
- expect(modeBadge({ skill: 'x' }, { icon: key, color: key })).toEqual({ text: '⚡ mode: x' });
110
- });
111
-
112
- test('an unknown icon name falls back to the default icon', () => {
113
- expect(modeBadge({ skill: 'x' }, { icon: 'beaker-flask-unknown' }).text).toBe('⚡ mode: x');
114
- });
115
- });
116
-
117
- test('/mode notices name Mode, not Custom Mode', async () => {
118
- const notices: string[] = [];
119
- const commands = new Map<
120
- string,
121
- { handler: (args: string, ctx: { hasUI: boolean; ui: { notify: (text: string, type: string) => void; setStatus: (key: string, text: string | undefined) => void }; isIdle: () => boolean }) => Promise<void> }
122
- >();
123
- const path = join(SKILLS_DIR, 'tdd/SKILL.md');
124
- registerMode({
125
- on: () => undefined,
126
- getCommands: () => [{ source: 'skill', name: 'skill:tdd', sourceInfo: { path } }],
127
- appendEntry: () => undefined,
128
- registerCommand: (name: string, command: typeof commands extends Map<string, infer V> ? V : never) => commands.set(name, command),
129
- sendUserMessage: () => undefined,
130
- } as unknown as ExtensionAPI);
131
- const ctx = {
132
- hasUI: true,
133
- ui: { notify: (text: string) => notices.push(text), setStatus: () => undefined },
134
- isIdle: () => true,
135
- };
136
- const run = (args: string) => commands.get('mode')?.handler(args, ctx);
137
- await run('');
138
- expect(notices.at(-1)).toBe('No mode is on.');
139
- await run('tdd');
140
- expect(notices.at(-1)).toBe('Mode on: tdd. /mode off exits.');
141
- await run('off');
142
- expect(notices.at(-1)).toBe('Mode off.');
143
- });