@saifymatteo/pi-extensions 0.1.4 → 0.1.6

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 (62) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +2 -1
  3. package/packages/pi-bg-bash/AGENTS.md +29 -0
  4. package/packages/pi-bg-bash/CHANGELOG.md +25 -0
  5. package/packages/pi-bg-bash/package.json +2 -1
  6. package/packages/pi-bg-bash/src/index.ts +189 -99
  7. package/packages/pi-bg-bash/src/lib/output.ts +31 -0
  8. package/packages/pi-bg-bash/src/lib/panel.ts +155 -0
  9. package/packages/pi-bg-bash/src/lib/process-control.ts +221 -0
  10. package/packages/pi-bg-bash/src/lib/windows-console.ts +169 -0
  11. package/packages/pi-bg-bash/test/job-controls.test.mjs +347 -0
  12. package/packages/pi-bg-bash/test/native-process-control.test.mjs +126 -0
  13. package/packages/pi-bg-bash/test/output.test.mjs +28 -0
  14. package/packages/pi-bg-bash/test/panel.test.mjs +146 -0
  15. package/packages/pi-bg-bash/test/process-control.test.mjs +300 -0
  16. package/packages/pi-bg-bash/test/windows-console.test.mjs +221 -0
  17. package/packages/pi-claude-usage/CHANGELOG.md +18 -0
  18. package/packages/pi-claude-usage/package.json +2 -1
  19. package/packages/pi-clipboard-image/CHANGELOG.md +18 -0
  20. package/packages/pi-clipboard-image/package.json +2 -1
  21. package/packages/pi-codeblock-borders/CHANGELOG.md +18 -0
  22. package/packages/pi-codeblock-borders/package.json +2 -1
  23. package/packages/pi-codegraph/CHANGELOG.md +25 -0
  24. package/packages/pi-codegraph/package.json +2 -1
  25. package/packages/pi-compact-tools/CHANGELOG.md +24 -0
  26. package/packages/pi-compact-tools/package.json +2 -1
  27. package/packages/pi-compact-tools/src/index.ts +18 -8
  28. package/packages/pi-compact-tools/test/behavior.test.mjs +131 -0
  29. package/packages/pi-decisions/CHANGELOG.md +18 -0
  30. package/packages/pi-decisions/package.json +2 -1
  31. package/packages/pi-load-pi-dotenv/CHANGELOG.md +18 -0
  32. package/packages/pi-load-pi-dotenv/package.json +2 -1
  33. package/packages/pi-model-go/CHANGELOG.md +18 -0
  34. package/packages/pi-model-go/package.json +2 -1
  35. package/packages/pi-prestart-working/CHANGELOG.md +18 -0
  36. package/packages/pi-prestart-working/package.json +2 -1
  37. package/packages/pi-root-guard/CHANGELOG.md +18 -0
  38. package/packages/pi-root-guard/package.json +2 -1
  39. package/packages/pi-session-finder/CHANGELOG.md +18 -0
  40. package/packages/pi-session-finder/package.json +2 -1
  41. package/packages/pi-session-id/CHANGELOG.md +18 -0
  42. package/packages/pi-session-id/package.json +2 -1
  43. package/packages/pi-session-nav/CHANGELOG.md +18 -0
  44. package/packages/pi-session-nav/package.json +2 -1
  45. package/packages/pi-session-rename/CHANGELOG.md +18 -0
  46. package/packages/pi-session-rename/package.json +2 -1
  47. package/packages/pi-session-retention/CHANGELOG.md +25 -0
  48. package/packages/pi-session-retention/package.json +2 -1
  49. package/packages/pi-skill-usage-log/CHANGELOG.md +18 -0
  50. package/packages/pi-skill-usage-log/package.json +2 -1
  51. package/packages/pi-tool-usage-log/CHANGELOG.md +18 -0
  52. package/packages/pi-tool-usage-log/package.json +2 -1
  53. package/packages/pi-tts/CHANGELOG.md +18 -0
  54. package/packages/pi-tts/package.json +2 -1
  55. package/packages/pi-user-image-previews/CHANGELOG.md +18 -0
  56. package/packages/pi-user-image-previews/package.json +2 -1
  57. package/packages/pi-vision/CHANGELOG.md +18 -0
  58. package/packages/pi-vision/package.json +2 -1
  59. package/packages/pi-wait/CHANGELOG.md +18 -0
  60. package/packages/pi-wait/package.json +2 -1
  61. package/packages/pi-zvec/CHANGELOG.md +25 -0
  62. package/packages/pi-zvec/package.json +2 -1
