@saifymatteo/pi-extensions 0.1.4 → 0.1.5
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 +28 -0
- package/package.json +2 -1
- package/packages/pi-bg-bash/AGENTS.md +29 -0
- package/packages/pi-bg-bash/CHANGELOG.md +25 -0
- package/packages/pi-bg-bash/package.json +2 -1
- package/packages/pi-bg-bash/src/index.ts +189 -99
- package/packages/pi-bg-bash/src/lib/output.ts +31 -0
- package/packages/pi-bg-bash/src/lib/panel.ts +155 -0
- package/packages/pi-bg-bash/src/lib/process-control.ts +221 -0
- package/packages/pi-bg-bash/src/lib/windows-console.ts +169 -0
- package/packages/pi-bg-bash/test/job-controls.test.mjs +347 -0
- package/packages/pi-bg-bash/test/native-process-control.test.mjs +126 -0
- package/packages/pi-bg-bash/test/output.test.mjs +28 -0
- package/packages/pi-bg-bash/test/panel.test.mjs +146 -0
- package/packages/pi-bg-bash/test/process-control.test.mjs +300 -0
- package/packages/pi-bg-bash/test/windows-console.test.mjs +221 -0
- package/packages/pi-claude-usage/CHANGELOG.md +18 -0
- package/packages/pi-claude-usage/package.json +2 -1
- package/packages/pi-clipboard-image/CHANGELOG.md +18 -0
- package/packages/pi-clipboard-image/package.json +2 -1
- package/packages/pi-codeblock-borders/CHANGELOG.md +18 -0
- package/packages/pi-codeblock-borders/package.json +2 -1
- package/packages/pi-codegraph/CHANGELOG.md +25 -0
- package/packages/pi-codegraph/package.json +2 -1
- package/packages/pi-compact-tools/CHANGELOG.md +18 -0
- package/packages/pi-compact-tools/package.json +2 -1
- package/packages/pi-decisions/CHANGELOG.md +18 -0
- package/packages/pi-decisions/package.json +2 -1
- package/packages/pi-load-pi-dotenv/CHANGELOG.md +18 -0
- package/packages/pi-load-pi-dotenv/package.json +2 -1
- package/packages/pi-model-go/CHANGELOG.md +18 -0
- package/packages/pi-model-go/package.json +2 -1
- package/packages/pi-prestart-working/CHANGELOG.md +18 -0
- package/packages/pi-prestart-working/package.json +2 -1
- package/packages/pi-root-guard/CHANGELOG.md +18 -0
- package/packages/pi-root-guard/package.json +2 -1
- package/packages/pi-session-finder/CHANGELOG.md +18 -0
- package/packages/pi-session-finder/package.json +2 -1
- package/packages/pi-session-id/CHANGELOG.md +18 -0
- package/packages/pi-session-id/package.json +2 -1
- package/packages/pi-session-nav/CHANGELOG.md +18 -0
- package/packages/pi-session-nav/package.json +2 -1
- package/packages/pi-session-rename/CHANGELOG.md +18 -0
- package/packages/pi-session-rename/package.json +2 -1
- package/packages/pi-session-retention/CHANGELOG.md +25 -0
- package/packages/pi-session-retention/package.json +2 -1
- package/packages/pi-skill-usage-log/CHANGELOG.md +18 -0
- package/packages/pi-skill-usage-log/package.json +2 -1
- package/packages/pi-tool-usage-log/CHANGELOG.md +18 -0
- package/packages/pi-tool-usage-log/package.json +2 -1
- package/packages/pi-tts/CHANGELOG.md +18 -0
- package/packages/pi-tts/package.json +2 -1
- package/packages/pi-user-image-previews/CHANGELOG.md +18 -0
- package/packages/pi-user-image-previews/package.json +2 -1
- package/packages/pi-vision/CHANGELOG.md +18 -0
- package/packages/pi-vision/package.json +2 -1
- package/packages/pi-wait/CHANGELOG.md +18 -0
- package/packages/pi-wait/package.json +2 -1
- package/packages/pi-zvec/CHANGELOG.md +25 -0
- package/packages/pi-zvec/package.json +2 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { loadExtension } from '../../../test/load-extension.mjs';
|
|
6
|
+
|
|
7
|
+
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
|
8
|
+
|
|
9
|
+
function setup({ stop = async () => 'stopped', capture = async pid => ({ pid, started: 'fixture' }), windows = false,
|
|
10
|
+
launchText = '__PI_BG_PID:501 __PI_BG_WINPID:601', hasChildren = () => false } = {}) {
|
|
11
|
+
const tools = new Map(), commands = new Map(), hooks = new Map(), events = new Map();
|
|
12
|
+
const files = new Map(), intervals = new Set(), messages = [], statuses = [], calls = [], stopped = [];
|
|
13
|
+
let controls, panelSignal;
|
|
14
|
+
const processStub = { ...process, platform: windows ? 'win32' : 'linux', kill() {} };
|
|
15
|
+
const builtin = () => ({ description: '', execute: async (_id, args) => {
|
|
16
|
+
calls.push(args);
|
|
17
|
+
return { content: [{ type: 'text', text: launchText }] };
|
|
18
|
+
} });
|
|
19
|
+
const { default: extension } = loadExtension(fileURLToPath(new URL('../src/index.ts', import.meta.url)), {
|
|
20
|
+
'node:fs': { ...fs, readdirSync: () => [], existsSync: path => files.has(path), readFileSync: path => {
|
|
21
|
+
if (files.has(path)) return files.get(path);
|
|
22
|
+
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
23
|
+
}, writeFileSync: (path, content) => files.set(path, content) },
|
|
24
|
+
'./lib/process-control': { captureProcess: capture, hasTrackedDescendants: hasChildren,
|
|
25
|
+
stopProcessTree: async (identity, mode) => { stopped.push({ identity, mode }); return stop(identity, mode); } },
|
|
26
|
+
'./lib/output': { readTail: path => files.get(path) ?? '', oneLine: text => text.replace(/\s+/g, ' ') },
|
|
27
|
+
'./lib/panel': { showJobPanel: async (_ctx, api, signal) => { controls = api; panelSignal = signal; } },
|
|
28
|
+
'@earendil-works/pi-coding-agent': { createBashTool: builtin, createPowerShellTool: builtin },
|
|
29
|
+
'@earendil-works/pi-tui': {},
|
|
30
|
+
}, { process: processStub, crypto: globalThis.crypto,
|
|
31
|
+
setInterval: callback => { const timer = { callback }; intervals.add(timer); return timer; },
|
|
32
|
+
clearInterval: timer => intervals.delete(timer),
|
|
33
|
+
});
|
|
34
|
+
const pi = { registerTool: tool => tools.set(tool.name, tool), registerToolRenderer() {}, registerMessageRenderer() {},
|
|
35
|
+
registerCommand: (name, options) => commands.set(name, options.handler),
|
|
36
|
+
events: { on: (name, fn) => events.set(name, fn) }, on: (name, fn) => hooks.set(name, fn),
|
|
37
|
+
getAllTools: () => [{ name: 'bash' }, ...(windows ? [{ name: 'powershell' }] : [])],
|
|
38
|
+
sendMessage: (message, options) => messages.push({ message, options }),
|
|
39
|
+
};
|
|
40
|
+
const ctx = { hasUI: true, mode: 'tui', isIdle: () => true, hasPendingMessages: () => false,
|
|
41
|
+
sessionManager: { getSessionFile: () => 'fixture-session', getBranch: () => [] },
|
|
42
|
+
ui: { setStatus: (...args) => statuses.push(args), notify() {} },
|
|
43
|
+
};
|
|
44
|
+
extension(pi);
|
|
45
|
+
return {
|
|
46
|
+
tools, commands, hooks, files, intervals, messages, statuses, stopped, calls, ctx,
|
|
47
|
+
get controls() { return controls; }, get panelSignal() { return panelSignal; },
|
|
48
|
+
start: () => hooks.get('session_start')({}, ctx),
|
|
49
|
+
launch: async (tool = 'bash') => {
|
|
50
|
+
const result = await tools.get(tool).execute('fixture', { command: 'npm run dev', background: true }, undefined, undefined, ctx);
|
|
51
|
+
const id = result.details.bgJob;
|
|
52
|
+
return { id, outPath: result.details.bgOutputPath, statusPath: result.details.bgOutputPath.replace(/\.out$/, '.status') };
|
|
53
|
+
},
|
|
54
|
+
panel: () => commands.get('bg')('', ctx),
|
|
55
|
+
claim: ids => {
|
|
56
|
+
let provider;
|
|
57
|
+
events.get('async-work:providers:v1')({ sessionFile: 'fixture-session', add: value => { provider = value; } });
|
|
58
|
+
return provider.claim(ids, 'fixture-wait');
|
|
59
|
+
},
|
|
60
|
+
shutdown: () => hooks.get('session_shutdown')({ reason: 'quit' }, ctx),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('launch records the real platform PID and updates the running footer', async () => {
|
|
65
|
+
for (const windows of [false, true]) {
|
|
66
|
+
const captured = [];
|
|
67
|
+
const s = setup({ windows, capture: async pid => { captured.push(pid); return { pid, started: 'fixture' }; } });
|
|
68
|
+
await s.start();
|
|
69
|
+
await s.launch();
|
|
70
|
+
if (windows) await s.launch('powershell');
|
|
71
|
+
assert.deepEqual(captured, windows ? [601, 501] : [501]);
|
|
72
|
+
assert.match(s.statuses.at(-1)[1], /running.*\/bg/);
|
|
73
|
+
await s.shutdown();
|
|
74
|
+
assert.equal(s.statuses.at(-1)[1], undefined);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('Windows launch isolates both shells and clears Ctrl+C suppression before children start', async () => {
|
|
79
|
+
const s = setup({ windows: true });
|
|
80
|
+
await s.start();
|
|
81
|
+
await s.launch();
|
|
82
|
+
const launcher = Buffer.from(s.calls[0].command.match(/-EncodedCommand (\S+)/)[1], 'base64').toString('utf16le');
|
|
83
|
+
assert.match(launcher, /-WindowStyle Hidden/);
|
|
84
|
+
assert.match(launcher, /-WorkingDirectory \(Get-Location\)\.ProviderPath/);
|
|
85
|
+
assert.doesNotMatch(launcher, /-NoNewWindow/);
|
|
86
|
+
const bashBootstrap = [...s.files].find(([path]) => path.endsWith('.ps1'))[1];
|
|
87
|
+
assert.match(bashBootstrap, /SetConsoleCtrlHandler\(\[IntPtr\]::Zero, \$false\)/);
|
|
88
|
+
assert.ok(bashBootstrap.indexOf('SetConsoleCtrlHandler') < bashBootstrap.indexOf('$child = [System.Diagnostics.Process]::Start'));
|
|
89
|
+
assert.ok(bashBootstrap.indexOf('SetHandleInformation($inputHandle, 1, 1)') < bashBootstrap.indexOf('$child = [System.Diagnostics.Process]::Start'));
|
|
90
|
+
await s.launch('powershell');
|
|
91
|
+
const psBootstrap = [...s.files].filter(([path]) => path.endsWith('.ps1')).at(-1)[1];
|
|
92
|
+
assert.ok(psBootstrap.indexOf('SetConsoleCtrlHandler') < psBootstrap.indexOf('npm run dev'));
|
|
93
|
+
await s.shutdown();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('Unix background jobs enable job control instead of inheriting ignored SIGINT', async () => {
|
|
97
|
+
const s = setup();
|
|
98
|
+
await s.start();
|
|
99
|
+
await s.launch();
|
|
100
|
+
assert.match(s.calls[0].command, /^set -m;/);
|
|
101
|
+
await s.shutdown();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('failed launch without a PID does not register an unmanageable forever-running job', async () => {
|
|
105
|
+
const s = setup({ launchText: 'launch failed' });
|
|
106
|
+
await s.start();
|
|
107
|
+
await assert.rejects(s.launch(), /did not return a native PID/);
|
|
108
|
+
await s.panel();
|
|
109
|
+
assert.equal(s.controls.list().length, 0);
|
|
110
|
+
assert.equal(s.intervals.size, 0);
|
|
111
|
+
await s.shutdown();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('identity discovery does not block launch; stopping waits for verification', { timeout: 1000 }, async () => {
|
|
115
|
+
const identity = deferred();
|
|
116
|
+
const s = setup({ capture: () => identity.promise });
|
|
117
|
+
await s.start();
|
|
118
|
+
const job = await s.launch();
|
|
119
|
+
await s.panel();
|
|
120
|
+
assert.match(s.controls.list()[0].state, /verifying PID/);
|
|
121
|
+
const stopping = s.controls.stop(job.id);
|
|
122
|
+
await Promise.resolve();
|
|
123
|
+
assert.equal(s.stopped.length, 0);
|
|
124
|
+
identity.resolve({ pid: 501, started: 'fixture' });
|
|
125
|
+
await stopping;
|
|
126
|
+
assert.equal(s.stopped.length, 1);
|
|
127
|
+
await s.shutdown();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('viewing jobs/output does not consume or claim completion; footer clears on exit', async () => {
|
|
131
|
+
const s = setup();
|
|
132
|
+
await s.start();
|
|
133
|
+
const job = await s.launch();
|
|
134
|
+
s.files.set(job.outPath, 'preview ready');
|
|
135
|
+
await s.panel();
|
|
136
|
+
assert.equal(s.controls.output(job.id), 'preview ready');
|
|
137
|
+
assert.equal(s.controls.list()[0].state, 'running');
|
|
138
|
+
const lease = s.claim([job.id]);
|
|
139
|
+
s.files.set(job.statusPath, '0');
|
|
140
|
+
for (const timer of s.intervals) timer.callback();
|
|
141
|
+
const [result] = await lease.result;
|
|
142
|
+
assert.equal(result.details.exit, '0');
|
|
143
|
+
assert.equal(s.messages.length, 0);
|
|
144
|
+
assert.equal(s.statuses.at(-1)[1], undefined);
|
|
145
|
+
lease.commit();
|
|
146
|
+
await s.shutdown();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('a direct user stop is recorded without waking an idle model and remains queryable', async () => {
|
|
150
|
+
const s = setup();
|
|
151
|
+
await s.start();
|
|
152
|
+
const job = await s.launch();
|
|
153
|
+
await s.panel();
|
|
154
|
+
assert.match(await s.controls.stop(job.id), /tracked process tree is gone/);
|
|
155
|
+
assert.equal(s.stopped.length, 1);
|
|
156
|
+
assert.equal(s.controls.list()[0].state, 'done, stopped gracefully by user');
|
|
157
|
+
assert.equal(s.messages.length, 1);
|
|
158
|
+
assert.equal(s.messages[0].options.triggerTurn, false);
|
|
159
|
+
const result = await s.tools.get('bash').execute('query', { job: String(job.id) }, undefined, undefined, s.ctx);
|
|
160
|
+
assert.match(result.content[0].text, /stopped gracefully by user/);
|
|
161
|
+
await s.shutdown();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('user stopping a wait-owned job resolves the lease once without duplicate delivery', async () => {
|
|
165
|
+
const s = setup();
|
|
166
|
+
await s.start();
|
|
167
|
+
const job = await s.launch();
|
|
168
|
+
const lease = s.claim([job.id]);
|
|
169
|
+
await s.panel();
|
|
170
|
+
await s.controls.stop(job.id);
|
|
171
|
+
const results = await lease.result;
|
|
172
|
+
assert.equal(results.length, 1);
|
|
173
|
+
assert.equal(results[0].details.exit, 'stopped gracefully by user');
|
|
174
|
+
assert.equal(s.messages.length, 0);
|
|
175
|
+
lease.commit();
|
|
176
|
+
lease.release();
|
|
177
|
+
assert.equal(s.messages.length, 0);
|
|
178
|
+
await s.shutdown();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('releasing a stopped job restores exactly one automatic delivery', async () => {
|
|
182
|
+
const s = setup();
|
|
183
|
+
await s.start();
|
|
184
|
+
const job = await s.launch();
|
|
185
|
+
const lease = s.claim([job.id]);
|
|
186
|
+
await s.panel();
|
|
187
|
+
await s.controls.stop(job.id);
|
|
188
|
+
await lease.result;
|
|
189
|
+
lease.release();
|
|
190
|
+
lease.release();
|
|
191
|
+
assert.equal(s.messages.length, 1);
|
|
192
|
+
await s.shutdown();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('concurrent user and agent stop share one operation and preserve the wait result', { timeout: 1000 }, async () => {
|
|
196
|
+
const operation = deferred(), entered = deferred();
|
|
197
|
+
const s = setup({ stop: () => { entered.resolve(); return operation.promise; } });
|
|
198
|
+
await s.start();
|
|
199
|
+
const job = await s.launch();
|
|
200
|
+
const lease = s.claim([job.id]);
|
|
201
|
+
await s.panel();
|
|
202
|
+
const first = s.controls.stop(job.id);
|
|
203
|
+
const second = s.tools.get('bash').execute('stop', { job: `${job.id}:stop` }, undefined, undefined, s.ctx);
|
|
204
|
+
await entered.promise;
|
|
205
|
+
assert.equal(s.stopped.length, 1);
|
|
206
|
+
assert.equal(s.controls.list()[0].state, 'stopping gracefully');
|
|
207
|
+
operation.resolve('stopped');
|
|
208
|
+
await Promise.all([first, second]);
|
|
209
|
+
assert.equal((await lease.result)[0].details.exit, 'stopped gracefully by user');
|
|
210
|
+
lease.commit();
|
|
211
|
+
await s.shutdown();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('force requested during graceful stopping queues a real force operation without duplicating completion', { timeout: 1000 }, async () => {
|
|
215
|
+
const graceful = deferred(), entered = deferred();
|
|
216
|
+
const s = setup({ stop: (_identity, mode) => {
|
|
217
|
+
if (mode === 'graceful') { entered.resolve(); return graceful.promise; }
|
|
218
|
+
return 'stopped';
|
|
219
|
+
} });
|
|
220
|
+
await s.start();
|
|
221
|
+
const job = await s.launch();
|
|
222
|
+
const lease = s.claim([job.id]);
|
|
223
|
+
await s.panel();
|
|
224
|
+
const first = s.controls.stop(job.id, 'graceful');
|
|
225
|
+
const failed = assert.rejects(first, /Force kill/);
|
|
226
|
+
await entered.promise;
|
|
227
|
+
const force = s.tools.get('bash').execute('kill', { job: `${job.id}:kill` }, undefined, undefined, s.ctx);
|
|
228
|
+
assert.equal(s.stopped.length, 1);
|
|
229
|
+
graceful.resolve('stuck');
|
|
230
|
+
await failed;
|
|
231
|
+
await force;
|
|
232
|
+
assert.deepEqual(s.stopped.map(call => call.mode), ['graceful', 'force']);
|
|
233
|
+
assert.equal((await lease.result)[0].details.exit, 'force killed by agent');
|
|
234
|
+
assert.equal(s.messages.length, 0);
|
|
235
|
+
lease.commit();
|
|
236
|
+
await s.shutdown();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('force stop is explicit, recorded without idle wakeup and never masquerades as graceful', async () => {
|
|
240
|
+
const s = setup();
|
|
241
|
+
await s.start();
|
|
242
|
+
const job = await s.launch();
|
|
243
|
+
await s.panel();
|
|
244
|
+
await s.controls.stop(job.id, 'force');
|
|
245
|
+
assert.equal(s.stopped[0].mode, 'force');
|
|
246
|
+
assert.equal(s.controls.list()[0].state, 'done, force killed by user');
|
|
247
|
+
assert.equal(s.messages[0].options.triggerTurn, false);
|
|
248
|
+
await s.shutdown();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('failed or unverifiable stops retain jobs and allow safe retry', async () => {
|
|
252
|
+
let attempt = 0;
|
|
253
|
+
const s = setup({ stop: async () => ++attempt === 1 ? 'stuck' : 'stopped' });
|
|
254
|
+
await s.start();
|
|
255
|
+
const job = await s.launch();
|
|
256
|
+
await s.panel();
|
|
257
|
+
await assert.rejects(s.controls.stop(job.id), /still running/);
|
|
258
|
+
s.files.set(job.statusPath, '143'); // The launcher exited, but a child survived cleanup.
|
|
259
|
+
for (const timer of s.intervals) timer.callback();
|
|
260
|
+
assert.equal(s.controls.list()[0].running, true);
|
|
261
|
+
assert.equal(s.controls.list()[0].state, 'cleanup incomplete');
|
|
262
|
+
assert.equal(s.messages.length, 0);
|
|
263
|
+
await s.controls.stop(job.id, 'force');
|
|
264
|
+
assert.equal(s.stopped.length, 2);
|
|
265
|
+
assert.deepEqual(s.stopped.map(call => call.mode), ['graceful', 'force']);
|
|
266
|
+
await s.shutdown();
|
|
267
|
+
|
|
268
|
+
const unavailable = setup({ capture: async () => { throw new Error('access denied'); } });
|
|
269
|
+
await unavailable.start();
|
|
270
|
+
const unknown = await unavailable.launch();
|
|
271
|
+
await unavailable.panel();
|
|
272
|
+
await assert.rejects(unavailable.controls.stop(unknown.id), /access denied/);
|
|
273
|
+
assert.equal(unavailable.stopped.length, 0);
|
|
274
|
+
assert.match(unavailable.controls.list()[0].state, /stop unavailable/);
|
|
275
|
+
await unavailable.shutdown();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('completed commands can clean known children without changing or duplicating their wait result', async () => {
|
|
279
|
+
let children = true;
|
|
280
|
+
const s = setup({ hasChildren: () => children, stop: async () => { children = false; return 'stopped'; } });
|
|
281
|
+
await s.start();
|
|
282
|
+
const job = await s.launch();
|
|
283
|
+
const lease = s.claim([job.id]);
|
|
284
|
+
s.files.set(job.statusPath, '0');
|
|
285
|
+
for (const timer of s.intervals) timer.callback();
|
|
286
|
+
assert.equal((await lease.result)[0].details.exit, '0');
|
|
287
|
+
await s.panel();
|
|
288
|
+
assert.equal(s.controls.list()[0].running, false);
|
|
289
|
+
assert.equal(s.controls.list()[0].stoppable, true);
|
|
290
|
+
assert.match(s.controls.list()[0].state, /child cleanup available/);
|
|
291
|
+
const cleanup = await s.tools.get('bash').execute('cleanup', { job: `${job.id}:kill` }, undefined, undefined, s.ctx);
|
|
292
|
+
assert.match(cleanup.content[0].text, /command result is unchanged/);
|
|
293
|
+
assert.equal(s.controls.list()[0].stoppable, false);
|
|
294
|
+
assert.equal((await lease.result)[0].details.exit, '0');
|
|
295
|
+
assert.equal(s.messages.length, 0);
|
|
296
|
+
lease.commit();
|
|
297
|
+
await s.shutdown();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('history trimming retains completed commands with unverified child cleanup', async () => {
|
|
301
|
+
const s = setup({ hasChildren: () => true });
|
|
302
|
+
await s.start();
|
|
303
|
+
for (let i = 0; i < 23; i++) {
|
|
304
|
+
const job = await s.launch();
|
|
305
|
+
const lease = s.claim([job.id]);
|
|
306
|
+
s.files.set(job.statusPath, '0');
|
|
307
|
+
for (const timer of s.intervals) timer.callback();
|
|
308
|
+
await lease.result;
|
|
309
|
+
lease.commit();
|
|
310
|
+
}
|
|
311
|
+
await s.panel();
|
|
312
|
+
assert.equal(s.controls.list().length, 23);
|
|
313
|
+
assert.ok(s.controls.list().every(job => job.stoppable && !job.running));
|
|
314
|
+
await s.shutdown();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('already-finished jobs receive no stop signal', async () => {
|
|
318
|
+
const s = setup();
|
|
319
|
+
await s.start();
|
|
320
|
+
const job = await s.launch();
|
|
321
|
+
s.files.set(job.statusPath, '0');
|
|
322
|
+
await s.panel();
|
|
323
|
+
assert.match(await s.controls.stop(job.id), /already finished/);
|
|
324
|
+
assert.equal(s.stopped.length, 0);
|
|
325
|
+
await s.shutdown();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('shutdown closes panel scope and drains only previously requested stops', async () => {
|
|
329
|
+
const operation = deferred();
|
|
330
|
+
const s = setup({ stop: () => operation.promise });
|
|
331
|
+
await s.start();
|
|
332
|
+
const job = await s.launch();
|
|
333
|
+
await s.launch();
|
|
334
|
+
await s.panel();
|
|
335
|
+
const stopping = s.controls.stop(job.id);
|
|
336
|
+
let drained = false;
|
|
337
|
+
const shutdown = s.shutdown().then(() => { drained = true; });
|
|
338
|
+
await Promise.resolve();
|
|
339
|
+
assert.equal(s.panelSignal.aborted, true);
|
|
340
|
+
assert.equal(drained, false);
|
|
341
|
+
operation.resolve('stopped');
|
|
342
|
+
await Promise.all([stopping, shutdown]);
|
|
343
|
+
assert.equal(s.stopped.length, 1);
|
|
344
|
+
assert.equal(s.messages.length, 0);
|
|
345
|
+
assert.equal(s.intervals.size, 0);
|
|
346
|
+
await assert.rejects(s.controls.stop(job.id), /no longer available/);
|
|
347
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { once } from 'node:events';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { loadExtension } from '../../../test/load-extension.mjs';
|
|
10
|
+
|
|
11
|
+
// Opt in: this launches and terminates only this test's disposable Node tree.
|
|
12
|
+
// PI_BG_NATIVE_TEST=1 node --test packages/pi-bg-bash/test/native-process-control.test.mjs
|
|
13
|
+
test('native stop retires a disposable parent and child process', {
|
|
14
|
+
skip: process.env.PI_BG_NATIVE_TEST !== '1', timeout: 90_000,
|
|
15
|
+
}, async () => {
|
|
16
|
+
const { captureProcess, stopProcessTree } = loadExtension(fileURLToPath(new URL('../src/lib/process-control.ts', import.meta.url)));
|
|
17
|
+
const childCode = "process.on('SIGTERM', () => {}); setTimeout(() => {}, 60000)";
|
|
18
|
+
const root = spawn(process.execPath, ['-e', `
|
|
19
|
+
const { spawn } = require('node:child_process');
|
|
20
|
+
const child = spawn(process.execPath, ['-e', ${JSON.stringify(childCode)}], { stdio: 'ignore', windowsHide: true });
|
|
21
|
+
child.once('spawn', () => console.log(JSON.stringify({ child: child.pid })));
|
|
22
|
+
process.on('SIGTERM', () => {});
|
|
23
|
+
setTimeout(() => {}, 60000);
|
|
24
|
+
`], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
25
|
+
const exited = once(root, 'exit');
|
|
26
|
+
let identity, childIdentity, childPid;
|
|
27
|
+
try {
|
|
28
|
+
const [chunk] = await once(root.stdout, 'data');
|
|
29
|
+
childPid = JSON.parse(chunk.toString().trim()).child;
|
|
30
|
+
identity = await captureProcess(root.pid);
|
|
31
|
+
childIdentity = await captureProcess(childPid);
|
|
32
|
+
const started = performance.now();
|
|
33
|
+
assert.equal(await stopProcessTree(identity, 'force'), 'stopped');
|
|
34
|
+
console.log(`native force: ${Math.round(performance.now() - started)}ms including discovery and verification`);
|
|
35
|
+
await exited;
|
|
36
|
+
await assert.rejects(captureProcess(childPid), /gone/);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('native fixture failed before cleanup:', error);
|
|
39
|
+
throw error;
|
|
40
|
+
} finally {
|
|
41
|
+
for (const captured of [childIdentity, identity].filter(Boolean)) {
|
|
42
|
+
try { process.kill(captured.pid, 0); await stopProcessTree(captured, 'force'); } catch { /* Already exited or cleanup failed; fixtures self-expire. */ }
|
|
43
|
+
}
|
|
44
|
+
// ChildProcess retains the directly spawned fixture handle if identity capture failed.
|
|
45
|
+
if (root.exitCode === null && root.signalCode === null) root.kill('SIGKILL');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
for (const name of process.platform === 'win32' ? ['bash', 'powershell'] : ['bash']) {
|
|
50
|
+
test(`native ${name} console supports graceful Ctrl+C and separate force kill through job controls`, {
|
|
51
|
+
skip: process.env.PI_BG_NATIVE_TEST !== '1', timeout: 240_000,
|
|
52
|
+
}, async () => {
|
|
53
|
+
const host = await import('@earendil-works/pi-coding-agent');
|
|
54
|
+
const tools = new Map(), hooks = new Map(), commands = new Map();
|
|
55
|
+
const folder = fs.mkdtempSync(join(tmpdir(), 'pi bg native-'));
|
|
56
|
+
const script = join(folder, 'preview.cjs');
|
|
57
|
+
fs.writeFileSync(script, `
|
|
58
|
+
const fs = require('node:fs');
|
|
59
|
+
const { spawn } = require('node:child_process');
|
|
60
|
+
const marker = process.argv[2];
|
|
61
|
+
const safety = setTimeout(() => process.exit(91), 90000);
|
|
62
|
+
process.on('SIGINT', () => setTimeout(() => {
|
|
63
|
+
fs.writeFileSync(marker, 'SIGINT cleanup completed');
|
|
64
|
+
clearTimeout(safety);
|
|
65
|
+
}, 200));
|
|
66
|
+
const code = "const fs=require('node:fs'); const t=setTimeout(()=>process.exit(91),90000); process.on('SIGINT',()=>setTimeout(()=>{fs.writeFileSync(process.argv[1], 'SIGINT child cleanup completed');clearTimeout(t);},100)); console.log('child-ready');";
|
|
67
|
+
const child = spawn(process.execPath, ['-e', code, marker + '.child'], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
68
|
+
child.stdout.once('data', () => {
|
|
69
|
+
fs.writeFileSync(marker + '.ready', String(child.pid));
|
|
70
|
+
console.log('native-preview-ready child=' + child.pid);
|
|
71
|
+
});
|
|
72
|
+
`);
|
|
73
|
+
let mode = 'force';
|
|
74
|
+
const { default: extension } = loadExtension(fileURLToPath(new URL('../src/index.ts', import.meta.url)), {
|
|
75
|
+
'@earendil-works/pi-coding-agent': host,
|
|
76
|
+
'@earendil-works/pi-tui': {},
|
|
77
|
+
'node:os': { tmpdir: () => folder, homedir: () => folder },
|
|
78
|
+
'node:fs': { ...fs, readdirSync: () => [] },
|
|
79
|
+
'./lib/panel': { showJobPanel: async (_ctx, api) => {
|
|
80
|
+
const job = api.list().find(job => job.running);
|
|
81
|
+
assert.ok(job, 'launched job appears in /bg');
|
|
82
|
+
assert.match(await api.stop(job.id, mode), /tracked process tree is gone/);
|
|
83
|
+
} },
|
|
84
|
+
}, { crypto: globalThis.crypto });
|
|
85
|
+
const ctx = { cwd: folder, hasUI: false, isIdle: () => true, hasPendingMessages: () => false,
|
|
86
|
+
sessionManager: { getSessionFile: () => undefined, getBranch: () => [], getSessionId: () => 'native-test' },
|
|
87
|
+
ui: { notify() {} },
|
|
88
|
+
};
|
|
89
|
+
extension({ registerTool: tool => tools.set(tool.name, tool), registerToolRenderer() {}, registerMessageRenderer() {},
|
|
90
|
+
registerCommand: (name, options) => commands.set(name, options.handler),
|
|
91
|
+
on: (name, fn) => hooks.set(name, fn), events: { on() {} }, getAllTools: () => [{ name: 'powershell' }],
|
|
92
|
+
sendMessage: (_message, options) => assert.equal(options.triggerTurn, false),
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
await hooks.get('session_start')({}, ctx);
|
|
96
|
+
for (mode of ['graceful', 'force']) {
|
|
97
|
+
const marker = join(folder, `${name}-${mode}.marker`);
|
|
98
|
+
let watcher, timer;
|
|
99
|
+
const ready = new Promise((resolve, reject) => {
|
|
100
|
+
watcher = fs.watch(folder, () => { if (fs.existsSync(marker + '.ready')) resolve(); });
|
|
101
|
+
timer = setTimeout(() => reject(new Error(`${name} fixture did not become ready`)), 15000);
|
|
102
|
+
});
|
|
103
|
+
void ready.catch(() => {});
|
|
104
|
+
try {
|
|
105
|
+
const command = `node "${script.replace(/\\/g, '/')}" "${marker.replace(/\\/g, '/')}"`;
|
|
106
|
+
const launch = await tools.get(name).execute('native-launch', { command, background: true }, undefined, undefined, ctx);
|
|
107
|
+
if (!fs.existsSync(marker + '.ready')) await ready;
|
|
108
|
+
const childPid = Number(fs.readFileSync(marker + '.ready', 'utf8'));
|
|
109
|
+
const started = performance.now();
|
|
110
|
+
await commands.get('bg')('', ctx);
|
|
111
|
+
console.log(`native ${name} ${mode}: ${Math.round(performance.now() - started)}ms`);
|
|
112
|
+
const result = await tools.get(name).execute('native-query', { job: String(launch.details.bgJob) }, undefined, undefined, ctx);
|
|
113
|
+
assert.match(result.content[0].text, mode === 'graceful' ? /stopped gracefully by user/ : /force killed by user/);
|
|
114
|
+
assert.equal(fs.existsSync(marker), mode === 'graceful', 'only graceful stop runs parent cleanup');
|
|
115
|
+
assert.equal(fs.existsSync(marker + '.child'), mode === 'graceful', 'only graceful stop runs child cleanup');
|
|
116
|
+
const { captureProcess } = loadExtension(fileURLToPath(new URL('../src/lib/process-control.ts', import.meta.url)));
|
|
117
|
+
await assert.rejects(captureProcess(childPid), /gone/, 'no live orphaned child remains');
|
|
118
|
+
} finally { watcher?.close(); clearTimeout(timer); }
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
await hooks.get('session_shutdown')({ reason: 'quit' }, ctx);
|
|
122
|
+
try { fs.rmSync(folder, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }
|
|
123
|
+
catch (error) { console.error('Native fixture directory still locked:', folder, error.code); }
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { loadExtension } from '../../../test/load-extension.mjs';
|
|
8
|
+
|
|
9
|
+
const { cleanOutput, oneLine, readTail } = loadExtension(fileURLToPath(new URL('../src/lib/output.ts', import.meta.url)));
|
|
10
|
+
|
|
11
|
+
test('output removes terminal controls, preserves text, and command labels stay on one line', () => {
|
|
12
|
+
assert.equal(cleanOutput('\x1b[31mred\x1b[0m\x1b]0;bad title\x07\x00\r\nnext\tcell'), 'red\nnext cell');
|
|
13
|
+
assert.equal(oneLine('npm\nrun\tdev\x1b[2J'), 'npm run dev');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('tail reads cap both bytes and lines, even for a giant single line', () => {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'pi-bg-output-test-'));
|
|
18
|
+
try {
|
|
19
|
+
const file = join(root, 'out');
|
|
20
|
+
writeFileSync(file, 'one\ntwo\nthree\n');
|
|
21
|
+
assert.equal(readTail(file, 3), '(earlier output omitted)\ntwo\nthree');
|
|
22
|
+
writeFileSync(file, 'x'.repeat(1024 * 1024));
|
|
23
|
+
const tail = readTail(file, 2000, 1024);
|
|
24
|
+
assert.match(tail, /^\(earlier output omitted\)/);
|
|
25
|
+
assert.ok(tail.length < 1100);
|
|
26
|
+
assert.equal(readTail(join(root, 'absent')), '(no output yet)');
|
|
27
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
28
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as tui from '@earendil-works/pi-tui';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { loadExtension } from '../../../test/load-extension.mjs';
|
|
6
|
+
|
|
7
|
+
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
|
|
8
|
+
|
|
9
|
+
function setup({ mode = 'tui', hasUI = true, confirm = true, answers = [] } = {}) {
|
|
10
|
+
const timers = new Set(), notifications = [], stopped = [], confirmations = [], selections = [...answers];
|
|
11
|
+
const { showJobPanel } = loadExtension(fileURLToPath(new URL('../src/lib/panel.ts', import.meta.url)), {
|
|
12
|
+
'@earendil-works/pi-tui': tui,
|
|
13
|
+
}, {
|
|
14
|
+
setInterval: callback => { const timer = { callback }; timers.add(timer); return timer; },
|
|
15
|
+
clearInterval: timer => timers.delete(timer),
|
|
16
|
+
});
|
|
17
|
+
const jobs = [{ id: 2, tool: 'bash', command: 'npm run dev', state: 'running', secs: 10, running: true, stoppable: true },
|
|
18
|
+
{ id: 1, tool: 'powershell', command: 'npm test', state: 'done', secs: 4, running: false, stoppable: false }];
|
|
19
|
+
let component, output = 'line 1\nline 2\nline 3\nline 4';
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const ctx = { mode, hasUI, ui: {
|
|
22
|
+
notify: (...args) => notifications.push(args),
|
|
23
|
+
confirm: async (...args) => { confirmations.push(args); return confirm; },
|
|
24
|
+
select: async (_title, options) => { const answer = selections.shift(); return typeof answer === 'number' ? options[answer] : answer; },
|
|
25
|
+
custom: factory => new Promise(resolve => {
|
|
26
|
+
component = factory({ terminal: { rows: 8 }, requestRender() {} },
|
|
27
|
+
{ fg: (_color, text) => text }, {}, value => { component?.dispose?.(); resolve(value); });
|
|
28
|
+
}),
|
|
29
|
+
} };
|
|
30
|
+
const controls = { list: () => jobs, output: () => output, stop: async (id, mode) => { stopped.push([id, mode]); jobs[0].running = false; return 'stopped'; } };
|
|
31
|
+
const result = showJobPanel(ctx, controls, controller.signal);
|
|
32
|
+
return { result, jobs, timers, stopped, confirmations, notifications, selections, controller,
|
|
33
|
+
get component() { return component; }, get output() { return output; }, set output(value) { output = value; },
|
|
34
|
+
refresh: () => { for (const timer of timers) timer.callback(); },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('x confirms before stopping; refusing or closing never stops a job', async () => {
|
|
39
|
+
for (const confirm of [true, false]) {
|
|
40
|
+
const s = setup({ confirm });
|
|
41
|
+
s.component.handleInput('x');
|
|
42
|
+
await flush();
|
|
43
|
+
assert.equal(s.confirmations.length, 1);
|
|
44
|
+
assert.deepEqual(s.stopped, confirm ? [[2, 'force']] : []);
|
|
45
|
+
assert.match(s.confirmations[0][0], /Force kill/);
|
|
46
|
+
assert.match(s.confirmations[0][1], /Cleanup hooks will not run/);
|
|
47
|
+
s.component.handleInput('\x1b');
|
|
48
|
+
await s.result;
|
|
49
|
+
assert.equal(s.timers.size, 0);
|
|
50
|
+
}
|
|
51
|
+
const s = setup();
|
|
52
|
+
s.component.handleInput('q');
|
|
53
|
+
await s.result;
|
|
54
|
+
assert.equal(s.stopped.length, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('s requests graceful stopping, independently confirmed and without escalation', async () => {
|
|
58
|
+
for (const confirm of [true, false]) {
|
|
59
|
+
const s = setup({ confirm });
|
|
60
|
+
s.component.handleInput('s');
|
|
61
|
+
await flush();
|
|
62
|
+
assert.match(s.confirmations[0][0], /Gracefully stop/);
|
|
63
|
+
assert.match(s.confirmations[0][1], /no automatic escalation/);
|
|
64
|
+
assert.deepEqual(s.stopped, confirm ? [[2, 'graceful']] : []);
|
|
65
|
+
s.component.handleInput('q');
|
|
66
|
+
await s.result;
|
|
67
|
+
assert.equal(s.timers.size, 0);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('list refresh preserves the selected job as rows change', async () => {
|
|
72
|
+
const s = setup();
|
|
73
|
+
s.component.handleInput('\x1b[B');
|
|
74
|
+
s.jobs.unshift({ id: 3, tool: 'bash', command: 'new', state: 'running', secs: 1, running: true, stoppable: true });
|
|
75
|
+
s.refresh();
|
|
76
|
+
s.component.handleInput('x');
|
|
77
|
+
await flush();
|
|
78
|
+
assert.equal(s.confirmations.length, 0, 'selected completed job is still selected');
|
|
79
|
+
assert.match(s.notifications.at(-1)[0], /#1 already finished/);
|
|
80
|
+
s.component.handleInput('q');
|
|
81
|
+
await s.result;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('output follows the bottom, preserves scroll-up, and all rendered lines fit narrow widths', async () => {
|
|
85
|
+
const s = setup();
|
|
86
|
+
s.component.handleInput('\r');
|
|
87
|
+
await flush();
|
|
88
|
+
let lines = s.component.render(40).map(line => line.trimEnd());
|
|
89
|
+
assert.ok(lines.includes('line 4'));
|
|
90
|
+
s.component.handleInput('\x1b[H');
|
|
91
|
+
lines = s.component.render(40).map(line => line.trimEnd());
|
|
92
|
+
assert.ok(lines.includes('line 1'));
|
|
93
|
+
s.output = 'line 1\nline 2\nline 3\nline 4\nline 5';
|
|
94
|
+
s.refresh();
|
|
95
|
+
lines = s.component.render(40).map(line => line.trimEnd());
|
|
96
|
+
assert.ok(lines.includes('line 1'));
|
|
97
|
+
assert.ok(!lines.includes('line 5'));
|
|
98
|
+
s.component.handleInput('\x1b[F');
|
|
99
|
+
s.component.render(40);
|
|
100
|
+
s.output += '\nline 6';
|
|
101
|
+
s.refresh();
|
|
102
|
+
assert.ok(s.component.render(40).some(line => line.trimEnd() === 'line 6'));
|
|
103
|
+
s.output = '界'.repeat(100);
|
|
104
|
+
s.refresh();
|
|
105
|
+
assert.ok(s.component.render(9).every(line => tui.visibleWidth(line) <= 9));
|
|
106
|
+
s.component.handleInput('\x1b');
|
|
107
|
+
await flush();
|
|
108
|
+
assert.equal(s.timers.size, 1, 'only the reopened list refreshes');
|
|
109
|
+
s.controller.abort();
|
|
110
|
+
await s.result;
|
|
111
|
+
assert.equal(s.timers.size, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('session invalidation closes an open output view and clears its timer', async () => {
|
|
115
|
+
const s = setup();
|
|
116
|
+
s.component.handleInput('\r');
|
|
117
|
+
await flush();
|
|
118
|
+
s.controller.abort();
|
|
119
|
+
await s.result;
|
|
120
|
+
assert.equal(s.timers.size, 0);
|
|
121
|
+
assert.equal(s.stopped.length, 0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('non-UI mode is list-only, and RPC uses explicit view/stop choices', async () => {
|
|
125
|
+
const noUI = setup({ mode: 'print', hasUI: false });
|
|
126
|
+
await noUI.result;
|
|
127
|
+
assert.match(noUI.notifications[0][0], /npm run dev/);
|
|
128
|
+
assert.equal(noUI.stopped.length, 0);
|
|
129
|
+
assert.equal(noUI.timers.size, 0);
|
|
130
|
+
|
|
131
|
+
const rpc = setup({ mode: 'rpc', answers: [0, 'View output', 0, 'Stop gracefully'] });
|
|
132
|
+
await rpc.result;
|
|
133
|
+
assert.match(rpc.notifications[0][0], /line 1/);
|
|
134
|
+
assert.deepEqual(rpc.stopped, [[2, 'graceful']]);
|
|
135
|
+
assert.equal(rpc.confirmations.length, 1);
|
|
136
|
+
assert.equal(rpc.timers.size, 0);
|
|
137
|
+
|
|
138
|
+
const force = setup({ mode: 'rpc', answers: [0, 'Force kill'] });
|
|
139
|
+
await force.result;
|
|
140
|
+
assert.deepEqual(force.stopped, [[2, 'force']]);
|
|
141
|
+
assert.match(force.confirmations[0][0], /Force kill/);
|
|
142
|
+
|
|
143
|
+
const cancelled = setup({ mode: 'rpc' });
|
|
144
|
+
await cancelled.result;
|
|
145
|
+
assert.equal(cancelled.stopped.length, 0);
|
|
146
|
+
});
|