@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
package/test/plan.test.ts DELETED
@@ -1,126 +0,0 @@
1
- import type { ExtensionAPI, ToolDefinition } from '@earendil-works/pi-coding-agent';
2
- import { expect, test } from 'vitest';
3
- import { PLAN_BASH, PLAN_ENTRY, parsePlanArgs, planBlockReason, planFromBranch, planSection, planTools, registerPlanMode } from '../src/plan.ts';
4
-
5
- type Handler = (event: unknown, ctx: unknown) => unknown;
6
-
7
- function harness(initialTools = ['read', 'bash', 'edit', 'write', 'Task', 'TodoWrite', 'AskQuestion', 'mcp']) {
8
- const tools: ToolDefinition[] = [];
9
- const handlers = new Map<string, Handler[]>();
10
- const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
11
- const entries: { type: string; customType: string; data: unknown }[] = [];
12
- const sent: { text: string; options: unknown }[] = [];
13
- const notes: string[] = [];
14
- const status = new Map<string, string | undefined>();
15
- let active = [...initialTools];
16
- const pi = {
17
- registerTool: (tool: ToolDefinition) => tools.push(tool),
18
- on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
19
- registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => commands.set(name, options),
20
- getActiveTools: () => [...active],
21
- setActiveTools: (names: string[]) => {
22
- active = [...names];
23
- },
24
- appendEntry: (customType: string, data: unknown) => entries.push({ type: 'custom', customType, data: structuredClone(data) }),
25
- sendUserMessage: (text: string, options: unknown) => sent.push({ text, options }),
26
- } as unknown as ExtensionAPI;
27
- const ctx = {
28
- hasUI: true,
29
- isIdle: () => true,
30
- ui: { notify: (text: string) => notes.push(text), setStatus: (key: string, text: string | undefined) => status.set(key, text), theme: { fg: (_color: string, text: string) => text } },
31
- sessionManager: { getBranch: () => entries },
32
- };
33
- const plan = registerPlanMode(pi);
34
- const emit = (event: string, payload: unknown = {}) => (handlers.get(event) ?? []).map((handler) => handler(payload, ctx));
35
- const run = (args: string) => (commands.get('plan') as { handler: (args: string, ctx: unknown) => Promise<void> }).handler(args, ctx);
36
- return { plan, tools, entries, sent, notes, status, emit, run, active: () => active, setActive: (names: string[]) => (active = names) };
37
- }
38
-
39
- test('parses the command forms', () => {
40
- expect(parsePlanArgs('')).toEqual({ kind: 'status' });
41
- expect(parsePlanArgs(' off ')).toEqual({ kind: 'off' });
42
- expect(parsePlanArgs('on')).toEqual({ kind: 'on', task: '' });
43
- expect(parsePlanArgs('add a cache layer')).toEqual({ kind: 'on', task: 'add a cache layer' });
44
- });
45
-
46
- test('registers the sandboxed shell inactive under its own name', () => {
47
- const { tools } = harness();
48
- expect(tools.map((tool) => [tool.name, tool.defaultActive])).toEqual([[PLAN_BASH, false]]);
49
- expect(tools[0]?.description).toContain('denies filesystem writes and network access');
50
- });
51
-
52
- test('/plan restricts the tools and /plan off restores the exact earlier set', async () => {
53
- const h = harness();
54
- const before = h.active();
55
- await h.run('');
56
- expect(h.notes.at(-1)).toContain('off');
57
- expect(h.plan.section()).toBeUndefined();
58
-
59
- await h.run('on');
60
- expect(h.active().toSorted()).toEqual(['AskQuestion', PLAN_BASH, 'Task', 'TodoWrite', 'find', 'grep', 'ls', 'read'].toSorted());
61
- expect(h.status.get('pstack-plan')).toBe('plan');
62
- expect(h.plan.section()).toBe(planSection());
63
- expect(h.sent).toEqual([]);
64
-
65
- await h.run('off');
66
- expect(h.active()).toEqual(before);
67
- expect(h.status.get('pstack-plan')).toBeUndefined();
68
- expect(h.plan.section()).toBeUndefined();
69
- expect(h.entries.map((entry) => entry.data)).toEqual([{ enabled: true, toolsBefore: before }, { enabled: false }]);
70
- });
71
-
72
- test('/plan <task> sends the task, and a second /plan keeps the first saved tool set', async () => {
73
- const h = harness();
74
- const before = h.active();
75
- await h.run('investigate the flaky test');
76
- expect(h.sent).toEqual([{ text: 'investigate the flaky test', options: { expandPromptTemplates: true } }]);
77
- await h.run('another task');
78
- await h.run('off');
79
- expect(h.active()).toEqual(before);
80
- });
81
-
82
- test('plan mode never activates Task when it was off before', async () => {
83
- expect(planTools(['read', 'bash', 'edit'])).toEqual(['read', 'grep', 'find', 'ls', PLAN_BASH]);
84
- });
85
-
86
- test('blocks writers and unknown tools, and lets Task run unchanged for it to restrict (C-M1)', async () => {
87
- const h = harness();
88
- const call = (toolName: string, input: Record<string, unknown> = {}) => ({ result: h.emit('tool_call', { toolName, input })[0], input });
89
- expect(call('write').result).toBeUndefined();
90
-
91
- await h.run('on');
92
- expect(call('write').result).toEqual({ block: true, reason: planBlockReason('write') });
93
- expect(planBlockReason('edit')).toContain('/plan off');
94
- expect((call('bash').result as { reason: string }).reason).toContain(PLAN_BASH);
95
- expect((call('mcp').result as { block: boolean }).block).toBe(true);
96
- expect(call('read').result).toBeUndefined();
97
- const task = call('Task', { prompt: 'look', description: 'x', readonly: false });
98
- expect([task.result, task.input]).toEqual([undefined, { prompt: 'look', description: 'x', readonly: false }]);
99
- expect(h.plan.enabled()).toBe(true);
100
- await h.run('off');
101
- expect(h.plan.enabled()).toBe(false);
102
- });
103
-
104
- test('restores plan mode from the session and drops a stray plan shell when off', async () => {
105
- const h = harness();
106
- await h.run('on');
107
- const saved = (h.entries.at(-1) as { data: unknown }).data as { toolsBefore: string[] };
108
- h.setActive(['read', 'bash', 'edit']);
109
- h.emit('session_start');
110
- expect(h.active()).toContain(PLAN_BASH);
111
- expect(h.active()).not.toContain('edit');
112
- await h.run('off');
113
- expect(h.active()).toEqual(saved.toolsBefore);
114
-
115
- h.setActive([...h.active(), PLAN_BASH]);
116
- h.emit('session_start');
117
- expect(h.active()).not.toContain(PLAN_BASH);
118
- });
119
-
120
- test('reads the last plan entry and treats malformed data as off', () => {
121
- const entry = (data: unknown) => ({ type: 'custom', customType: PLAN_ENTRY, data });
122
- expect(planFromBranch([])).toEqual({ enabled: false });
123
- expect(planFromBranch([entry({ enabled: true, toolsBefore: ['read'] }), entry({ enabled: false })])).toEqual({ enabled: false });
124
- expect(planFromBranch([entry({ enabled: true, toolsBefore: ['read', 3] })])).toEqual({ enabled: true });
125
- expect(planFromBranch([entry(null)])).toEqual({ enabled: false });
126
- });
@@ -1,84 +0,0 @@
1
- import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
2
- import { homedir, tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- import type { ExtensionAPI, ExtensionToolContext, ToolDefinition } from '@earendil-works/pi-coding-agent';
6
- import { afterEach, expect, test, vi } from 'vitest';
7
-
8
- // The real sandbox is the subject here (macOS/Linux only); these tests prove the enforcement the tool promises.
9
- const supported = process.platform === 'darwin' || process.platform === 'linux';
10
- // An in-process consumer of the same dependency may have initialized a permissive policy first; the shell must not
11
- // inherit it (reviews/resume-security.md finding 3).
12
- vi.mock(import('@anthropic-ai/sandbox-runtime'), async (importOriginal) => {
13
- const original = await importOriginal();
14
- if (supported)
15
- await original.SandboxManager.initialize({
16
- network: { allowedDomains: ['example.com'], deniedDomains: [] },
17
- filesystem: { denyRead: [], allowWrite: [tmpdir()], denyWrite: [] },
18
- });
19
- return original;
20
- });
21
-
22
- const { registerReadonlyShell } = await import('../src/readonly-shell.ts');
23
-
24
- let dir: string;
25
- afterEach(() => {
26
- if (dir !== undefined) rmSync(dir, { recursive: true, force: true });
27
- });
28
-
29
- function bash(): ToolDefinition {
30
- const tools: ToolDefinition[] = [];
31
- registerReadonlyShell({ registerTool: (tool: ToolDefinition) => tools.push(tool), on: () => undefined } as unknown as ExtensionAPI);
32
- const [tool] = tools;
33
- if (tool === undefined) throw new Error('no tool registered');
34
- return tool;
35
- }
36
-
37
- const run = async (tool: ToolDefinition, command: string, signal?: AbortSignal): Promise<string> => {
38
- const result = await tool.execute('call', { command }, signal, undefined, { cwd: dir } as ExtensionToolContext);
39
- return result.content.map((part) => (part.type === 'text' ? part.text : '')).join('\n');
40
- };
41
-
42
- test.skipIf(!supported)('a shared pre-initialized sandbox policy does not weaken the shell (TASK-019)', async () => {
43
- dir = realpathSync(mkdtempSync(join(tmpdir(), 'pi-pstack-ro-shared-')));
44
- const text = await run(bash(), `printf shared-escape > ${join(dir, 'shared-escape.txt')} && cat ${join(dir, 'shared-escape.txt')}`).catch((error: Error) => `ERROR: ${error.message}`);
45
- expect(text).toMatch(/Operation not permitted|Read-only file system/);
46
- expect(existsSync(join(dir, 'shared-escape.txt'))).toBe(false);
47
- });
48
-
49
- test.skipIf(!supported)('the dependency default write paths stay denied (TASK-019)', async () => {
50
- dir = realpathSync(mkdtempSync(join(tmpdir(), 'pi-pstack-ro-paths-')));
51
- const tmpClaude = process.platform === 'darwin' ? '/private/tmp/claude' : '/tmp/claude';
52
- const home = homedir();
53
- const homeEscape = join(home, '.claude/debug/pstack-escape.txt');
54
- rmSync(homeEscape, { force: true });
55
- const script = `mkdir -p ${tmpClaude} ~/.claude/debug 2>/dev/null; (printf x > ${tmpClaude}/pstack-escape && echo TMP-CLAUDE-WROTE); (printf x > ${homeEscape} && echo HOME-DEBUG-WROTE); echo done`;
56
- const text = await run(bash(), script);
57
- expect(text).not.toContain('TMP-CLAUDE-WROTE');
58
- expect(text).not.toContain('HOME-DEBUG-WROTE');
59
- expect(text).toContain('done');
60
- expect(existsSync(homeEscape)).toBe(false);
61
- rmSync(join(tmpClaude, 'pstack-escape'), { force: true });
62
- });
63
-
64
- test.skipIf(!supported)('an inherited BASH_ENV startup file never runs outside the sandbox (TASK-019)', async () => {
65
- dir = realpathSync(mkdtempSync(join(tmpdir(), 'pi-pstack-ro-env-')));
66
- const marker = join(dir, 'bashenv-escape.txt');
67
- const startup = join(dir, 'startup.sh');
68
- writeFileSync(startup, `printf escaped > ${marker}\n`);
69
- process.env['BASH_ENV'] = startup;
70
- try {
71
- const text = await run(bash(), 'echo sandbox-ran');
72
- expect(text).toContain('sandbox-ran');
73
- } finally {
74
- delete process.env['BASH_ENV'];
75
- }
76
- expect(existsSync(marker)).toBe(false);
77
- });
78
-
79
- test.skipIf(!supported)('an already-aborted call never spawns the command (TASK-019)', async () => {
80
- dir = realpathSync(mkdtempSync(join(tmpdir(), 'pi-pstack-ro-abort-')));
81
- const controller = new AbortController();
82
- controller.abort();
83
- await expect(run(bash(), 'sleep 1; printf ran-after-abort', controller.signal)).rejects.toThrow(/aborted/i);
84
- });
@@ -1,44 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
- import type { ExtensionAPI, ExtensionToolContext, ToolDefinition } from '@earendil-works/pi-coding-agent';
7
- import { expect, onTestFinished, test, vi } from 'vitest';
8
- import { registerReadonlyShell } from '../src/readonly-shell.ts';
9
-
10
- function registeredBash(): ToolDefinition {
11
- const tools: ToolDefinition[] = [];
12
- const pi = { registerTool: (tool: ToolDefinition) => tools.push(tool), on: () => undefined } as unknown as ExtensionAPI;
13
- registerReadonlyShell(pi);
14
- const [bash] = tools;
15
- if (bash === undefined) throw new Error('no tool registered');
16
- return bash;
17
- }
18
-
19
- test('describes the subprocess boundary without claiming whole-process isolation', () => {
20
- const bash = registeredBash();
21
- expect(bash.name).toBe('bash');
22
- expect(bash.description).toContain('denies filesystem writes and network access');
23
- expect(bash.description).toContain('Pi and trusted extensions are not sandboxed');
24
- });
25
-
26
- test.skipIf(process.platform !== 'darwin' && process.platform !== 'linux')('a process that used the shell exits on its own once its session shuts down', () => {
27
- const cwd = mkdtempSync(join(tmpdir(), 'pi-pstack-ro-exit-'));
28
- onTestFinished(() => rmSync(cwd, { recursive: true, force: true }));
29
- const result = spawnSync(process.execPath, [join(import.meta.dirname, 'fixtures/readonly-shell-exit.ts')], { cwd, encoding: 'utf8', timeout: 20_000 });
30
- expect(result.stdout).toContain('SANDBOXED');
31
- expect(result.stdout).toContain('SHUTDOWN-DONE');
32
- expect([result.error?.message, result.signal, result.status]).toEqual([undefined, null, 0]);
33
- });
34
-
35
- test('unsupported hosts fail closed without running a command', async () => {
36
- vi.spyOn(process, 'platform', 'get').mockReturnValue('freebsd');
37
- onTestFinished(() => {
38
- vi.restoreAllMocks();
39
- });
40
- const cwd = mkdtempSync(join(tmpdir(), 'pi-pstack-ro-'));
41
- onTestFinished(() => rmSync(cwd, { recursive: true, force: true }));
42
- await expect(registeredBash().execute('call-1', { command: 'touch escaped.txt' }, undefined, undefined, { cwd } as ExtensionToolContext)).rejects.toThrow('no verified OS sandbox is available');
43
- expect(existsSync(join(cwd, 'escaped.txt'))).toBe(false);
44
- });
@@ -1,143 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { basename, join } from 'node:path';
5
-
6
- import { SessionManager } from '@earendil-works/pi-coding-agent';
7
- import { afterEach, beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest';
8
- import { storeDir, subagentDir, workspaceSlug, workspaceStateDir, worktreesDir } from '../src/paths.ts';
9
- import { acquireRunLock, readonlyLaunchRestores, readRecord, writeRecord } from '../src/task/records.ts';
10
-
11
- const id = '11111111-2222-4333-8444-555555555555';
12
- let dir = '';
13
- beforeEach(() => {
14
- dir = mkdtempSync(join(tmpdir(), 'locks-'));
15
- });
16
- afterEach(() => rmSync(dir, { recursive: true, force: true }));
17
-
18
- describe('run lock: one writer per agent ID (review: security 3, architecture 2)', () => {
19
- test('a second acquire while the holder lives is refused, naming the lock file', () => {
20
- const lock = acquireRunLock(dir, id);
21
- expect(() => acquireRunLock(dir, id)).toThrow(`Agent ${id} is still running (pid ${process.pid}; lock ${join(dir, `${id}.lock`)}). Wait for its completion before resuming it.`);
22
- lock.release();
23
- expect(existsSync(join(dir, `${id}.lock`))).toBe(false);
24
- });
25
-
26
- test('the lock follows the child that writes the transcript (recheck)', () => {
27
- const lock = acquireRunLock(dir, id);
28
- const child = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' }).stdout;
29
- lock.holdFor(Number(child));
30
- expect(readFileSync(join(dir, `${id}.lock`), 'utf8')).toBe(child);
31
- lock.release();
32
- });
33
-
34
- test('a lock with no readable pid counts as held, naming the file to remove (fail closed)', () => {
35
- writeFileSync(join(dir, `${id}.lock`), '');
36
- expect(() => acquireRunLock(dir, id)).toThrow(`Agent ${id} is still running (pid unknown; lock ${join(dir, `${id}.lock`)}). Wait for its completion before resuming it.`);
37
- });
38
-
39
- test('release leaves a lock that another live holder took over', () => {
40
- const lock = acquireRunLock(dir, id);
41
- writeFileSync(join(dir, `${id}.lock`), String(process.ppid));
42
- lock.release();
43
- expect(readFileSync(join(dir, `${id}.lock`), 'utf8')).toBe(String(process.ppid));
44
- });
45
-
46
- test('release never throws, because a leftover lock is taken over once its holder is gone (review r2: security)', () => {
47
- const lock = acquireRunLock(dir, id);
48
- chmodSync(dir, 0o500);
49
- try {
50
- lock.release();
51
- } finally {
52
- chmodSync(dir, 0o700);
53
- }
54
- expect(readFileSync(join(dir, `${id}.lock`), 'utf8')).toBe(String(process.pid));
55
- });
56
-
57
- test('a lock left by a dead process is taken over', () => {
58
- const dead = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' }).stdout;
59
- writeFileSync(join(dir, `${id}.lock`), dead);
60
- const lock = acquireRunLock(dir, id);
61
- expect(readFileSync(join(dir, `${id}.lock`), 'utf8')).toBe(String(process.pid));
62
- expect(() => acquireRunLock(dir, id)).toThrow('is still running');
63
- lock.release();
64
- });
65
- });
66
-
67
- describe('agent records (review r2: architecture 5)', () => {
68
- test.for(['{not json', '{"agentId":"x"}', '[]'])('a corrupt or partial record %j fails with a clear error', (text) => {
69
- writeFileSync(join(dir, `${id}.json`), text);
70
- expect(() => readRecord(dir, id)).toThrow(`Agent record ${join(dir, `${id}.json`)} is unreadable; delete it and start a new agent.`);
71
- });
72
-
73
- test("a cloud agent's worktree survives a write and read (TASK-010)", () => {
74
- const worktree = { path: '/s/wt', cwd: '/s/wt/pkg', branch: `pstack/${id}`, base: 'main', commit: 'abc' };
75
- writeRecord(dir, { agentId: id, type: 'generalPurpose', readonly: false, cwd: '/w/app', createdAt: 't', worktree });
76
- expect(readRecord(dir, id)?.worktree).toEqual(worktree);
77
- });
78
-
79
- test.for([
80
- { label: 'no createdAt', record: { agentId: id, type: 'generalPurpose', readonly: false, cwd: '/w/app' } },
81
- { label: 'a model that is a string', record: { agentId: id, type: 'generalPurpose', readonly: false, cwd: '/w/app', createdAt: 't', model: 'p/m' } },
82
- { label: 'a model without an id', record: { agentId: id, type: 'generalPurpose', readonly: false, cwd: '/w/app', createdAt: 't', model: { provider: 'p' } } },
83
- { label: 'a thinking level that is not a string', record: { agentId: id, type: 'generalPurpose', readonly: false, cwd: '/w/app', createdAt: 't', model: { provider: 'p', id: 'm', thinking: 3 } } },
84
- ])('a record with $label is unreadable (C-L3)', ({ record }) => {
85
- writeFileSync(join(dir, `${id}.json`), JSON.stringify(record));
86
- expect(() => readRecord(dir, id)).toThrow(`Agent record ${join(dir, `${id}.json`)} is unreadable; delete it and start a new agent.`);
87
- });
88
-
89
- test('a record whose worktree lacks a field is unreadable (TASK-010)', () => {
90
- writeFileSync(join(dir, `${id}.json`), JSON.stringify({ agentId: id, type: 'generalPurpose', readonly: false, cwd: '/w/app', createdAt: 't', worktree: { path: '/s/wt' } }));
91
- expect(() => readRecord(dir, id)).toThrow(`Agent record ${join(dir, `${id}.json`)} is unreadable; delete it and start a new agent.`);
92
- });
93
- });
94
-
95
- describe('per-workspace state (review: parity 4)', () => {
96
- test('names a workspace the way Pi names a session folder', () => {
97
- expect([workspaceSlug('/Users/me/src/app'), workspaceSlug('/w/a:b')]).toEqual(['--Users-me-src-app--', '--w-a-b--']);
98
- });
99
-
100
- test('the workspace folder name stays the default session folder Pi gives that directory', () => {
101
- vi.stubEnv('PI_CODING_AGENT_DIR', dir);
102
- onTestFinished(() => {
103
- vi.unstubAllEnvs();
104
- });
105
- for (const cwd of ['/Users/me/src/app', '/w/a:b']) expect(basename(SessionManager.create(cwd).getSessionDir())).toBe(workspaceSlug(cwd));
106
- });
107
-
108
- test("a subagent uses its main chat's workspace state, wherever it runs (C-M2)", () => {
109
- expect([workspaceStateDir('/w/worktrees/agent-1', '/state/root-workspace'), workspaceStateDir('/w/app', undefined).endsWith('pstack/workspaces/--w-app--')]).toEqual(['/state/root-workspace', true]);
110
- });
111
-
112
- test('keeps subagents and the agent store under the workspace', () => {
113
- const base = join(process.env['PI_CODING_AGENT_DIR'] ?? join(process.env['HOME'] ?? '', '.pi/agent'), 'pstack/workspaces/--w-app--');
114
- expect([subagentDir('/w/app'), storeDir('/w/app', 'session-1'), worktreesDir('/w/app')]).toEqual([join(base, 'subagents'), join(base, 'store/session-1'), join(base, 'worktrees')]);
115
- });
116
- });
117
-
118
- describe('tool loadout after a plan-mode launch (C-M1)', () => {
119
- const tool = (name: string) => ({ name, description: name, parameters: { type: 'object' } });
120
- const system = (id: string, parentId: string | null, added: string[], removed: string[] = []) => ({
121
- type: 'message',
122
- id,
123
- parentId,
124
- timestamp: '2026-10-28T10:00:00.000Z',
125
- message: { role: 'system', content: '', toolsAdded: added.map(tool), toolsRemoved: removed.map((name) => ({ name })), timestamp: 0 },
126
- });
127
- const transcript = (...entries: unknown[]) => {
128
- const path = join(dir, `2026-10-28T10-00-00-000Z_${id}.jsonl`);
129
- writeFileSync(path, [{ type: 'session', version: 3, id, timestamp: '2026-10-28T10:00:00.000Z', cwd: '/w/app' }, ...entries].map((entry) => `${JSON.stringify(entry)}\n`).join(''));
130
- return path;
131
- };
132
-
133
- test('gives back the tools a readonly launch took from the last writable loadout', () => {
134
- const path = transcript(system('s1', null, ['read', 'bash', 'edit', 'write', 'Task']), system('s2', 's1', ['grep', 'bash'], ['bash', 'edit', 'write', 'Task']));
135
- expect(readonlyLaunchRestores(path).toSorted()).toEqual(['Task', 'edit', 'write']);
136
- });
137
-
138
- test('gives back nothing when the loadout is writable or was never writable', () => {
139
- const writable = readonlyLaunchRestores(transcript(system('s1', null, ['read', 'bash', 'edit', 'write'])));
140
- const neverWritable = readonlyLaunchRestores(transcript(system('s1', null, ['read', 'grep'])));
141
- expect({ writable, neverWritable }).toEqual({ writable: [], neverWritable: [] });
142
- });
143
- });
@@ -1,18 +0,0 @@
1
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import { expect, test } from 'vitest';
3
- import { createSubagentRegistry } from '../src/task/registry.ts';
4
- import type { ChildOutcome } from '../src/task/runner.ts';
5
-
6
- const notice = { title: 't', toolCallId: 'c1', mode: 'json' as const, detailFor: () => ({ status: 'success' as const, detail: 'x' }) };
7
- const json = { mode: 'json' } as ExtensionContext;
8
-
9
- test('a one-shot run holds its exit for a running background subagent, unless the run was aborted (TASK-003; review r2: architecture)', async () => {
10
- const registry = createSubagentRegistry({ sendMessage: () => undefined } as unknown as ExtensionAPI);
11
- registry.track('11111111-2222-4333-8444-555555555555', { done: new Promise<ChildOutcome>(() => undefined), kill: () => undefined, stop: () => undefined, steer: async () => undefined, pid: 1 }, () => undefined, false, notice);
12
- let completedSettled = false;
13
- void registry.beforeSettle(json, 'completed').then(() => {
14
- completedSettled = true;
15
- });
16
- const aborted = await registry.beforeSettle(json, 'aborted');
17
- expect([aborted, completedSettled]).toEqual([undefined, false]);
18
- });
@@ -1,162 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
- import { tmpdir } from 'node:os';
3
- import { dirname, join, relative, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
-
6
- import { loadSkills, parseFrontmatter } from '@earendil-works/pi-coding-agent';
7
- import { describe, expect, test } from 'vitest';
8
-
9
- const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
- const PSTACK_HIDDEN = 57;
11
- const TEAM_KIT = ['control-cli', 'control-ui', 'deslop'];
12
- const STANDARD_FIELDS = new Set(['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools', 'disable-model-invocation']);
13
- const SHIPPED_ROOTS = ['skills', 'agents', 'docs', 'automations', 'README.md'];
14
-
15
- function loadPackageSkills() {
16
- return loadSkills({ cwd: tmpdir(), agentDir: join(tmpdir(), 'pi-pstack-no-agent-dir'), skillPaths: [join(root, 'skills')], includeDefaults: false });
17
- }
18
-
19
- function shippedFiles(predicate: (path: string) => boolean): string[] {
20
- const files: string[] = [];
21
- const walk = (directory: string) => {
22
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
23
- const path = join(directory, entry.name);
24
- if (entry.name === 'node_modules' || entry.name === '.DS_Store') continue;
25
- if (entry.isDirectory()) walk(path);
26
- else if (predicate(path)) files.push(relative(root, path));
27
- }
28
- };
29
- for (const shipped of SHIPPED_ROOTS) {
30
- const path = join(root, shipped);
31
- if (statSync(path).isDirectory()) walk(path);
32
- else if (predicate(path)) files.push(shipped);
33
- }
34
- return files.sort();
35
- }
36
-
37
- /**
38
- * Words that name Cursor's host rather than something a Pi user has. Nothing shipped may name Cursor (lowercase
39
- * "cursor" is a text or loop cursor) except the spans listed in OUTSIDE_DATA, each on its own file with the reason.
40
- */
41
- const CURSOR_HOST_TERMS =
42
- /~\/\.cursor|\.cursor\/|\.cursor-plugin|\.mdc\b|Option\+Enter|Alt\+Enter|agent-transcripts|cursor\.com|github\.com\/cursor\/|api2\.cursor|update_state|SendToUser|add-plugin|Custom Mode|cloud agent|cloud VM|cursor-team-kit|Grok Bot/i;
43
- const OUTSIDE_DATA: { file: string; span: RegExp; reason: string }[] = [
44
- { file: 'README.md', span: /A `pstack-models\.mdc` from an earlier version is moved there once, without its frontmatter, and kept as `pstack-models\.mdc\.migrated`\./, reason: 'names the legacy rule file pi-pstack migrates' },
45
- { file: 'README.md', span: /`licenses\/cursor-team-kit\.LICENSE`/, reason: 'names the shipped MIT notice of deslop, control-cli, and control-ui' },
46
- ];
47
-
48
- function hostTermLines(file: string, text: string): string[] {
49
- const exempt = OUTSIDE_DATA.filter((entry) => entry.file === file);
50
- return text.split('\n').flatMap((line, index) => {
51
- const rest = exempt.reduce((remaining, entry) => remaining.replace(entry.span, ''), line);
52
- return CURSOR_HOST_TERMS.test(rest) || /\bCursor\b/.test(rest) ? [`${file}:${index + 1}: ${line.trim().slice(0, 120)}`] : [];
53
- });
54
- }
55
-
56
- describe('shipped resources', () => {
57
- test('loads 62 skills with no diagnostics and names equal to directories (RES-001)', () => {
58
- const { skills, diagnostics } = loadPackageSkills();
59
- expect(diagnostics).toEqual([]);
60
- expect(skills).toHaveLength(62);
61
- const renamed = skills.filter((skill) => skill.name !== skill.filePath.split('/').at(-2));
62
- expect(renamed.map((skill) => skill.name)).toEqual([]);
63
- expect(skills.map((skill) => skill.name)).toEqual(expect.arrayContaining(['poteto-mode', 'make-bot-ui', 'how', 'setup-pstack', 'create-skill', 'ketchup-video', 'statechart', ...TEAM_KIT]));
64
- });
65
-
66
- test('keeps only Agent Skills fields at the top level of skill frontmatter (RES-004)', () => {
67
- const extra = shippedFiles((path) => path.startsWith(join(root, 'skills')) && path.endsWith('/SKILL.md')).flatMap((path) => {
68
- const { frontmatter } = parseFrontmatter(readFileSync(join(root, path), 'utf8'));
69
- return Object.keys(frontmatter)
70
- .filter((key) => !STANDARD_FIELDS.has(key))
71
- .map((key) => `${path}: ${key}`);
72
- });
73
- expect(extra).toEqual([]);
74
- const poteto = parseFrontmatter(readFileSync(join(root, 'skills/poteto-mode/SKILL.md'), 'utf8')).frontmatter;
75
- expect(poteto['metadata']).toEqual({ icon: 'crown', color: 'yellow' });
76
- const typescript = parseFrontmatter(readFileSync(join(root, 'skills/typescript-best-practices/SKILL.md'), 'utf8')).frontmatter;
77
- expect(typescript['metadata']).toEqual({ paths: ['**/*.ts', '**/*.tsx'] });
78
- });
79
-
80
- test('hides 57 pstack skills from model invocation and keeps the rest invocable (RES-003, RES-005)', () => {
81
- const { skills } = loadPackageSkills();
82
- const invocable = skills.filter((skill) => !skill.disableModelInvocation).map((skill) => skill.name);
83
- expect(invocable.toSorted()).toEqual(['control-cli', 'control-ui', 'create-skill', 'deslop', 'setup-pstack']);
84
- expect(skills.filter((skill) => skill.disableModelInvocation)).toHaveLength(PSTACK_HIDDEN);
85
- });
86
-
87
- test('ships the two subagent definitions (RES-006)', () => {
88
- expect(readdirSync(join(root, 'agents')).toSorted()).toEqual(['comment-sicko.md', 'poteto-agent.md']);
89
- });
90
-
91
- test('poteto-mode tells delegates where bundled skills live, and every principle it names is at ../<name>/SKILL.md (RES-010)', () => {
92
- const potetoMode = join(root, 'skills/poteto-mode/SKILL.md');
93
- const text = readFileSync(potetoMode, 'utf8');
94
- expect(text).toContain('Bundled skills live at `../<name>/SKILL.md` from this file');
95
- // Each Principles index entry names its leaf: an explicit (**principle-x**), else principle-<kebab title>.
96
- const index = text.slice(text.indexOf('Each leaf is the **principle-<title>** skill'), text.indexOf('\n## ', text.indexOf('Each leaf is the')));
97
- const leaves = [...index.matchAll(/^- \*\*([^*]+?)\.?\*\*(?: \(\*\*(principle-[a-z-]+)\*\*\))?/gm)].map(
98
- ([, title = '', explicit]) =>
99
- explicit ??
100
- `principle-${title
101
- .toLowerCase()
102
- .replace(/[^a-z0-9 -]/g, '')
103
- .replace(/ +/g, '-')}`,
104
- );
105
- expect(leaves.length).toBeGreaterThan(20);
106
- expect(leaves.filter((name) => !existsSync(resolve(dirname(potetoMode), '..', name, 'SKILL.md')))).toEqual([]);
107
- });
108
-
109
- test('resolves every relative Markdown link in shipped text (RES-007)', () => {
110
- const broken: string[] = [];
111
- for (const file of shippedFiles((path) => path.endsWith('.md'))) {
112
- const text = readFileSync(join(root, file), 'utf8');
113
- // Every relative target that names a file or folder (an extension or a trailing /); placeholders such as (url) are not links.
114
- for (const match of text.matchAll(/\]\((?![a-z]+:|#|\/)([^)#\s]+(?:\.[a-z0-9]+|\/))\)/gi)) {
115
- const target = resolve(dirname(join(root, file)), match[1] ?? '');
116
- if (!existsSync(target)) broken.push(`${file} -> ${match[1]}`);
117
- }
118
- }
119
- expect(broken).toEqual([]);
120
- expect(existsSync(join(root, 'docs/guide/01-setup.md'))).toBe(true);
121
- });
122
-
123
- test('ships Benny as files that never load as skills (RES-008)', () => {
124
- expect(shippedFiles((path) => path.includes('/automations/benny/') && path.endsWith('SKILL.md'))).toHaveLength(3);
125
- const { skills } = loadPackageSkills();
126
- expect(skills.filter((skill) => skill.filePath.includes('/automations/'))).toEqual([]);
127
- });
128
-
129
- test('ships both MIT license texts (RES-009)', () => {
130
- expect(readFileSync(join(root, 'licenses/pstack.LICENSE'), 'utf8')).toContain('Copyright (c) 2026 Lauren Tan');
131
- expect(readFileSync(join(root, 'licenses/cursor-team-kit.LICENSE'), 'utf8')).toContain('MIT License');
132
- const readme = readFileSync(join(root, 'README.md'), 'utf8');
133
- expect(readme).toContain('licenses/pstack.LICENSE');
134
- expect(readme).toContain('licenses/cursor-team-kit.LICENSE');
135
- });
136
-
137
- test('nothing depends on a Cursor plugin checkout: no sync script, lock, or host adapter (RES-011)', () => {
138
- for (const path of ['upstream.lock.json', 'scripts/sync-upstream.mjs', 'scripts/patches.json', 'docs/harness']) expect(existsSync(join(root, path)), path).toBe(false);
139
- const scripts = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts: Record<string, string> }).scripts;
140
- expect(Object.keys(scripts).filter((name) => name.includes('upstream'))).toEqual([]);
141
- });
142
-
143
- test('shipped text names Pi, not Cursor host features (RES-012)', () => {
144
- const offending = shippedFiles(() => true).flatMap((file) => hostTermLines(file, readFileSync(join(root, file), 'utf8')));
145
- const stale = OUTSIDE_DATA.filter(
146
- (entry) =>
147
- !readFileSync(join(root, entry.file), 'utf8')
148
- .split('\n')
149
- .some((line) => entry.span.test(line)),
150
- );
151
- expect({ offending, stale: stale.map((entry) => `${entry.file}: ${entry.reason}`) }).toEqual({ offending: [], stale: [] });
152
- });
153
-
154
- test('RES-012 catches host paths in any case, in README.md, and beside an exempt span', () => {
155
- expect(hostTermLines('skills/x/SKILL.md', 'See https://GitHub.com/Cursor/plugins/blob/main/pstack/README.md.')).toHaveLength(1);
156
- expect(hostTermLines('skills/x/SKILL.md', 'See https://cursor.com/changelog.')).toHaveLength(1);
157
- expect(hostTermLines('README.md', 'Install with /add-plugin pstack.')).toHaveLength(1);
158
- expect(hostTermLines('README.md', 'Theirs are in `licenses/cursor-team-kit.LICENSE`.')).toEqual([]);
159
- expect(hostTermLines('README.md', 'Theirs are in `licenses/cursor-team-kit.LICENSE`, and Cursor reads them.')).toHaveLength(1);
160
- expect(shippedFiles(() => true)).toContain('README.md');
161
- });
162
- });