@@ -0,0 +1,300 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { loadExtension } from '../../../test/load-extension.mjs';
4
+
5
+ const file = new URL('../src/lib/process-control.ts', import.meta.url);
6
+ const birth = 'Mon Jan 5 12:00:00 2026 UTC';
7
+ const later = 'Mon Jan 5 12:00:01 2026 UTC';
8
+ const winBirth = '2026-01-05T12:00:00.0000000Z';
9
+ const winLater = '2026-01-05T12:00:01.0000000Z';
10
+
11
+ function fixture({ windows = false, onRead, onSignal, onWait, output, commandError, consoleError } = {}) {
12
+ const started = windows ? winBirth : birth;
13
+ const rows = new Map([
14
+ [900, { pid: 900, ppid: 1, started }], // fake harness
15
+ [100, { pid: 100, ppid: 1, started }],
16
+ [101, { pid: 101, ppid: 100, started }],
17
+ [102, { pid: 102, ppid: 101, started }],
18
+ [500, { pid: 500, ppid: 1, started }], // unrelated
19
+ ]);
20
+ const calls = [], signals = [], waits = [];
21
+ let reads = 0;
22
+ const emitSignal = (pid, signal) => {
23
+ signals.push([pid, signal]);
24
+ if (onSignal) onSignal({ pid, signal, force: signal === 'SIGKILL', rows });
25
+ else if (signal === 'SIGKILL') rows.delete(pid);
26
+ };
27
+ const fakeProcess = {
28
+ platform: windows ? 'win32' : 'linux', pid: 900, env: {},
29
+ kill(pid, signal) {
30
+ assert.equal(windows, false, 'Windows must not emulate graceful signals with process.kill');
31
+ assert.ok(pid > 1 && pid !== 900);
32
+ emitSignal(pid, signal);
33
+ },
34
+ };
35
+ const api = loadExtension(file, {
36
+ 'node:process': fakeProcess,
37
+ './windows-console': { windowsConsoleInterruptScript: (root, targets, ownerPid) =>
38
+ '# PI_BG_CONSOLE\n' + JSON.stringify({ root, targets, ownerPid }) },
39
+ 'node:child_process': {
40
+ execFile(command, args, options, callback) {
41
+ calls.push({ command, args, options });
42
+ try {
43
+ if (commandError) throw commandError;
44
+ const script = args.at(-1);
45
+ if (windows && script.includes('# PI_BG_CONSOLE')) {
46
+ if (consoleError) throw consoleError;
47
+ const { targets, ownerPid } = JSON.parse(script.split('# PI_BG_CONSOLE\n')[1]);
48
+ assert.equal(ownerPid, 900);
49
+ for (const target of targets.reverse()) {
50
+ if (rows.get(target.pid)?.started === target.started) emitSignal(target.pid, 'SIGINT');
51
+ }
52
+ callback(null, '', '');
53
+ return;
54
+ }
55
+ if (windows && script.includes('-ceq')) {
56
+ for (const match of script.matchAll(/GetProcessById\((\d+)\)[\s\S]*?-ceq '([^']+)'/g)) {
57
+ const pid = Number(match[1]);
58
+ if (rows.get(pid)?.started.replace(/(\.\d{6})\dZ$/, '$1Z') === match[2]) emitSignal(pid, 'SIGKILL');
59
+ }
60
+ callback(null, '', '');
61
+ return;
62
+ }
63
+ reads++;
64
+ onRead?.({ reads, rows });
65
+ const text = output ?? (windows ? JSON.stringify([...rows.values()]) : [...rows.values()].map(row =>
66
+ `${row.pid} ${row.ppid} ${row.started.replace(/ UTC$/, '')} ${row.dead ? 'Z' : 'S'}`).join('\n'));
67
+ callback(null, text, '');
68
+ } catch (error) { callback(error, '', ''); }
69
+ },
70
+ },
71
+ }, {
72
+ process: fakeProcess,
73
+ setTimeout(callback, ms) {
74
+ waits.push(ms);
75
+ onWait?.({ ms, rows });
76
+ queueMicrotask(callback);
77
+ return waits.length;
78
+ },
79
+ });
80
+ return { ...api, rows, calls, signals, waits, identity: { pid: 100, started } };
81
+ }
82
+
83
+ for (const windows of [false, true]) {
84
+ const platform = windows ? 'Windows' : 'Unix';
85
+ test(`${platform}: captures birth identity; validates PID, identity and mode before OS calls`, async () => {
86
+ const f = fixture({ windows });
87
+ for (const pid of [0, 1, -10, 900, 1.5, NaN, Infinity, '100']) {
88
+ await assert.rejects(f.captureProcess(pid), /Unsafe/);
89
+ await assert.rejects(f.stopProcessTree({ pid, started: birth }), /Unsafe/);
90
+ }
91
+ await assert.rejects(f.stopProcessTree({ pid: 100, started: '' }), /identity/);
92
+ await assert.rejects(f.stopProcessTree(f.identity, 'auto'), /mode/);
93
+ assert.equal(f.calls.length, 0);
94
+ const controller = new AbortController();
95
+ assert.deepEqual({ ...await f.captureProcess(100, controller.signal) }, f.identity);
96
+ assert.equal(f.calls[0].options.signal, controller.signal);
97
+ await assert.rejects(f.captureProcess(333), /gone/);
98
+ assert.equal(f.signals.length, 0);
99
+ });
100
+
101
+ test(`${platform}: distinct force action skips grace and kills only the captured tree`, async () => {
102
+ const f = fixture({ windows });
103
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'stopped');
104
+ assert.deepEqual(f.signals, [[102, 'SIGKILL'], [101, 'SIGKILL'], [100, 'SIGKILL']]);
105
+ assert.deepEqual(f.waits, [100]);
106
+ assert.equal(f.rows.has(500), true);
107
+ for (const call of f.calls) {
108
+ assert.ok(Array.isArray(call.args));
109
+ assert.equal(call.options.timeout, 15000);
110
+ assert.equal(call.options.env.LC_ALL, 'C');
111
+ assert.equal(call.options.shell, undefined);
112
+ }
113
+ if (windows) {
114
+ const scripts = f.calls.map(call => call.args.at(-1));
115
+ assert.equal(scripts.filter(script => script.includes('GetProcessById')).length, 1);
116
+ assert.equal(scripts.filter(script => script.includes('CreationDate')).length, 2, 'discovery and final verification only');
117
+ assert.ok(scripts.some(script => script.includes('$p.Kill()') && script.includes('$p.Handle')));
118
+ assert.ok(scripts.every(script => !/CloseMainWindow|taskkill|Stop-Process|\/T|node\.exe/.test(script)));
119
+ } else {
120
+ assert.deepEqual(Array.from(f.calls[0].args), ['-A', '-o', 'pid=', '-o', 'ppid=', '-o', 'lstart=', '-o', 'stat=']);
121
+ assert.equal(f.calls[0].command, 'ps');
122
+ }
123
+ });
124
+
125
+ test(`${platform}: distinct graceful action interrupts but never escalates survivors`, async () => {
126
+ const f = fixture({ windows });
127
+ assert.equal(await f.stopProcessTree(f.identity, 'graceful'), 'stuck');
128
+ assert.deepEqual(f.signals, [[102, 'SIGINT'], [101, 'SIGINT'], [100, 'SIGINT']]);
129
+ assert.deepEqual(f.waits, [3000]);
130
+ assert.equal(f.rows.has(100), true);
131
+ assert.equal(f.rows.has(500), true);
132
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'stopped');
133
+ assert.equal(f.rows.has(102), false);
134
+ });
135
+
136
+ test(`${platform}: retry reports already gone when all previously retained processes exited naturally`, async () => {
137
+ const f = fixture({ windows });
138
+ assert.equal(await f.stopProcessTree(f.identity, 'graceful'), 'stuck');
139
+ for (const pid of [100, 101, 102]) f.rows.delete(pid);
140
+ f.signals.length = 0;
141
+ f.waits.length = 0;
142
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'gone');
143
+ assert.deepEqual(f.signals, []);
144
+ assert.deepEqual(f.waits, []);
145
+ });
146
+
147
+ test(`${platform}: cooperative graceful exit is verified without force`, async () => {
148
+ const f = fixture({ windows, onSignal({ pid, signal, rows }) {
149
+ assert.equal(signal, 'SIGINT');
150
+ rows.delete(pid);
151
+ } });
152
+ assert.equal(await f.stopProcessTree(f.identity, 'graceful'), 'stopped');
153
+ assert.equal(f.signals.length, 3);
154
+ });
155
+
156
+ test(`${platform}: absent/reused root receives no signal`, async () => {
157
+ for (const reused of [false, true]) {
158
+ const f = fixture({ windows });
159
+ if (reused) f.rows.get(100).started = windows ? winLater : later;
160
+ else f.rows.delete(100);
161
+ assert.equal(await f.stopProcessTree(f.identity), 'gone');
162
+ assert.deepEqual(f.signals, []);
163
+ }
164
+ });
165
+
166
+ test(`${platform}: launch capture retains known children even if the launcher exits before any stop`, async () => {
167
+ const f = fixture({ windows });
168
+ const captured = await f.captureProcess(100);
169
+ f.rows.delete(100);
170
+ f.rows.get(101).ppid = 1;
171
+ assert.equal(await f.stopProcessTree(captured, 'force'), 'stopped');
172
+ assert.deepEqual(f.signals, [[102, 'SIGKILL'], [101, 'SIGKILL']]);
173
+ assert.equal(f.rows.has(500), true);
174
+ });
175
+
176
+ test(`${platform}: retains reparented children after graceful launcher exit for force retry`, async () => {
177
+ const f = fixture({ windows, onSignal({ pid, force, rows }) {
178
+ if (force || pid === 100) rows.delete(pid);
179
+ if (pid === 100 && !force) rows.get(101).ppid = 1;
180
+ } });
181
+ assert.equal(await f.stopProcessTree(f.identity, 'graceful'), 'stuck');
182
+ assert.equal(f.rows.has(100), false);
183
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'stopped');
184
+ assert.deepEqual(f.signals.filter(([, signal]) => signal === 'SIGKILL'), [[102, 'SIGKILL'], [101, 'SIGKILL']]);
185
+ });
186
+
187
+ test(`${platform}: records late verified descendants for retry even after reparenting`, async () => {
188
+ const f = fixture({ windows, onWait({ ms, rows }) {
189
+ if (ms === 3000) rows.set(103, { pid: 103, ppid: 102, started: windows ? winLater : later });
190
+ } });
191
+ assert.equal(await f.stopProcessTree(f.identity, 'graceful'), 'stuck');
192
+ f.rows.delete(100);
193
+ f.rows.get(101).ppid = 1;
194
+ f.rows.get(103).ppid = 1;
195
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'stopped');
196
+ assert.ok(f.signals.some(([pid, signal]) => pid === 103 && signal === 'SIGKILL'));
197
+ });
198
+
199
+ test(`${platform}: never adopts a reused descendant or its new children`, async () => {
200
+ const f = fixture({ windows });
201
+ assert.equal(await f.stopProcessTree(f.identity, 'graceful'), 'stuck');
202
+ f.rows.get(101).started = windows ? winLater : later;
203
+ f.rows.get(101).ppid = 1;
204
+ f.rows.set(103, { pid: 103, ppid: 101, started: windows ? winLater : later });
205
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'stuck');
206
+ assert.ok(!f.signals.some(([pid, signal]) => [101, 103].includes(pid) && signal === 'SIGKILL'));
207
+ });
208
+
209
+ test(`${platform}: unverified descendants after force remain untouched and report incomplete cleanup`, async () => {
210
+ const f = fixture({ windows, onWait({ rows }) {
211
+ rows.set(103, { pid: 103, ppid: 100, started: windows ? winLater : later });
212
+ } });
213
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'stuck');
214
+ assert.ok(!f.signals.some(([pid]) => pid === 103));
215
+ });
216
+
217
+ test(`${platform}: unsafe descendants and incomplete inventories fail before signaling`, async () => {
218
+ const f = fixture({ windows });
219
+ f.rows.get(900).ppid = 101;
220
+ await assert.rejects(f.stopProcessTree(f.identity), /Unsafe/);
221
+ assert.deepEqual(f.signals, []);
222
+ f.rows.delete(900);
223
+ await assert.rejects(f.stopProcessTree(f.identity), /Incomplete/);
224
+ });
225
+
226
+ test(`${platform}: enumeration failures are errors, not absence`, async () => {
227
+ const f = fixture({ windows, commandError: Object.assign(new Error('permission denied'), { code: 'EPERM' }) });
228
+ await assert.rejects(f.captureProcess(100), /permission denied/);
229
+ await assert.rejects(f.stopProcessTree(f.identity), /permission denied/);
230
+ assert.deepEqual(f.signals, []);
231
+ });
232
+
233
+ test(`${platform}: failed force retains authenticated children when only launcher exits`, async () => {
234
+ let allowChildren = false;
235
+ const f = fixture({ windows, onSignal({ pid, rows }) {
236
+ if (pid === 100 || allowChildren) rows.delete(pid);
237
+ if (pid === 100) rows.get(101).ppid = 1;
238
+ } });
239
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'stuck');
240
+ allowChildren = true;
241
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'stopped');
242
+ assert.equal(f.rows.has(500), true);
243
+ });
244
+ }
245
+
246
+ test('Unix: revalidates each identity immediately before killing', async () => {
247
+ const f = fixture({ onRead({ reads, rows }) {
248
+ if (reads === 2) { rows.get(102).started = later; rows.get(102).ppid = 1; }
249
+ } });
250
+ assert.equal(await f.stopProcessTree(f.identity), 'stopped');
251
+ assert.ok(!f.signals.some(([pid]) => pid === 102));
252
+ });
253
+
254
+ test('Unix: EPERM rejects; ESRCH races are verified with the final inventory', async () => {
255
+ for (const code of ['EPERM', 'ESRCH']) {
256
+ const f = fixture({ onSignal({ pid, rows }) {
257
+ if (code === 'ESRCH') rows.delete(pid);
258
+ throw Object.assign(new Error(code), { code });
259
+ } });
260
+ if (code === 'EPERM') await assert.rejects(f.stopProcessTree(f.identity), /EPERM/);
261
+ else assert.equal(await f.stopProcessTree(f.identity), 'stopped');
262
+ }
263
+ });
264
+
265
+ test('Unix: zombies are gone; malformed inventory fails closed', async () => {
266
+ const f = fixture();
267
+ f.rows.get(100).dead = true;
268
+ assert.equal(await f.stopProcessTree(f.identity), 'gone');
269
+ await assert.rejects(f.captureProcess(100), /gone/);
270
+ const malformed = fixture({ output: '100 1 invalid birth' });
271
+ await assert.rejects(malformed.stopProcessTree(malformed.identity), /Invalid ps/);
272
+ });
273
+
274
+ test('Windows: stale ParentProcessId cannot attach an older process to a reused root PID', async () => {
275
+ const f = fixture({ windows: true });
276
+ f.identity.started = winLater;
277
+ f.rows.get(100).started = winLater;
278
+ assert.equal(await f.stopProcessTree(f.identity), 'stopped');
279
+ assert.deepEqual(f.signals, [[100, 'SIGKILL']]);
280
+ assert.equal(f.rows.has(101), true);
281
+ });
282
+
283
+ test('Windows: unavailable console never silently falls back to force', async () => {
284
+ const f = fixture({ windows: true, consoleError: new Error('Graceful stop unavailable: shared console') });
285
+ await assert.rejects(f.stopProcessTree(f.identity, 'graceful'), /unavailable/);
286
+ assert.deepEqual(f.signals, []);
287
+ assert.equal(await f.stopProcessTree(f.identity, 'force'), 'stopped');
288
+ });
289
+
290
+ test('Windows: missing selected birth identity fails closed', async () => {
291
+ const f = fixture({ windows: true });
292
+ f.rows.get(101).started = '';
293
+ await assert.rejects(f.stopProcessTree(f.identity), /identity/);
294
+ });
295
+
296
+ test('post-signal enumeration failure cannot claim successful cleanup', async () => {
297
+ const f = fixture({ onWait({ rows }) { rows.delete(900); } });
298
+ await assert.rejects(f.stopProcessTree(f.identity, 'graceful'), /Incomplete/);
299
+ assert.ok(f.signals.every(([, signal]) => signal === 'SIGINT'));
300
+ });
@@ -0,0 +1,221 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { loadExtension } from '../../../test/load-extension.mjs';
9
+
10
+ const { windowsConsoleInterruptScript, windowsConsoleInputPrelude, windowsBashLaunchScript } = loadExtension(new URL('../src/lib/windows-console.ts', import.meta.url));
11
+ const root = { pid: 100, started: '2026-01-05T12:00:00.1234560Z' };
12
+ const child = { pid: 101, started: '2026-01-05T12:00:01.6543210Z' };
13
+ const script = () => windowsConsoleInterruptScript(root, [root, child], 900);
14
+ const exec = promisify(execFile);
15
+ const windows = process.platform === 'win32';
16
+ async function ps(source) {
17
+ return exec('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand',
18
+ Buffer.from(source, 'utf16le').toString('base64')], { timeout: 25_000, maxBuffer: 2 * 1024 * 1024, windowsHide: true });
19
+ }
20
+
21
+ test('serializes only authenticated numeric PIDs and CIM UTC birth identities', () => {
22
+ assert.match(script(), /100 = '2026-01-05T12:00:00\.123456Z'/);
23
+ for (const bad of [0, 1, -1, NaN, Infinity, 1.5, '100', 900]) {
24
+ assert.throws(() => windowsConsoleInterruptScript({ ...root, pid: bad }, [], 900), /identity/);
25
+ }
26
+ for (const started of ['', "'; Stop-Process -Id 900; '", '2026-01-05T12:00:00.1234567Z', '2026-01-05T12:00:00Z']) {
27
+ assert.throws(() => windowsConsoleInterruptScript({ ...root, started }, [], 900), /identity/);
28
+ }
29
+ assert.throws(() => windowsConsoleInterruptScript(root, [{ ...root, started: child.started }], 900), /Conflicting/);
30
+ assert.throws(() => windowsConsoleInterruptScript(root, [], 0), /owner/);
31
+ assert.doesNotMatch(script(), /CloseMainWindow|\.Kill\(|Stop-Process|process\.kill/);
32
+ assert.match(script(), /GenerateConsoleCtrlEvent\(0, 0\)/);
33
+ assert.ok(script().indexOf('::AttachConsole(100)') < script().indexOf('::SetConsoleCtrlHandler([IntPtr]::Zero'));
34
+ });
35
+
36
+ test('Bash bootstrap authenticates its solo console, inherits CONIN$, and avoids native stdin piping', () => {
37
+ const source = windowsBashLaunchScript("C:/fixture/O'Brien preview.sh");
38
+ assert.match(source, /\$start.Arguments = '-- "C:\/fixture\/O''Brien preview.sh"'/);
39
+ assert.ok(source.indexOf('GetConsoleProcessList') < source.indexOf('::CreateFile('));
40
+ assert.ok(source.indexOf('::SetHandleInformation($inputHandle, 1, 1)') < source.indexOf('[System.Diagnostics.Process]::Start'));
41
+ assert.match(source, /finally \{ \[void\]\[PiBgConsoleInput\]::CloseHandle\(\$inputHandle\) \}/);
42
+ });
43
+
44
+ for (const [name, count, member] of [['shared', 2, '(uint)System.Diagnostics.Process.GetCurrentProcess().Id'],
45
+ ['missing', 0, '0'], ['foreign', 1, '1']]) {
46
+ test(`Bash bootstrap refuses ${name} console before touching input`, { skip: !windows }, async () => {
47
+ const fake = `Add-Type -TypeDefinition @'
48
+ using System;
49
+ public static class PiBgConsoleInput {
50
+ public static bool SetConsoleCtrlHandler(IntPtr handler, bool add) { return true; }
51
+ public static uint GetConsoleProcessList(uint[] buffer, uint size) { buffer[0] = ${member}; return ${count}; }
52
+ }
53
+ '@`;
54
+ const source = windowsBashLaunchScript('unused.sh').replace(/Add-Type -TypeDefinition @'[\s\S]*?\r?\n'@/, fake);
55
+ await assert.rejects(ps(source), error => /Job console is not isolated/.test(error.stderr));
56
+ });
57
+ }
58
+
59
+ test('bootstrap clears inherited Ctrl+C suppression before commands; all scripts parse in PowerShell 5.1', { skip: !windows }, async () => {
60
+ assert.match(windowsConsoleInputPrelude, /SetConsoleCtrlHandler\(\[IntPtr\]::Zero, \$false\)/);
61
+ const encoded = Buffer.from(windowsBashLaunchScript("C:/fixture/O'Brien preview.sh") + '\n' + script(), 'utf8').toString('base64');
62
+ const { stdout } = await ps(`$source = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))
63
+ $tokens = $null; $errors = $null
64
+ $null = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors)
65
+ if ($errors.Count) { throw ($errors | Out-String) }
66
+ 'parsed ' + $PSVersionTable.PSVersion.ToString()`);
67
+ assert.match(stdout, /parsed 5\.1\./);
68
+ });
69
+
70
+ // Exercise the actual PowerShell control flow, substituting only OS boundaries.
71
+ // No fixture ever attaches to a console or delivers a real control event.
72
+ async function fixture({ lists = ['100,101,SELF'], fail = '', changed = 0, missing = 0, changeOnRefresh = 0 } = {}) {
73
+ const arrays = lists.map(list => `new uint[] { ${list.replaceAll('SELF', '(uint)System.Diagnostics.Process.GetCurrentProcess().Id')} }`).join(', ');
74
+ const native = `Add-Type -TypeDefinition @'
75
+ using System;
76
+ using System.Collections.Generic;
77
+ public static class PiBgConsole {
78
+ public static List<string> Calls = new List<string>();
79
+ public static int reads;
80
+ public static uint[][] lists = new uint[][] { ${arrays} };
81
+ public static bool FreeConsole() { Calls.Add("free"); return true; }
82
+ public static bool AttachConsole(uint p) { Calls.Add("attach"); return ${fail === 'attach' ? 'false' : 'true'}; }
83
+ public static bool SetConsoleCtrlHandler(IntPtr h, bool add) { Calls.Add("handler"); return ${fail === 'handler' ? 'false' : 'true'}; }
84
+ public static uint GetConsoleProcessList(uint[] buffer, uint size) {
85
+ Calls.Add("list");
86
+ ${fail === 'list' ? 'return 0;' : fail === 'oversize' ? 'return 65537;' : fail === 'unstable' ? 'return size + 1;' : `var list = lists[Math.Min(reads++, lists.Length - 1)]; Array.Copy(list, buffer, Math.Min(list.Length, buffer.Length)); return (uint)list.Length;`}
87
+ }
88
+ public static bool GenerateConsoleCtrlEvent(uint e, uint g) { Calls.Add("signal:" + e + ":" + g); return ${fail === 'signal' ? 'false' : 'true'}; }
89
+ }
90
+ '@`;
91
+ const source = script().replace(/Add-Type -TypeDefinition @'[\s\S]*?\r?\n'@/, native)
92
+ .replace('[System.Diagnostics.Process]::GetProcessById($targetPid)', '(Get-FixtureProcess $targetPid)');
93
+ const { stdout } = await ps(`$ErrorActionPreference = 'Stop'
94
+ $script:opened = @(); $script:disposed = @(); $script:refreshes = @{}
95
+ function Get-FixtureProcess([int]$id) {
96
+ if ($id -eq ${missing}) { throw 'process access denied or missing' }
97
+ $script:opened += $id
98
+ $birth = if ($id -eq 100) { '${root.started}' } else { '${child.started}' }
99
+ if ($id -eq ${changed}) { $birth = '2026-01-05T12:00:02.0000000Z' }
100
+ $p = [pscustomobject]@{ Id=$id; Handle=123; HasExited=$false; StartTime=[DateTime]::Parse($birth).ToUniversalTime() }
101
+ $p | Add-Member ScriptMethod Refresh {
102
+ $script:refreshes[$this.Id]++
103
+ if ($this.Id -eq ${changeOnRefresh} -and $script:refreshes[$this.Id] -ge 3) { $this.HasExited=$true }
104
+ }
105
+ $p | Add-Member ScriptMethod Dispose { $script:disposed += $this.Id }
106
+ return $p
107
+ }
108
+ $message = ''
109
+ try { & {
110
+ ${source}
111
+ } } catch { $message = $_.Exception.Message }
112
+ $calls = @(); try { $calls = @([PiBgConsole]::Calls) } catch {}
113
+ [pscustomobject]@{message=$message; calls=$calls; opened=@($script:opened); disposed=@($script:disposed)} | ConvertTo-Json -Compress`);
114
+ return JSON.parse(stdout.trim());
115
+ }
116
+
117
+ test('isolated authenticated console: one real Ctrl+C API call, held handles, cleanup', { skip: !windows }, async () => {
118
+ const result = await fixture();
119
+ assert.equal(result.message, '');
120
+ assert.deepEqual(result.calls, ['free', 'attach', 'handler', 'list', 'list', 'list', 'signal:0:0', 'free']);
121
+ assert.deepEqual(result.opened, [100, 101]);
122
+ assert.deepEqual(result.disposed.sort(), [100, 101]);
123
+ });
124
+
125
+ for (const [name, options, error] of [
126
+ ['Pi shared console', { lists: ['100,900,SELF'] }, /owner/],
127
+ ['unrelated sentinel', { lists: ['100,500,SELF'] }, /Unauthenticated/],
128
+ ['root absent', { lists: ['101,SELF'] }, /Root absent/],
129
+ ['helper absent', { lists: ['100,101'] }, /Helper absent/],
130
+ ['reused root', { changed: 100 }, /birth identity/],
131
+ ['reused child', { changed: 101 }, /birth identity/],
132
+ ['process access error', { missing: 101 }, /access denied/],
133
+ ['late process exit', { changeOnRefresh: 101 }, /process exited/],
134
+ ['late unrelated member', { lists: ['100,101,SELF', '100,101,SELF', '100,101,500,SELF'] }, /membership changed/],
135
+ ['membership shrink', { lists: ['100,101,SELF', '100,SELF'] }, /membership changed/],
136
+ ['duplicate membership', { lists: ['100,100,SELF'] }, /Invalid console membership/],
137
+ ['missing console', { fail: 'attach' }, /AttachConsole/],
138
+ ['handler error', { fail: 'handler' }, /SetConsoleCtrlHandler/],
139
+ ['enumeration error', { fail: 'list' }, /GetConsoleProcessList/],
140
+ ]) {
141
+ test(`fails closed: ${name}`, { skip: !windows }, async () => {
142
+ const result = await fixture(options);
143
+ assert.match(result.message, /Graceful stop unsupported\/error:/);
144
+ assert.match(result.message, error);
145
+ assert.ok(!result.calls.some(call => call.startsWith('signal:')));
146
+ assert.deepEqual(result.disposed.sort(), result.opened.sort());
147
+ if (result.calls.includes('handler')) assert.equal(result.calls.at(-1), 'free');
148
+ });
149
+ }
150
+
151
+ test('bounded console enumeration resizing rejects unstable oversized snapshots', { skip: !windows }, async () => {
152
+ const large = Array.from({ length: 17 }, (_, i) => i + 100).join(',') + ',SELF';
153
+ const result = await fixture({ lists: [large, '100,101,SELF'] });
154
+ assert.equal(result.message, '');
155
+ assert.equal(result.calls.filter(c => c === 'list').length, 4);
156
+ const tooLarge = await fixture({ fail: 'oversize' });
157
+ assert.match(tooLarge.message, /safety bound/);
158
+ assert.ok(!tooLarge.calls.some(c => c.startsWith('signal:')));
159
+ const unstable = await fixture({ fail: 'unstable' });
160
+ assert.match(unstable.message, /did not stabilize/);
161
+ assert.equal(unstable.calls.filter(c => c === 'list').length, 4);
162
+ assert.ok(!unstable.calls.some(c => c.startsWith('signal:')));
163
+ });
164
+
165
+ test('signal API failure is reported and still releases the console and handles', { skip: !windows }, async () => {
166
+ const result = await fixture({ fail: 'signal' });
167
+ assert.match(result.message, /GenerateConsoleCtrlEvent/);
168
+ assert.equal(result.calls.at(-1), 'free');
169
+ assert.deepEqual(result.disposed.sort(), [100, 101]);
170
+ });
171
+
172
+ // Opt in: only two test-owned hidden consoles. Both Node fixtures self-expire;
173
+ // no kill calls, no attachment to the test runner/Pi console, even on failure.
174
+ test('native isolated-console Ctrl+C runs a Node SIGINT handler and preserves sentinel', {
175
+ skip: !windows || process.env.PI_BG_CONSOLE_NATIVE_TEST !== '1', timeout: 65_000,
176
+ }, async () => {
177
+ const folder = await mkdtemp(join(tmpdir(), 'pi-bg-console-'));
178
+ const quote = value => "'" + value.replaceAll("'", "''") + "'";
179
+ try {
180
+ const nodeFile = join(folder, 'fixture.cjs');
181
+ await writeFile(nodeFile, `const fs = require('node:fs');
182
+ const path = require('node:path');
183
+ const mode = process.argv[2];
184
+ const dir = __dirname;
185
+ process.on('SIGINT', () => { fs.writeFileSync(path.join(dir, mode + '.interrupt'), 'SIGINT'); if (mode === 'root') process.exit(0); });
186
+ fs.writeFileSync(path.join(dir, mode + '.ready'), String(process.pid));
187
+ setTimeout(() => { fs.writeFileSync(path.join(dir, mode + '.expired'), 'expired'); }, 30000);
188
+ `);
189
+ const jobFile = join(folder, 'job.ps1');
190
+ await writeFile(jobFile, windowsConsoleInputPrelude + `\n& ${quote(process.execPath)} ${quote(nodeFile)} root\n`);
191
+ const launcher = join(folder, 'launch.ps1');
192
+ await writeFile(launcher, `$ErrorActionPreference='Stop'
193
+ $root = Start-Process -FilePath powershell.exe -ArgumentList @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', '"${jobFile}"') -WindowStyle Hidden -RedirectStandardOutput ${quote(join(folder, 'root.out'))} -RedirectStandardError ${quote(join(folder, 'root.err'))} -PassThru
194
+ $sentinel = Start-Process -FilePath ${quote(process.execPath)} -ArgumentList @('"${nodeFile}"', 'sentinel') -WindowStyle Hidden -RedirectStandardOutput ${quote(join(folder, 'sentinel.out'))} -RedirectStandardError ${quote(join(folder, 'sentinel.err'))} -PassThru
195
+ $deadline = [DateTime]::UtcNow.AddSeconds(5)
196
+ while (-not ((Test-Path ${quote(join(folder, 'root.ready'))}) -and (Test-Path ${quote(join(folder, 'sentinel.ready'))}))) {
197
+ if ([DateTime]::UtcNow -gt $deadline) { throw 'Fixture readiness timeout' }
198
+ Start-Sleep -Milliseconds 20
199
+ }
200
+ $nodePid = [int](Get-Content ${quote(join(folder, 'root.ready'))})
201
+ $rows = @(Get-CimInstance Win32_Process -Filter ('ProcessId = ' + $root.Id + ' OR ProcessId = ' + $nodePid))
202
+ $row = $rows | Where-Object ProcessId -eq $root.Id
203
+ $nodeRow = $rows | Where-Object ProcessId -eq $nodePid
204
+ @{pid=$root.Id; started=$row.CreationDate.ToUniversalTime().ToString('o'); sentinel=$sentinel.Id;
205
+ targets=@(@{pid=$root.Id; started=$row.CreationDate.ToUniversalTime().ToString('o')},
206
+ @{pid=$nodePid; started=$nodeRow.CreationDate.ToUniversalTime().ToString('o')})} | ConvertTo-Json -Compress -Depth 3
207
+ `);
208
+ const launch = await exec('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', launcher], { timeout: 25_000, windowsHide: true });
209
+ const identity = JSON.parse(launch.stdout.trim());
210
+ const delivered = await ps(windowsConsoleInterruptScript(identity, identity.targets, process.pid));
211
+ assert.equal(delivered.stderr, '');
212
+ // API acceptance is insufficient: require evidence from the application's handler.
213
+ assert.equal(await readFile(join(folder, 'root.interrupt'), 'utf8'), 'SIGINT');
214
+ await assert.rejects(readFile(join(folder, 'sentinel.interrupt')), { code: 'ENOENT' });
215
+ console.log(`native Ctrl+C handler confirmed: launcher ${identity.pid}, Node ${identity.targets[1].pid}; unrelated sentinel ${identity.sentinel} untouched`);
216
+ } finally {
217
+ // Every fixture naturally expires after 30s. Keep its files available until then.
218
+ await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Start-Sleep -Seconds 31'], { timeout: 35_000, windowsHide: true });
219
+ await rm(folder, { recursive: true, force: true });
220
+ }
221
+ });
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ Release notes are recorded from 0.1.3 onward.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.4]
8
+
9
+ ### Added
10
+
11
+ - Include package release notes in Pi 1.5.0's Extension Updates.
12
+
13
+ ## [0.1.3]
14
+
15
+ ### Fixed
16
+
17
+ - Classify the package as an extension in the Pi package gallery.
18
+ - Show the package screenshot as its gallery preview.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saifymatteo/pi-claude-usage",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Displays Claude subscription rate-limit windows from provider message diagnostics.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "src",
13
13
  "README.md",
