@mjasnikovs/pi-task 0.40.42 → 0.40.44

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.
@@ -37,9 +37,37 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
37
37
  * possible at all: with `detached`, a `kill(-pid)` issued after the child
38
38
  * has exited still takes the backgrounded grandchild with it; without it
39
39
  * the same call throws ESRCH and the grandchild survives. Set only for
40
- * model children (json-events); plumbing stays in-group. */
40
+ * model children (json-events); plumbing stays in-group. Never set on
41
+ * win32 — see ownGroupSpawnOptions. */
41
42
  detached?: boolean;
43
+ /** win32 only: keep the child's console off-screen. */
44
+ windowsHide?: boolean;
42
45
  }) => ProcLike;
46
+ /**
47
+ * Spawn options that make a model child reapable with everything it backgrounds.
48
+ *
49
+ * POSIX (linux, darwin): `detached` = own process group, so `kill(-pid)` sweeps
50
+ * the grandchildren. On win32 the same flag is a defect: libuv maps it to
51
+ * DETACHED_PROCESS, which gives the child NO console, so every console process
52
+ * the model then runs allocates a fresh visible one (issue #20). win32 gets
53
+ * `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
54
+ * that its descendants inherit. Either/or is load-bearing — Windows ignores
55
+ * CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
56
+ * which walks the live tree and needs no flag; unlike a POSIX group kill it
57
+ * cannot catch what the child left behind after it exited.
58
+ */
59
+ export declare function ownGroupSpawnOptions(platform: NodeJS.Platform): OwnGroupSpawnOptions;
60
+ export type OwnGroupSpawnOptions = {
61
+ detached: true;
62
+ } | {
63
+ windowsHide: true;
64
+ };
65
+ /**
66
+ * Tear down a child spawned with `ownGroupSpawnOptions`, and whatever it
67
+ * backgrounded. Best-effort: a group already gone is not an error. Kept beside
68
+ * the spawn shape so a platform change edits one file.
69
+ */
70
+ export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, platform?: NodeJS.Platform): void;
43
71
  /**
44
72
  * Why runChild killed the child. Five sources converge on one kill path, and
45
73
  * each names itself here rather than in its own flag — so a consumer reads ONE
@@ -124,6 +152,9 @@ export interface RunChildTextOptions {
124
152
  }
125
153
  export interface RunChildJsonEventsOptions {
126
154
  mode: 'json-events';
155
+ /** Which platform's group options and reap to use. Tests drive the win32 arm
156
+ * from a POSIX host with it; production leaves it to `process.platform`. */
157
+ platform?: NodeJS.Platform;
127
158
  onLine?: (line: string) => void;
128
159
  onContextUsage?: (snapshot: ContextSnapshot) => void;
129
160
  /**
@@ -13,6 +13,40 @@ export const CHILD_BASE_ARGS = [
13
13
  '--no-context-files',
14
14
  '--no-session'
15
15
  ];
16
+ /**
17
+ * Spawn options that make a model child reapable with everything it backgrounds.
18
+ *
19
+ * POSIX (linux, darwin): `detached` = own process group, so `kill(-pid)` sweeps
20
+ * the grandchildren. On win32 the same flag is a defect: libuv maps it to
21
+ * DETACHED_PROCESS, which gives the child NO console, so every console process
22
+ * the model then runs allocates a fresh visible one (issue #20). win32 gets
23
+ * `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
24
+ * that its descendants inherit. Either/or is load-bearing — Windows ignores
25
+ * CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
26
+ * which walks the live tree and needs no flag; unlike a POSIX group kill it
27
+ * cannot catch what the child left behind after it exited.
28
+ */
29
+ export function ownGroupSpawnOptions(platform) {
30
+ return platform === 'win32' ? { windowsHide: true } : { detached: true };
31
+ }
32
+ /**
33
+ * Tear down a child spawned with `ownGroupSpawnOptions`, and whatever it
34
+ * backgrounded. Best-effort: a group already gone is not an error. Kept beside
35
+ * the spawn shape so a platform change edits one file.
36
+ */
37
+ export function reapProcessGroup(pid, sig, platform = process.platform) {
38
+ try {
39
+ if (platform === 'win32') {
40
+ spawnSyncDefault('taskkill', ['/pid', String(pid), '/T', '/F']);
41
+ }
42
+ else {
43
+ process.kill(-pid, sig);
44
+ }
45
+ }
46
+ catch {
47
+ // group already gone
48
+ }
49
+ }
16
50
  /** The cause a signal was aborted with, when its owner attached one. */
