picopilot 0.1.5 → 0.2.1
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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +205 -17
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +3 -3
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +69 -44
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +226 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/export.d.ts +3 -4
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +4 -9
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/gfx.d.ts +2 -2
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +14 -7
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/lint.d.ts +2 -2
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +3 -2
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/minify.d.ts +2 -2
- package/dist/commands/minify.d.ts.map +1 -1
- package/dist/commands/minify.js +3 -2
- package/dist/commands/minify.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +112 -0
- package/dist/commands/pico8-env.d.ts.map +1 -0
- package/dist/commands/pico8-env.js +118 -0
- package/dist/commands/pico8-env.js.map +1 -0
- package/dist/commands/playtest.d.ts +2 -2
- package/dist/commands/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +4 -9
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/run.d.ts +3 -4
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +52 -19
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/serve.d.ts +2 -2
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +4 -9
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/shrinko-env.d.ts +22 -0
- package/dist/commands/shrinko-env.d.ts.map +1 -0
- package/dist/commands/shrinko-env.js +15 -0
- package/dist/commands/shrinko-env.js.map +1 -0
- package/dist/commands/tokens.d.ts +4 -4
- package/dist/commands/tokens.d.ts.map +1 -1
- package/dist/commands/tokens.js +3 -2
- package/dist/commands/tokens.js.map +1 -1
- package/dist/commands/verify.d.ts +2 -2
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +3 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/engine/child-env.d.ts +14 -0
- package/dist/engine/child-env.d.ts.map +1 -0
- package/dist/engine/child-env.js +21 -0
- package/dist/engine/child-env.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +23 -3
- package/dist/engine/pico8/adapter.d.ts.map +1 -1
- package/dist/engine/pico8/adapter.js.map +1 -1
- package/dist/engine/pico8/cleanup.d.ts +16 -0
- package/dist/engine/pico8/cleanup.d.ts.map +1 -0
- package/dist/engine/pico8/cleanup.js +74 -0
- package/dist/engine/pico8/cleanup.js.map +1 -0
- package/dist/engine/pico8/display.d.ts +149 -0
- package/dist/engine/pico8/display.d.ts.map +1 -0
- package/dist/engine/pico8/display.js +311 -0
- package/dist/engine/pico8/display.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -1
- package/dist/engine/pico8/drive.js +28 -2
- package/dist/engine/pico8/drive.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +8 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +9 -1
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/pico8/index.d.ts +3 -0
- package/dist/engine/pico8/index.d.ts.map +1 -1
- package/dist/engine/pico8/index.js +3 -0
- package/dist/engine/pico8/index.js.map +1 -1
- package/dist/engine/pico8/shell.d.ts +56 -3
- package/dist/engine/pico8/shell.d.ts.map +1 -1
- package/dist/engine/pico8/shell.js +188 -24
- package/dist/engine/pico8/shell.js.map +1 -1
- package/dist/engine/shrinko/shell.d.ts +3 -3
- package/dist/engine/shrinko/shell.js +3 -3
- package/package.json +2 -2
- package/src/cli.test.ts +229 -0
- package/src/cli.ts +230 -18
- package/src/commands/audio.test.ts +64 -1
- package/src/commands/audio.ts +86 -57
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +284 -0
- package/src/commands/export.ts +9 -13
- package/src/commands/gfx.ts +19 -15
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +192 -0
- package/src/commands/playtest.ts +8 -11
- package/src/commands/run.test.ts +110 -2
- package/src/commands/run.ts +68 -24
- package/src/commands/serve.ts +8 -11
- package/src/commands/shrinko-env.test.ts +68 -0
- package/src/commands/shrinko-env.ts +40 -0
- package/src/commands/tokens.ts +9 -6
- package/src/commands/verify.ts +7 -4
- package/src/engine/child-env.ts +22 -0
- package/src/engine/pico8/adapter.ts +25 -3
- package/src/engine/pico8/cleanup.test.ts +28 -0
- package/src/engine/pico8/cleanup.ts +76 -0
- package/src/engine/pico8/display.test.ts +210 -0
- package/src/engine/pico8/display.ts +429 -0
- package/src/engine/pico8/drive.test.ts +20 -0
- package/src/engine/pico8/drive.ts +25 -2
- package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
- package/src/engine/pico8/harness.test.ts +26 -0
- package/src/engine/pico8/harness.ts +18 -2
- package/src/engine/pico8/index.ts +3 -0
- package/src/engine/pico8/shell.test.ts +448 -9
- package/src/engine/pico8/shell.ts +255 -26
- package/src/engine/pico8/virtual-display.process.test.ts +206 -0
- package/src/engine/shrinko/shell.ts +3 -3
- package/src/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-overview/SKILL.md +9 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kill-on-abort for child processes picopilot owns while a run is in flight
|
|
3
|
+
* (the PICO-8 process group, a private Xvfb). Normal endings (sentinel,
|
|
4
|
+
* backstop, crash) are handled by the run itself; this covers picopilot being
|
|
5
|
+
* stopped mid-run: `process.exit`, an uncaught throw, or SIGINT / SIGTERM /
|
|
6
|
+
* SIGHUP sent to picopilot alone (an agent harness or `timeout` killing it).
|
|
7
|
+
* Without it, a detached PICO-8 or an Xvfb would outlive picopilot.
|
|
8
|
+
*
|
|
9
|
+
* `kill` must be synchronous (it runs inside an `exit` handler). On a signal,
|
|
10
|
+
* the hook kills, then re-raises the signal with picopilot's handler removed so
|
|
11
|
+
* the default behaviour (terminate) still happens, unless some other listener
|
|
12
|
+
* owns that signal (an MCP server, a test runner), which then decides.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The kill functions of every run in flight. ONE listener per event serves them
|
|
19
|
+
* all (installed while the set is non-empty), so many concurrent runs (an MCP
|
|
20
|
+
* server) never trip Node's MaxListeners warning.
|
|
21
|
+
*/
|
|
22
|
+
const pending = new Set<() => void>();
|
|
23
|
+
const signalHandlers = new Map<NodeJS.Signals, () => void>();
|
|
24
|
+
|
|
25
|
+
function killAll(): void {
|
|
26
|
+
for (const kill of [...pending]) {
|
|
27
|
+
pending.delete(kill);
|
|
28
|
+
try {
|
|
29
|
+
kill();
|
|
30
|
+
} catch {
|
|
31
|
+
// best effort: the process may already be gone
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function onExit(): void {
|
|
37
|
+
killAll();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function install(): void {
|
|
41
|
+
process.on('exit', onExit);
|
|
42
|
+
for (const signal of SIGNALS) {
|
|
43
|
+
const handler = () => {
|
|
44
|
+
killAll();
|
|
45
|
+
uninstall();
|
|
46
|
+
// Re-raise for the default behaviour (terminate) unless another listener
|
|
47
|
+
// (an MCP server, a test runner) owns this signal.
|
|
48
|
+
if (process.listenerCount(signal) === 0) {
|
|
49
|
+
process.kill(process.pid, signal);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
signalHandlers.set(signal, handler);
|
|
53
|
+
process.on(signal, handler);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function uninstall(): void {
|
|
58
|
+
process.removeListener('exit', onExit);
|
|
59
|
+
for (const [signal, handler] of signalHandlers) {
|
|
60
|
+
process.removeListener(signal, handler);
|
|
61
|
+
}
|
|
62
|
+
signalHandlers.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Registers `kill` to run if picopilot exits or is signalled; returns the disposer. */
|
|
66
|
+
export function killOnAbort(kill: () => void): () => void {
|
|
67
|
+
let entry: (() => void) | undefined = () => kill();
|
|
68
|
+
if (pending.size === 0) install();
|
|
69
|
+
pending.add(entry);
|
|
70
|
+
return () => {
|
|
71
|
+
if (entry === undefined) return;
|
|
72
|
+
pending.delete(entry);
|
|
73
|
+
entry = undefined;
|
|
74
|
+
if (pending.size === 0) uninstall();
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
detectVirtualDisplayBackend,
|
|
4
|
+
needsVirtualDisplay,
|
|
5
|
+
resolveDisplayMode,
|
|
6
|
+
startVirtualDisplay,
|
|
7
|
+
type XvfbSpawner,
|
|
8
|
+
} from './display.js';
|
|
9
|
+
import {FakeXvfb} from './fake-xvfb.test-support.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The display-mode seam (ADR-0017) is tested WITHOUT a real X server: the
|
|
13
|
+
* resolution + decision tables are pure, and Xvfb is a fake process that the
|
|
14
|
+
* test makes report a display number, exit early, or hang.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe('resolveDisplayMode: flag > env > config > default', () => {
|
|
18
|
+
const cases: {
|
|
19
|
+
name: string;
|
|
20
|
+
flag?: string;
|
|
21
|
+
env?: string;
|
|
22
|
+
config?: unknown;
|
|
23
|
+
mode: string;
|
|
24
|
+
source: string;
|
|
25
|
+
}[] = [
|
|
26
|
+
{name: 'nothing set', mode: 'auto', source: 'default'},
|
|
27
|
+
{name: 'config only', config: 'native', mode: 'native', source: 'config'},
|
|
28
|
+
{name: 'env only', env: 'xvfb', mode: 'xvfb', source: 'env'},
|
|
29
|
+
{name: 'flag only', flag: 'native', mode: 'native', source: 'flag'},
|
|
30
|
+
{
|
|
31
|
+
name: 'env beats config',
|
|
32
|
+
env: 'xvfb',
|
|
33
|
+
config: 'native',
|
|
34
|
+
mode: 'xvfb',
|
|
35
|
+
source: 'env',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'flag beats env and config',
|
|
39
|
+
flag: 'auto',
|
|
40
|
+
env: 'xvfb',
|
|
41
|
+
config: 'native',
|
|
42
|
+
mode: 'auto',
|
|
43
|
+
source: 'flag',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'an empty env value is unset',
|
|
47
|
+
env: ' ',
|
|
48
|
+
config: 'xvfb',
|
|
49
|
+
mode: 'xvfb',
|
|
50
|
+
source: 'config',
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
for (const c of cases) {
|
|
54
|
+
it(c.name, () => {
|
|
55
|
+
expect(
|
|
56
|
+
resolveDisplayMode({flag: c.flag, env: c.env, config: c.config}),
|
|
57
|
+
).toEqual({ok: true, mode: c.mode, source: c.source});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it('reports an unknown value instead of silently falling back', () => {
|
|
62
|
+
expect(resolveDisplayMode({env: 'xfvb', config: 'native'})).toEqual({
|
|
63
|
+
ok: false,
|
|
64
|
+
source: 'env',
|
|
65
|
+
value: 'xfvb',
|
|
66
|
+
});
|
|
67
|
+
expect(resolveDisplayMode({config: 42})).toEqual({
|
|
68
|
+
ok: false,
|
|
69
|
+
source: 'config',
|
|
70
|
+
value: '42',
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('needsVirtualDisplay: the auto decision', () => {
|
|
76
|
+
const table: [string, NodeJS.ProcessEnv, boolean, boolean, boolean][] = [
|
|
77
|
+
// [name, env, auto, xvfb, native]
|
|
78
|
+
['no display at all', {}, true, true, false],
|
|
79
|
+
['X11 DISPLAY set', {DISPLAY: ':0'}, false, true, false],
|
|
80
|
+
['Wayland only', {WAYLAND_DISPLAY: 'wayland-0'}, false, true, false],
|
|
81
|
+
['both set', {DISPLAY: ':0', WAYLAND_DISPLAY: 'w'}, false, true, false],
|
|
82
|
+
['empty DISPLAY counts as none', {DISPLAY: ''}, true, true, false],
|
|
83
|
+
];
|
|
84
|
+
for (const [name, env, auto, xvfb, native] of table) {
|
|
85
|
+
it(name, () => {
|
|
86
|
+
expect(needsVirtualDisplay('auto', env)).toBe(auto);
|
|
87
|
+
expect(needsVirtualDisplay('xvfb', env)).toBe(xvfb);
|
|
88
|
+
expect(needsVirtualDisplay('native', env)).toBe(native);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('detectVirtualDisplayBackend', () => {
|
|
94
|
+
it('prefers Xvfb over xvfb-run, and reports none when neither exists', () => {
|
|
95
|
+
const has =
|
|
96
|
+
(...names: string[]) =>
|
|
97
|
+
(n: string) =>
|
|
98
|
+
names.includes(n) ? `/bin/${n}` : undefined;
|
|
99
|
+
expect(detectVirtualDisplayBackend({}, has('Xvfb', 'xvfb-run'))).toBe(
|
|
100
|
+
'Xvfb',
|
|
101
|
+
);
|
|
102
|
+
expect(detectVirtualDisplayBackend({}, has('xvfb-run'))).toBe('xvfb-run');
|
|
103
|
+
expect(detectVirtualDisplayBackend({}, has())).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const onlyXvfb = (n: string) => (n === 'Xvfb' ? '/fake/Xvfb' : undefined);
|
|
108
|
+
|
|
109
|
+
describe('startVirtualDisplay: direct Xvfb with -displayfd', () => {
|
|
110
|
+
it('spawns Xvfb -displayfd and sets DISPLAY to the number it reports', async () => {
|
|
111
|
+
const xvfb = new FakeXvfb();
|
|
112
|
+
let spawned: {file: string; args: string[]} | undefined;
|
|
113
|
+
const spawnXvfb: XvfbSpawner = (file, args) => {
|
|
114
|
+
spawned = {file, args};
|
|
115
|
+
setImmediate(() => xvfb.reportDisplay(37));
|
|
116
|
+
return xvfb;
|
|
117
|
+
};
|
|
118
|
+
const started = await startVirtualDisplay({}, {find: onlyXvfb, spawnXvfb});
|
|
119
|
+
expect(started.ok).toBe(true);
|
|
120
|
+
if (!started.ok) return;
|
|
121
|
+
expect(spawned?.file).toBe('/fake/Xvfb');
|
|
122
|
+
expect(spawned?.args).toContain('-displayfd');
|
|
123
|
+
// No fixed display number: the server picks one (no races between runs).
|
|
124
|
+
expect(spawned?.args.some((a) => /^:\d+$/.test(a))).toBe(false);
|
|
125
|
+
expect(started.display.backend).toBe('Xvfb');
|
|
126
|
+
expect(started.display.env).toEqual({DISPLAY: ':37'});
|
|
127
|
+
expect(started.display.wrap('pico8', ['-run', 'c.p8'])).toEqual({
|
|
128
|
+
file: 'pico8',
|
|
129
|
+
args: ['-run', 'c.p8'],
|
|
130
|
+
});
|
|
131
|
+
await started.display.stop();
|
|
132
|
+
expect(xvfb.signals).toContain('SIGTERM');
|
|
133
|
+
expect(xvfb.exited).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('escalates to SIGKILL when Xvfb ignores SIGTERM', async () => {
|
|
137
|
+
const xvfb = new FakeXvfb();
|
|
138
|
+
xvfb.exitOnTerm = false;
|
|
139
|
+
const started = await startVirtualDisplay(
|
|
140
|
+
{},
|
|
141
|
+
{
|
|
142
|
+
find: onlyXvfb,
|
|
143
|
+
stopGraceMs: 10,
|
|
144
|
+
spawnXvfb: () => {
|
|
145
|
+
setImmediate(() => xvfb.reportDisplay(1));
|
|
146
|
+
return xvfb;
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
if (!started.ok) throw new Error('expected a display');
|
|
151
|
+
await started.display.stop();
|
|
152
|
+
expect(xvfb.signals).toEqual(['SIGTERM', 'SIGKILL']);
|
|
153
|
+
expect(xvfb.exited).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('is display-unavailable (with stderr) when Xvfb exits before reporting', async () => {
|
|
157
|
+
const xvfb = new FakeXvfb();
|
|
158
|
+
const started = await startVirtualDisplay(
|
|
159
|
+
{},
|
|
160
|
+
{
|
|
161
|
+
find: onlyXvfb,
|
|
162
|
+
spawnXvfb: () => {
|
|
163
|
+
setImmediate(() => {
|
|
164
|
+
xvfb.writeStderr('Fatal server error: no screens\n');
|
|
165
|
+
xvfb.exit(1);
|
|
166
|
+
});
|
|
167
|
+
return xvfb;
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
expect(started.ok).toBe(false);
|
|
172
|
+
if (started.ok) return;
|
|
173
|
+
expect(started.reason).toBe('display-unavailable');
|
|
174
|
+
expect(started.detail).toContain('no screens');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('kills Xvfb and is display-unavailable when it never reports a display', async () => {
|
|
178
|
+
const xvfb = new FakeXvfb();
|
|
179
|
+
const started = await startVirtualDisplay(
|
|
180
|
+
{},
|
|
181
|
+
{find: onlyXvfb, startTimeoutMs: 20, spawnXvfb: () => xvfb},
|
|
182
|
+
);
|
|
183
|
+
expect(started.ok).toBe(false);
|
|
184
|
+
expect(xvfb.exited).toBe(true); // never left behind
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('startVirtualDisplay: xvfb-run fallback and absence', () => {
|
|
189
|
+
it('wraps the command in xvfb-run -a when only xvfb-run exists', async () => {
|
|
190
|
+
const started = await startVirtualDisplay(
|
|
191
|
+
{},
|
|
192
|
+
{find: (n) => (n === 'xvfb-run' ? '/fake/xvfb-run' : undefined)},
|
|
193
|
+
);
|
|
194
|
+
if (!started.ok) throw new Error('expected xvfb-run');
|
|
195
|
+
expect(started.display.backend).toBe('xvfb-run');
|
|
196
|
+
const w = started.display.wrap('pico8', ['-run', 'c.p8']);
|
|
197
|
+
expect(w.file).toBe('/fake/xvfb-run');
|
|
198
|
+
expect(w.args[0]).toBe('-a');
|
|
199
|
+
expect(w.args.slice(-3)).toEqual(['pico8', '-run', 'c.p8']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('is display-unavailable with a remedy naming Xvfb when nothing is installed', async () => {
|
|
203
|
+
const started = await startVirtualDisplay({}, {find: () => undefined});
|
|
204
|
+
expect(started.ok).toBe(false);
|
|
205
|
+
if (started.ok) return;
|
|
206
|
+
expect(started.reason).toBe('display-unavailable');
|
|
207
|
+
expect(started.remedy).toMatch(/Xvfb/);
|
|
208
|
+
expect(started.remedy).toMatch(/desktop/);
|
|
209
|
+
});
|
|
210
|
+
});
|