14
+ "CHANGELOG.md",
14
15
  "NOTICE.md",
15
16
  "LICENSE"
16
17
  ],
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ Release notes are recorded from 0.1.3 onward.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.4]
8
+
9
+ ### Added
10
+
11
+ - Include package release notes in Pi 1.5.0's Extension Updates.
12
+
13
+ ## [0.1.3]
14
+
15
+ ### Fixed
16
+
17
+ - Classify the package as an extension in the Pi package gallery.
18
+ - Show the package screenshot as its gallery preview.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saifymatteo/pi-clipboard-image",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Attaches clipboard images to the draft, including copied image files on Windows.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "src",
13
13
  "README.md",
14
+ "CHANGELOG.md",
14
15
  "NOTICE.md",
15
16
  "LICENSE"
16
17
  ],
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ Release notes are recorded from 0.1.3 onward.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.4]
8
+
9
+ ### Added
10
+
11
+ - Include package release notes in Pi 1.5.0's Extension Updates.
12
+
13
+ ## [0.1.3]
14
+
15
+ ### Fixed
16
+
17
+ - Classify the package as an extension in the Pi package gallery.
18
+ - Show the package screenshot as its gallery preview.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saifymatteo/pi-codeblock-borders",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "license": "AGPL-3.0-only",
5
5
  "description": "Decorated code fences",
6
6
  "engines": {
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "src",
17
17
  "README.md",
18
+ "CHANGELOG.md",
18
19
  "NOTICE.md",
19
20
  "LICENSE"
20
21
  ],
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ Release notes are recorded from 0.1.3 onward.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.5]
8
+
9
+ ### Added
10
+
11
+ - Include package release notes in Pi 1.5.0's Extension Updates.
12
+
13
+ ## [0.1.4]
14
+
15
+ ### Fixed
16
+
17
+ - Defer CLI checks, configuration, and index initialization until after session startup to avoid blocking Pi.
18
+ - Cancel stale initialization when the active session changes.
19
+
20
+ ## [0.1.3]
21
+
22
+ ### Fixed
23
+
24
+ - Classify the package as an extension in the Pi package gallery.
25
+ - Show the package screenshot as its gallery preview.