17
51
  function abortCause(reason) {
18
52
  const tagged = reason;
@@ -229,11 +263,12 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
229
263
  // OWN process group so every such grandchild can be reaped as a unit on exit.
230
264
  // Plumbing (git, mode:'text') never backgrounds anything and stays in-group.
231
265
  const ownGroup = opts?.mode === 'json-events';
266
+ const platform = (ownGroup && opts.platform) || process.platform;
232
267
  const proc = spawn(invocation.command, invocation.args, {
233
268
  cwd,
234
269
  shell: false,
235
270
  stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
236
- ...(ownGroup ? { detached: true } : {}),
271
+ ...(ownGroup ? ownGroupSpawnOptions(platform) : {}),
237
272
  ...(invocation.env ? { env: invocation.env } : {})
238
273
  });
239
274
  if (usesStdin) {
@@ -250,23 +285,11 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
250
285
  proc.stdin?.end();
251
286
  }
252
287
  // Reap the child's whole process group — the child itself AND anything it
253
- // backgrounded. No-op unless the child owns a group (ownGroup) and we have a
254
- // pid; ESRCH (group already gone) is swallowed. POSIX: negative-pid signals
255
- // the group; Windows has no groups, so taskkill /T tears down the tree.
288
+ // backgrounded. No-op unless the child owns a group (ownGroup) and we have a pid.
256
289
  const reapGroup = (sig) => {
257
290
  if (!ownGroup || !proc.pid)
258
291
  return;
259
- try {
260
- if (process.platform === 'win32') {
261
- spawnSyncDefault('taskkill', ['/pid', String(proc.pid), '/T', '/F']);
262
- }
263
- else {
264
- process.kill(-proc.pid, sig);
265
- }
266
- }
267
- catch {
268
- // group already gone
269
- }
292
+ reapProcessGroup(proc.pid, sig, platform);
270
293
  };
271
294
  // One kill path for every source: SIGTERM, then SIGKILL after a grace
272
295
  // period if the child ignored the term. For a group-owning (model) child,
@@ -1,4 +1,5 @@
1
1
  import type { RenderOutcome } from './render-check.js';
2
+ import type { OwnGroupSpawnOptions } from '../shared/child-process.js';
2
3
  import { type DeepRenderOutcome } from './deep-render-check.js';
3
4
  import type { HealthCommand } from './repo-health-check.js';
4
5
  /**
@@ -163,14 +164,16 @@ export interface BootDeps {
163
164
  * fake pid would signal something else entirely.
164
165
  */
165
166
  killGroup?: (pid: number, signal: NodeJS.Signals) => void;
167
+ /** Which platform's group options to spawn with. Tests drive the win32 arm
168
+ * from a POSIX host; production leaves it to `process.platform`. */
169
+ platform?: NodeJS.Platform;
166
170
  }
167
171
  /** What `runBootCheck` passes to its spawn. */
168
- export interface BootSpawnOptions {
172
+ export type BootSpawnOptions = {
169
173
  cwd: string;
170
- detached: true;
171
174
  stdio: ['ignore', 'pipe', 'pipe'];
172
175
  env: Record<string, string | undefined>;
173
- }
176
+ } & OwnGroupSpawnOptions;
174
177
  /** A stream the boot check reads output from. */
