@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.
- package/CHANGELOG.md +39 -0
- package/README.md +15 -12
- package/agents/ci-watcher.md +27 -0
- package/agents/thermo-nuclear-code-quality-review.md +23 -0
- package/assets/logo.png +0 -0
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/README.md +2 -2
- package/automations/benny/skills/setup-benny/SKILL.md +5 -5
- package/bin/pstack-routines-supervisor +9 -0
- package/docs/guide/07-overnight.md +1 -1
- package/docs/guide/09-make-it-yours.md +1 -1
- package/docs/guide/10-recipes-and-pitfalls.md +1 -1
- package/docs/native-gaps.md +11 -5
- package/docs/parity-reverse-engineering.md +94 -0
- package/package.json +41 -6
- package/packaging/install-supervisor-unit.sh +164 -0
- package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
- package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
- package/skills/architect/SKILL.md +1 -1
- package/skills/architect/references/runner-prompt.md +1 -1
- package/skills/arena/SKILL.md +3 -3
- package/skills/check-compiler-errors/SKILL.md +23 -0
- package/skills/create-skill/SKILL.md +515 -29
- package/skills/fix-ci/SKILL.md +29 -0
- package/skills/fix-merge-conflicts/SKILL.md +32 -0
- package/skills/get-pr-comments/SKILL.md +23 -0
- package/skills/how/SKILL.md +1 -1
- package/skills/ketchup-video/SKILL.md +2 -2
- package/skills/loop-on-ci/SKILL.md +50 -0
- package/skills/make-bot-ui/SKILL.md +2 -2
- package/skills/make-pr-easy-to-review/SKILL.md +59 -0
- package/skills/new-branch-and-pr/SKILL.md +29 -0
- package/skills/no-inline-imports/SKILL.md +12 -0
- package/skills/poteto-help/SKILL.md +4 -4
- package/skills/poteto-mode/playbooks/multi-phase-plan.md +1 -1
- package/skills/poteto-mode/playbooks/orchestrate.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +176 -0
- package/skills/pr-review-canvas/renderer.js +170 -0
- package/skills/pr-review-canvas/styles.css +96 -0
- package/skills/pr-review-canvas/template.html +18 -0
- package/skills/reflect/SKILL.md +1 -1
- package/skills/review-and-ship/SKILL.md +41 -0
- package/skills/run-smoke-tests/SKILL.md +42 -0
- package/skills/statechart/scripts/test/cli.test.ts +1 -1
- package/skills/swarm/SKILL.md +2 -2
- package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
- package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
- package/skills/verify-this/SKILL.md +74 -0
- package/skills/weekly-review/SKILL.md +32 -0
- package/skills/what-did-i-get-done/SKILL.md +31 -0
- package/skills/why/SKILL.md +1 -1
- package/skills/workflow-from-chats/SKILL.md +50 -0
- package/src/mode.ts +3 -3
- package/src/paths.ts +13 -0
- package/src/pi-command.ts +12 -0
- package/src/rule.ts +4 -4
- package/src/sections.ts +2 -1
- package/src/subscriptions/durable-store.ts +482 -0
- package/src/subscriptions/durable-types.ts +41 -0
- package/src/subscriptions/ensure-supervisor.ts +67 -0
- package/src/subscriptions/index.ts +231 -271
- package/src/subscriptions/supervisor-main.ts +88 -0
- package/src/subscriptions/supervisor.ts +271 -0
- package/src/subscriptions/webhook.ts +22 -9
- package/src/task/card.ts +29 -0
- package/src/task/records.ts +3 -8
- package/src/task/runner.ts +9 -14
- package/src/task/tool.ts +18 -21
- package/src/todo.ts +1 -2
- package/scripts/live-e2e.mjs +0 -257
- package/test/aliases.test.ts +0 -57
- package/test/ask.test.ts +0 -85
- package/test/autocomplete.test.ts +0 -39
- package/test/e2e/aliases.e2e.test.ts +0 -81
- package/test/e2e/ask.e2e.test.ts +0 -135
- package/test/e2e/harness.ts +0 -179
- package/test/e2e/host.e2e.test.ts +0 -193
- package/test/e2e/lifecycle.e2e.test.ts +0 -99
- package/test/e2e/mode.e2e.test.ts +0 -160
- package/test/e2e/package.e2e.test.ts +0 -67
- package/test/e2e/task.e2e.test.ts +0 -734
- package/test/e2e/todo.e2e.test.ts +0 -59
- package/test/fixtures/cursor-askquestion-calls.json +0 -806
- package/test/fixtures/fake-pi.mjs +0 -96
- package/test/fixtures/image-model.ts +0 -16
- package/test/fixtures/list-models.ts +0 -15
- package/test/fixtures/readonly-shell-exit.ts +0 -17
- package/test/fixtures/scripted-model.ts +0 -113
- package/test/loop.test.ts +0 -209
- package/test/mode.test.ts +0 -143
- package/test/plan.test.ts +0 -126
- package/test/readonly-shell-security.test.ts +0 -84
- package/test/readonly-shell.test.ts +0 -44
- package/test/records.test.ts +0 -143
- package/test/registry.test.ts +0 -18
- package/test/resources.test.ts +0 -162
- package/test/rule.test.ts +0 -92
- package/test/scripts.test.ts +0 -182
- package/test/sections.test.ts +0 -56
- package/test/subscriptions-cron.test.ts +0 -60
- package/test/subscriptions.test.ts +0 -598
- package/test/task-agents.test.ts +0 -49
- package/test/task-card.test.ts +0 -34
- package/test/task-fork.test.ts +0 -233
- package/test/task-format.test.ts +0 -65
- package/test/task-model.test.ts +0 -141
- package/test/task-runner.test.ts +0 -259
- package/test/task-schema.test.ts +0 -50
- package/test/task-status.test.ts +0 -155
- package/test/task-worktree.test.ts +0 -106
- package/test/todo.test.ts +0 -74
- package/test/visualize.test.ts +0 -152
- package/tsconfig.json +0 -5
- package/vitest.config.ts +0 -16
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, expect, test } from 'vitest';
|
|
2
|
-
import { contentText, makeSandbox, type PiEvent, type RpcSession, type Sandbox, script, startRpc } from './harness.ts';
|
|
3
|
-
|
|
4
|
-
let sandbox: Sandbox;
|
|
5
|
-
let rpc: RpcSession;
|
|
6
|
-
beforeEach(() => {
|
|
7
|
-
sandbox = makeSandbox();
|
|
8
|
-
rpc = startRpc(sandbox, ['--no-session']);
|
|
9
|
-
});
|
|
10
|
-
afterEach(async () => {
|
|
11
|
-
await rpc.close();
|
|
12
|
-
sandbox.dispose();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
// Pi sets process.title to "pi", so ps cannot find a child by its arguments; the Task details carry its pid.
|
|
16
|
-
const alive = (pid: number) => {
|
|
17
|
-
try {
|
|
18
|
-
process.kill(pid, 0);
|
|
19
|
-
return true;
|
|
20
|
-
} catch {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
async function gone(pids: readonly number[], timeoutMs = 12_000): Promise<boolean> {
|
|
26
|
-
const deadline = Date.now() + timeoutMs;
|
|
27
|
-
while (Date.now() < deadline) {
|
|
28
|
-
if (pids.every((pid) => !alive(pid))) return true;
|
|
29
|
-
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
30
|
-
}
|
|
31
|
-
return pids.every((pid) => !alive(pid));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function reply(message: string): Promise<string> {
|
|
35
|
-
const mark = rpc.events.length;
|
|
36
|
-
rpc.send({ type: 'prompt', message });
|
|
37
|
-
await rpc.waitFor((event) => event.type === 'agent_settled' && rpc.events.indexOf(event) >= mark);
|
|
38
|
-
const last = rpc.events
|
|
39
|
-
.slice(mark)
|
|
40
|
-
.filter((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'assistant')
|
|
41
|
-
.at(-1);
|
|
42
|
-
if (last === undefined) throw new Error('the run produced no assistant message');
|
|
43
|
-
return contentText((last['message'] as { content: { type: string; text?: string }[] }).content);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async function backgroundAgent(): Promise<number> {
|
|
47
|
-
const mark = rpc.events.length;
|
|
48
|
-
rpc.send({ type: 'prompt', message: script([{ tool: 'Task', args: { description: 'slow', prompt: 'go\n@@sleep 60000 SLOW', run_in_background: true } }]) });
|
|
49
|
-
const end = await rpc.waitFor((event: PiEvent) => event.type === 'tool_execution_end' && event['toolName'] === 'Task' && rpc.events.indexOf(event) >= mark);
|
|
50
|
-
await rpc.waitFor((event) => event.type === 'agent_settled' && rpc.events.indexOf(event) >= mark);
|
|
51
|
-
return (end['result'] as { details: { pid: number } }).details.pid;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Interactive Pi redraws the chat between the old session's shutdown and the new session's start, so a warning sent
|
|
55
|
-
// at shutdown never reaches the screen. The new session's mode status marks that start.
|
|
56
|
-
test.for([
|
|
57
|
-
{ replace: 'new session', command: { type: 'new_session' }, reason: 'new' },
|
|
58
|
-
{ replace: 'reload', command: { type: 'prompt', message: '/test-reload' }, reason: 'reload' },
|
|
59
|
-
])('a $replace shows the stopped-subagent warning after the next session starts', async ({ command, reason }) => {
|
|
60
|
-
await backgroundAgent();
|
|
61
|
-
const mark = rpc.events.length;
|
|
62
|
-
rpc.send({ id: 'replace', ...command });
|
|
63
|
-
await rpc.waitFor((event) => event['id'] === 'replace');
|
|
64
|
-
const after = rpc.events.slice(mark);
|
|
65
|
-
const isUi = (event: PiEvent, method: string) => event.type === 'extension_ui_request' && event['method'] === method;
|
|
66
|
-
const started = after.findIndex((event) => isUi(event, 'setStatus') && event['statusKey'] === 'pstack-mode');
|
|
67
|
-
const warned = await rpc.waitFor((event) => isUi(event, 'notify') && String(event['message']).startsWith('Stopped 1 background') && rpc.events.indexOf(event) >= mark);
|
|
68
|
-
expect([warned['message'], rpc.events.indexOf(warned) - mark > started, started >= 0]).toEqual([`Stopped 1 background subagent (session ${reason}). Resume one with its agent ID.`, true, true]);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('/reload keeps aliases and tools, and stops the old runtime children (PKG-002)', async () => {
|
|
72
|
-
const pid = await backgroundAgent();
|
|
73
|
-
expect(alive(pid)).toBe(true);
|
|
74
|
-
rpc.send({ id: 'reload', type: 'prompt', message: '/test-reload' });
|
|
75
|
-
await rpc.waitFor((event) => event['id'] === 'reload');
|
|
76
|
-
expect(await gone([pid])).toBe(true);
|
|
77
|
-
expect(await reply('/how @@say STILL-ALIASED')).toBe('STILL-ALIASED');
|
|
78
|
-
expect(
|
|
79
|
-
(await reply('tools\n@@tools-list'))
|
|
80
|
-
.split(',')
|
|
81
|
-
.filter((name) => name === 'Task' || name === 'TodoWrite')
|
|
82
|
-
.toSorted(),
|
|
83
|
-
).toEqual(['Task', 'TodoWrite']);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('a new session resets mode and todos and stops the old children (PKG-002)', async () => {
|
|
87
|
-
rpc.send({ id: 'mode', type: 'prompt', message: '/mode tdd' });
|
|
88
|
-
await rpc.waitFor((event) => event['id'] === 'mode');
|
|
89
|
-
await reply(script([{ tool: 'TodoWrite', args: { merge: false, todos: [{ id: 'a', content: 'Alpha', status: 'pending' }] } }]));
|
|
90
|
-
const pid = await backgroundAgent();
|
|
91
|
-
expect(alive(pid)).toBe(true);
|
|
92
|
-
rpc.send({ id: 'new', type: 'new_session' });
|
|
93
|
-
await rpc.waitFor((event) => event['id'] === 'new');
|
|
94
|
-
expect(await gone([pid])).toBe(true);
|
|
95
|
-
const notice = rpc.events.find((event) => event.type === 'extension_ui_request' && event['method'] === 'notify' && String(event['message']).startsWith('Stopped 1 background'));
|
|
96
|
-
expect(notice?.['message']).toBe('Stopped 1 background subagent (session new). Resume one with its agent ID.');
|
|
97
|
-
expect(await reply('fresh\n@@sys')).not.toContain('<pstack_mode>');
|
|
98
|
-
expect(await reply(script([{ tool: 'TodoWrite', args: { merge: true, todos: [{ id: 'b', content: 'Beta', status: 'pending' }] } }]))).toBe('RESULTS:\n[TodoWrite] Todos (0/1 completed):\n[ ] b: Beta');
|
|
99
|
-
});
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
import { afterEach, beforeEach, expect, test } from 'vitest';
|
|
5
|
-
import { PACKAGE_ROOT } from '../../src/paths.ts';
|
|
6
|
-
import { contentText, makeSandbox, type PiEvent, type RpcSession, type Sandbox, startRpc } from './harness.ts';
|
|
7
|
-
|
|
8
|
-
let sandbox: Sandbox;
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
sandbox = makeSandbox();
|
|
11
|
-
});
|
|
12
|
-
afterEach(() => sandbox.dispose());
|
|
13
|
-
|
|
14
|
-
async function ask(rpc: RpcSession, message: string): Promise<string> {
|
|
15
|
-
const mark = rpc.events.length;
|
|
16
|
-
rpc.send({ type: 'prompt', message });
|
|
17
|
-
await rpc.waitFor((event) => event.type === 'agent_settled' && rpc.events.indexOf(event) >= mark);
|
|
18
|
-
const reply = rpc.events
|
|
19
|
-
.slice(mark)
|
|
20
|
-
.filter((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'assistant')
|
|
21
|
-
.at(-1);
|
|
22
|
-
if (reply === undefined) throw new Error('the run produced no assistant message');
|
|
23
|
-
return contentText((reply['message'] as { content: { type: string; text?: string }[] }).content);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function command(rpc: RpcSession, message: string): Promise<PiEvent> {
|
|
27
|
-
const id = `cmd-${rpc.events.length}`;
|
|
28
|
-
rpc.send({ id, type: 'prompt', message });
|
|
29
|
-
return rpc.waitFor((event) => event['id'] === id);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const modeSectionOf = (prompt: string) => /<pstack_mode>\n([\s\S]*?)\n<\/pstack_mode>/.exec(prompt)?.[1];
|
|
33
|
-
const statuses = (rpc: RpcSession) => rpc.events.filter((event) => event.type === 'extension_ui_request' && event['method'] === 'setStatus' && event['statusKey'] === 'pstack-mode').map((event) => event['statusText']);
|
|
34
|
-
|
|
35
|
-
test('/mode keeps the skill on every turn until /mode off, with a status badge (MODE-001, MODE-002, MODE-005)', async () => {
|
|
36
|
-
const rpc = startRpc(sandbox, ['--no-session']);
|
|
37
|
-
try {
|
|
38
|
-
const mark = rpc.events.length;
|
|
39
|
-
rpc.send({ type: 'prompt', message: '/mode poteto-mode first task\n@@sys' });
|
|
40
|
-
await rpc.waitFor((event) => event.type === 'agent_settled' && rpc.events.indexOf(event) >= mark);
|
|
41
|
-
const firstUser = rpc.events.find((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'user');
|
|
42
|
-
if (firstUser === undefined) throw new Error('no user message');
|
|
43
|
-
expect(contentText((firstUser['message'] as { content: string }).content)).toBe('first task\n@@sys');
|
|
44
|
-
const firstReply = rpc.events.find((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'assistant');
|
|
45
|
-
if (firstReply === undefined) throw new Error('no assistant reply');
|
|
46
|
-
expect(modeSectionOf(contentText((firstReply['message'] as { content: { type: string; text?: string }[] }).content))?.split('\n')[0]).toBe('Mode poteto-mode is on for every turn until the user runs /mode off.');
|
|
47
|
-
expect(statuses(rpc).some((text) => typeof text === 'string' && text.replace(/\x1b\[[0-9;]*m/g, '') === '♛ mode: poteto-mode')).toBe(true);
|
|
48
|
-
|
|
49
|
-
const second = modeSectionOf(await ask(rpc, 'second turn\n@@sys'));
|
|
50
|
-
expect(second?.startsWith('Mode poteto-mode is on for every turn until the user runs /mode off.\n<skill name="poteto-mode"')).toBe(true);
|
|
51
|
-
expect(second).not.toContain('New task?');
|
|
52
|
-
expect(second).toContain('# Poteto mode\n\nBundled skills live at `../<name>/SKILL.md` from this file');
|
|
53
|
-
// The absolute path that 0.15.21's poteto-mode tells the parent to hand each poteto-agent (RES-010).
|
|
54
|
-
expect(second).toContain(`<skill name="poteto-mode" location="${join(PACKAGE_ROOT, 'skills/poteto-mode/SKILL.md')}">`);
|
|
55
|
-
|
|
56
|
-
await command(rpc, '/mode off');
|
|
57
|
-
expect(modeSectionOf(await ask(rpc, 'third turn\n@@sys'))).toBeUndefined();
|
|
58
|
-
expect(statuses(rpc).at(-1)).toBeUndefined();
|
|
59
|
-
} finally {
|
|
60
|
-
await rpc.close();
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('the /mode task goes through normal skill expansion (MODE-001)', async () => {
|
|
65
|
-
const rpc = startRpc(sandbox, ['--no-session']);
|
|
66
|
-
try {
|
|
67
|
-
const mark = rpc.events.length;
|
|
68
|
-
rpc.send({ type: 'prompt', message: '/mode tdd /ketchup since lunch\n@@say OK' });
|
|
69
|
-
await rpc.waitFor((event) => event.type === 'agent_settled' && rpc.events.indexOf(event) >= mark);
|
|
70
|
-
const user = rpc.events.find((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'user');
|
|
71
|
-
if (user === undefined) throw new Error('no user message');
|
|
72
|
-
const text = contentText((user['message'] as { content: string }).content);
|
|
73
|
-
expect([text.startsWith('<skill name="ketchup"'), text.endsWith('since lunch\n@@say OK')]).toEqual([true, true]);
|
|
74
|
-
} finally {
|
|
75
|
-
await rpc.close();
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('/mode rejects an unknown skill and lists the choices (MODE-002)', async () => {
|
|
80
|
-
const rpc = startRpc(sandbox, ['--no-session']);
|
|
81
|
-
try {
|
|
82
|
-
await command(rpc, '/mode no-such-skill');
|
|
83
|
-
const notice = await rpc.waitFor((event) => event.type === 'extension_ui_request' && event['method'] === 'notify');
|
|
84
|
-
expect(notice['notifyType']).toBe('error');
|
|
85
|
-
expect(String(notice['message'])).toMatch(/^No skill named "no-such-skill"\. Skills that can back a mode: architect, arena, /);
|
|
86
|
-
} finally {
|
|
87
|
-
await rpc.close();
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('a mode whose stored skill path went stale is found again by name (recheck 4)', async () => {
|
|
92
|
-
const sessions = join(sandbox.root, 'sessions');
|
|
93
|
-
const first = startRpc(sandbox, ['--session-dir', sessions]);
|
|
94
|
-
let sessionFile = '';
|
|
95
|
-
try {
|
|
96
|
-
await command(first, '/mode tdd');
|
|
97
|
-
first.send({ id: 'state', type: 'get_state' });
|
|
98
|
-
sessionFile = String(((await first.waitFor((event) => event['id'] === 'state'))['data'] as { sessionFile: string }).sessionFile);
|
|
99
|
-
await ask(first, 'persist\n@@say OK');
|
|
100
|
-
} finally {
|
|
101
|
-
await first.close();
|
|
102
|
-
}
|
|
103
|
-
const moved = readFileSync(sessionFile, 'utf8').replaceAll(join(PACKAGE_ROOT, 'skills/tdd/SKILL.md'), '/old/install/skills/tdd/SKILL.md');
|
|
104
|
-
expect(moved).toContain('/old/install/skills/tdd/SKILL.md');
|
|
105
|
-
writeFileSync(sessionFile, moved);
|
|
106
|
-
const second = startRpc(sandbox, ['--session-dir', sessions, '--session', sessionFile]);
|
|
107
|
-
try {
|
|
108
|
-
const section = modeSectionOf(await ask(second, 'after the move\n@@sys'));
|
|
109
|
-
expect(section?.split('\n').slice(0, 2)).toEqual(['Mode tdd is on for every turn until the user runs /mode off.', `<skill name="tdd" location="${join(PACKAGE_ROOT, 'skills/tdd/SKILL.md')}">`]);
|
|
110
|
-
} finally {
|
|
111
|
-
await second.close();
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test('a stored mode whose skill no longer loads never inlines the stored path (review r2: security 3)', async () => {
|
|
116
|
-
const sessions = join(sandbox.root, 'sessions');
|
|
117
|
-
const secret = join(sandbox.root, 'secret.txt');
|
|
118
|
-
writeFileSync(secret, '---\nname: x\n---\nSECRET-CONTENT\n');
|
|
119
|
-
const first = startRpc(sandbox, ['--session-dir', sessions]);
|
|
120
|
-
let sessionFile = '';
|
|
121
|
-
try {
|
|
122
|
-
await command(first, '/mode tdd');
|
|
123
|
-
first.send({ id: 'state', type: 'get_state' });
|
|
124
|
-
sessionFile = String(((await first.waitFor((event) => event['id'] === 'state'))['data'] as { sessionFile: string }).sessionFile);
|
|
125
|
-
await ask(first, 'persist\n@@say OK');
|
|
126
|
-
} finally {
|
|
127
|
-
await first.close();
|
|
128
|
-
}
|
|
129
|
-
// A crafted or pre-0.15.20 entry that names a path, as an untrusted session file could.
|
|
130
|
-
const crafted = readFileSync(sessionFile, 'utf8').replace(/"data":\{"skill":"tdd"[^}]*\}/, `"data":${JSON.stringify({ skill: 'gone-skill', path: secret })}`);
|
|
131
|
-
expect(crafted).toContain(`"path":${JSON.stringify(secret)}`);
|
|
132
|
-
writeFileSync(sessionFile, crafted);
|
|
133
|
-
const second = startRpc(sandbox, ['--session-dir', sessions, '--session', sessionFile]);
|
|
134
|
-
try {
|
|
135
|
-
const section = modeSectionOf(await ask(second, 'after\n@@sys'));
|
|
136
|
-
expect(section).toBe('Mode gone-skill is on, but no loaded skill has that name. Tell the user to run /mode off or /mode <skill>.');
|
|
137
|
-
} finally {
|
|
138
|
-
await second.close();
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test('the mode survives a restart of the same session (MODE-003)', async () => {
|
|
143
|
-
const sessions = join(sandbox.root, 'sessions');
|
|
144
|
-
const first = startRpc(sandbox, ['--session-dir', sessions]);
|
|
145
|
-
let sessionFile = '';
|
|
146
|
-
try {
|
|
147
|
-
await command(first, '/mode tdd');
|
|
148
|
-
expect(modeSectionOf(await ask(first, 'check\n@@sys'))?.split('\n')[0]).toBe('Mode tdd is on for every turn until the user runs /mode off.');
|
|
149
|
-
first.send({ id: 'state', type: 'get_state' });
|
|
150
|
-
sessionFile = String(((await first.waitFor((event) => event['id'] === 'state'))['data'] as { sessionFile: string }).sessionFile);
|
|
151
|
-
} finally {
|
|
152
|
-
await first.close();
|
|
153
|
-
}
|
|
154
|
-
const second = startRpc(sandbox, ['--session-dir', sessions, '--session', sessionFile]);
|
|
155
|
-
try {
|
|
156
|
-
expect(modeSectionOf(await ask(second, 'after restart\n@@sys'))?.split('\n')[0]).toBe('Mode tdd is on for every turn until the user runs /mode off.');
|
|
157
|
-
} finally {
|
|
158
|
-
await second.close();
|
|
159
|
-
}
|
|
160
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { cpSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join, resolve } from 'node:path';
|
|
4
|
-
|
|
5
|
-
import { afterEach, beforeEach, expect, test } from 'vitest';
|
|
6
|
-
import { PACKAGE_ROOT } from '../../src/paths.ts';
|
|
7
|
-
import { contentText, makeSandbox, piInvocation, runJson, type Sandbox, script, startRpc, toolEnds } from './harness.ts';
|
|
8
|
-
|
|
9
|
-
let sandbox: Sandbox;
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
sandbox = makeSandbox({ packages: [] });
|
|
12
|
-
});
|
|
13
|
-
afterEach(() => sandbox.dispose());
|
|
14
|
-
|
|
15
|
-
const pi = (...args: string[]) => execFileSync(...piInvocation(args), { cwd: sandbox.workDir, env: sandbox.env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
16
|
-
const settingsPackages = () => ((JSON.parse(readFileSync(join(sandbox.agentDir, 'settings.json'), 'utf8')) as { packages?: string[] }).packages ?? []).map((entry) => resolve(sandbox.agentDir, entry));
|
|
17
|
-
|
|
18
|
-
async function discovered(): Promise<{ commands: string[]; tools: string }> {
|
|
19
|
-
const rpc = startRpc(sandbox, ['--no-session']);
|
|
20
|
-
try {
|
|
21
|
-
rpc.send({ id: 'commands', type: 'get_commands' });
|
|
22
|
-
const response = await rpc.waitFor((event) => event['id'] === 'commands');
|
|
23
|
-
const commands = ((response['data'] as { commands: { name: string }[] }).commands ?? []).map((command) => command.name);
|
|
24
|
-
rpc.send({ type: 'prompt', message: 'list\n@@tools-list' });
|
|
25
|
-
const reply = await rpc.waitFor((event) => event.type === 'message_end' && (event['message'] as { role: string }).role === 'assistant');
|
|
26
|
-
return { commands, tools: contentText((reply['message'] as { content: { type: string; text?: string }[] }).content) };
|
|
27
|
-
} finally {
|
|
28
|
-
await rpc.close();
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
test('installs from a fresh copy without node_modules, then removes cleanly (PKG-001)', async () => {
|
|
33
|
-
const copy = join(sandbox.root, 'pi-pstack');
|
|
34
|
-
cpSync(PACKAGE_ROOT, copy, { recursive: true, filter: (source) => !/\/(node_modules|coverage)(\/|$)/.test(source.slice(PACKAGE_ROOT.length)) });
|
|
35
|
-
expect(existsSync(join(copy, 'node_modules'))).toBe(false);
|
|
36
|
-
|
|
37
|
-
pi('install', copy);
|
|
38
|
-
expect(settingsPackages()).toEqual([copy]);
|
|
39
|
-
const installed = await discovered();
|
|
40
|
-
expect(installed.commands.filter((name) => ['mode', 'loop', 'skill:poteto-mode', 'skill:how', 'skill:create-skill', 'skill:deslop'].includes(name)).toSorted()).toEqual([
|
|
41
|
-
'loop',
|
|
42
|
-
'mode',
|
|
43
|
-
'skill:create-skill',
|
|
44
|
-
'skill:deslop',
|
|
45
|
-
'skill:how',
|
|
46
|
-
'skill:poteto-mode',
|
|
47
|
-
]);
|
|
48
|
-
expect(installed.commands.filter((name) => name.startsWith('skill:'))).toHaveLength(62);
|
|
49
|
-
expect(
|
|
50
|
-
installed.tools
|
|
51
|
-
.split(',')
|
|
52
|
-
.filter((name) => ['Task', 'TodoWrite', 'AskQuestion', 'Loop'].includes(name))
|
|
53
|
-
.toSorted(),
|
|
54
|
-
).toEqual(['AskQuestion', 'Loop', 'Task', 'TodoWrite']);
|
|
55
|
-
|
|
56
|
-
// A local install gets no dependency install, so the readonly shell has no sandbox module and must refuse (TASK-019).
|
|
57
|
-
const steps = [{ tool: 'bash', args: { command: 'touch escaped.txt' } }];
|
|
58
|
-
const readonlyRun = await runJson(sandbox, script([{ tool: 'Task', args: { description: 'ro', prompt: `go\n@@script ${JSON.stringify(steps)}`, readonly: true } }]), ['--no-session']);
|
|
59
|
-
expect(toolEnds(readonlyRun.events, 'Task')[0]?.text).toContain('no verified OS sandbox is available');
|
|
60
|
-
expect(existsSync(join(sandbox.workDir, 'escaped.txt'))).toBe(false);
|
|
61
|
-
|
|
62
|
-
pi('remove', copy);
|
|
63
|
-
expect(settingsPackages()).toEqual([]);
|
|
64
|
-
const removed = await discovered();
|
|
65
|
-
expect(removed.commands.filter((name) => name === 'mode' || name.startsWith('skill:'))).toEqual([]);
|
|
66
|
-
expect(removed.tools.split(',').filter((name) => ['Task', 'TodoWrite'].includes(name))).toEqual([]);
|
|
67
|
-
});
|