175
178
  export interface BootStream {
176
179
  on: (event: 'data', cb: (chunk: Buffer | string) => void) => void;
@@ -255,8 +258,9 @@ export declare function defaultFindPortHolder(port: number): {
255
258
  *
256
259
  * - non-zero exit (or signal death) before the window closes → FAIL, output tail;
257
260
  * - exit 0 before the window closes → PASS (a CLI-style "run" that finished);
258
- * - still alive when the window closes → PASS, then the whole process group is
259
- * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
261
+ * - still alive when the window closes → PASS, then the child and everything it
262
+ * backgrounded are killed (ownGroupSpawnOptions + reapProcessGroup; SIGTERM,
263
+ * escalating to SIGKILL).
260
264
  *
261
265
  * For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
262
266
  * survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
@@ -25,6 +25,7 @@ import * as path from 'node:path';
25
25
  import { runRenderCheck } from './render-check.js';
26
26
  import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
27
27
  import { outputTail } from './command-run.js';
28
+ import { ownGroupSpawnOptions, reapProcessGroup } from '../shared/child-process.js';
28
29
  import { packageScripts, makeHasTarget } from './launch-manifest.js';
29
30
  import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
30
31
  /** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
@@ -435,7 +436,8 @@ function pgidOf(pid) {
435
436
  }
436
437
  }
437
438
  /** Default listener probe: any LISTENing socket owned by a pid in process group
438
- * `pgid` (the detached boot child IS its own group leader, so pgid === child.pid). */
439
+ * `pgid`. POSIX only (expectServer is fenced off win32): there the detached boot
440
+ * child leads its own group, so pgid === child.pid. */
439
441
  function defaultGroupHasListener(pgid) {
440
442
  for (const { pid } of listeningSockets()) {
441
443
  if (pgidOf(pid) === pgid)
@@ -503,26 +505,9 @@ function holderIsOurs(command, boot) {
503
505
  function defaultSpawnBoot(bin, args, o) {
504
506
  return spawn(bin, args, o);
505
507
  }
506
- /**
507
- * The real group teardown, best-effort. A group already gone is not an error.
508
- *
509
- * On POSIX the negative pid signals the whole group, which is what makes a
510
- * `detached` spawn reapable together with anything it backgrounded. Windows has
511
- * neither process groups nor a negative-pid kill, so that branch shells out to
512
- * `taskkill /T /F` as a single forced tree teardown instead of escalating.
513
- */
508
+ /** The real group teardown; the spawn shape's twin lives beside it. */
514
509
  function defaultKillGroup(pid, sig) {
515
- try {
516
- if (process.platform === 'win32') {
517
- spawnSync('taskkill', ['/pid', String(pid), '/T', '/F']);
518
- }
519
- else {
520
- process.kill(-pid, sig);
521
- }
522
- }
523
- catch {
524
- // group already gone
525
- }
510
+ reapProcessGroup(pid, sig);
526
511
  }
527
512
  /**
528
513
  * Exercise the start command ONCE. All four outcomes below were run against real
@@ -533,8 +518,9 @@ function defaultKillGroup(pid, sig) {
533
518
  *
534
519
  * - non-zero exit (or signal death) before the window closes → FAIL, output tail;
535
520
  * - exit 0 before the window closes → PASS (a CLI-style "run" that finished);
536
- * - still alive when the window closes → PASS, then the whole process group is
537
- * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
521
+ * - still alive when the window closes → PASS, then the child and everything it
522
+ * backgrounded are killed (ownGroupSpawnOptions + reapProcessGroup; SIGTERM,
523
+ * escalating to SIGKILL).
538
524
  *
539
525
  * For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
540
526
  * survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
@@ -593,7 +579,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
593
579
  return new Promise(resolve => {
594
580
  const child = spawnBoot(runner.bin, args, {
595
581
  cwd,
596
- detached: true,
582
+ ...ownGroupSpawnOptions(opts.deps?.platform ?? process.platform),
597
583
  stdio: ['ignore', 'pipe', 'pipe'],
598
584
  env: {
599
585
  ...runnerEnv(runner),
@@ -44,6 +44,7 @@ import * as os from 'node:os';
44
44
  import * as path from 'node:path';
45
45
  import WebSocket from 'ws';
46
46
  import { findHeadlessBrowser, judgeRenderedDom } from './render-check.js';
47
+ import { ownGroupSpawnOptions, reapProcessGroup } from '../shared/child-process.js';
47
48
  /** Identifier halves of a credential pair, in preference order. */
48
49
  const IDENTIFIER_SUFFIXES = ['PHONE', 'EMAIL', 'USERNAME', 'USER', 'LOGIN', 'IDENTIFIER'];
49
50
  const PASSWORD_SUFFIXES = ['PASSWORD', 'PASSWD', 'PASS'];
@@ -622,13 +623,8 @@ export async function launchBrowser(bin, userDataDir, { signal } = {}) {
622
623
  catch {
623
624
  // socket already gone
624
625
  }
625
- try {
626
- if (child?.pid)
627
- process.kill(-child.pid, 'SIGKILL');
628
- }
629
- catch {
630
- // group already gone
631
- }
626
+ if (child?.pid)
627
+ reapProcessGroup(child.pid, 'SIGKILL');
632
628
  return Promise.resolve();
633
629
  };
634
630
  signal?.addEventListener('abort', () => void close(), { once: true });
@@ -647,7 +643,7 @@ export async function launchBrowser(bin, userDataDir, { signal } = {}) {
647
643
  `--user-data-dir=${userDataDir}`,
648
644
  '--remote-debugging-port=0',
649
645
  'about:blank'
650
- ], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
646
+ ], { ...ownGroupSpawnOptions(process.platform), stdio: ['ignore', 'pipe', 'pipe'] });
651
647
  const proc = child;
652
648
  proc.unref();
653
649
  const wsUrl = await new Promise((resolve, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.42",
3
+ "version": "0.40.44",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",