harnery 0.3.1 → 0.3.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../src/commands/agents.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAeH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA+EnD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,GAAG,IAAI,CA6OpF"}
1
+ {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../src/commands/agents.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAeH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAgFnD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,GAAG,IAAI,CA6OpF"}
@@ -18,6 +18,7 @@ import { homedir, tmpdir } from "node:os";
18
18
  import { basename, join, resolve } from "node:path";
19
19
  import { emitCanonical, monorepoRoot, normalizeHarness, readHeartbeat, resolveOwner, resolveOwnerWithSource, } from "../core/agents/index.js";
20
20
  import { resolveBinName } from "../core/config.js";
21
+ import { parsePsChainLine } from "../core/hooks/resolve/anchor.js";
21
22
  import { buildCouncilId, buildInviteMarkdown, COUNCIL_SCHEMA_VERSION, contributorsInRound, councilBodyDir, councilsArchiveDir, deleteArchivedCouncil, effectiveSteward, findManifestByPartialId, listKnownAgents, listManifests, moveFromArchive, moveToArchive, normalizeAgentName, pendingCouncilsForMember, readArchivedManifest, readManifest, readRoundPrompts, roundDir, setCouncilSteward, writeContribution, writeManifest, writePrompt, } from "../lib/council/index.js";
22
23
  import { displayName as displayAgentName, ensureIdentity, listIdentities, lookupById as lookupIdentityById, lookupByName as lookupIdentityByName, } from "../lib/identities/index.js";
23
24
  import { appendEntry, resolveOwnerByName } from "../lib/scratch/index.js";
@@ -2251,14 +2252,26 @@ function runHarnessProbe(id, opts) {
2251
2252
  for (let hops = 0; hops < 20; hops++) {
2252
2253
  let comm = "?";
2253
2254
  let ppid = 0;
2255
+ let got = false;
2254
2256
  try {
2255
2257
  comm = readFileSync(`/proc/${walkPid}/comm`, "utf8").trim() || "?";
2256
2258
  const status = readFileSync(`/proc/${walkPid}/status`, "utf8");
2257
2259
  const m = status.match(/^PPid:\s+(\d+)/m);
2258
2260
  ppid = m ? Number(m[1]) : 0;
2261
+ got = true;
2259
2262
  }
2260
2263
  catch {
2261
- // non-Linux (no /proc) or the pid is gone: stop walking.
2264
+ // non-Linux (no /proc): fall through to the portable ps walk below.
2265
+ }
2266
+ if (!got) {
2267
+ const out = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(walkPid)], {
2268
+ encoding: "utf8",
2269
+ });
2270
+ const parsed = out.status === 0 ? parsePsChainLine(out.stdout) : null;
2271
+ if (parsed) {
2272
+ comm = parsed.comm || "?";
2273
+ ppid = parsed.ppid;
2274
+ }
2262
2275
  }
2263
2276
  chainParts.push(`${walkPid}:${comm}`);
2264
2277
  if (!anchorPid && anchorTokens.has(comm))
@@ -330,15 +330,30 @@ export function selfDisplayName() {
330
330
  return displayName(owner);
331
331
  }
332
332
  function readPpid(pid) {
333
+ // Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
333
334
  try {
334
335
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
335
336
  const m = status.match(/^PPid:\s+(\d+)/m);
336
- if (!m)
337
- return null;
338
- const parsed = Number.parseInt(m[1], 10);
339
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
337
+ if (m) {
338
+ const parsed = Number.parseInt(m[1], 10);
339
+ if (Number.isFinite(parsed) && parsed > 0)
340
+ return parsed;
341
+ }
340
342
  }
341
343
  catch {
342
- return null;
344
+ // no /proc (macOS/BSD) — fall through to ps
345
+ }
346
+ // Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
347
+ try {
348
+ const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
349
+ if (out.status === 0) {
350
+ const parsed = Number.parseInt(out.stdout.trim(), 10);
351
+ if (Number.isFinite(parsed) && parsed > 0)
352
+ return parsed;
353
+ }
354
+ }
355
+ catch {
356
+ // ps unavailable — give up
343
357
  }
358
+ return null;
344
359
  }
@@ -30,7 +30,7 @@ import { captureImages, detectPresence, imageJanitor, playSound, resetSoundCount
30
30
  import { emit } from "./events/emit.js";
31
31
  import { detectHarness } from "./harness/detect.js";
32
32
  import { extractBashCommand, extractToolDescription, normalizeEventName, parsePayload, } from "./harness/parse.js";
33
- import { selectAnchorPid } from "./resolve/anchor.js";
33
+ import { parsePsChainLine, selectAnchorPid } from "./resolve/anchor.js";
34
34
  import { findCoordRoot } from "./resolve/coord-root.js";
35
35
  import { extractIntentComment, resolveIntent } from "./resolve/intent.js";
36
36
  import { resolveOwner } from "./resolve/owner.js";
@@ -820,10 +820,10 @@ function healHeartbeatViaCli(coordRoot, instanceId, sessionId, harness) {
820
820
  * the next tool call rather than going invisible until SessionStart fires
821
821
  * again, which it may never do.
822
822
  *
823
- * Returns undefined on macOS (no /proc) or when no anchor is found; callers
824
- * fall back to `process.ppid` (the bash wrapper's parent, which is usually
825
- * the harness binary itself). `HARNERY_AGENT_COORD_TEST_ANCHOR_PID` overrides
826
- * everything so the test sandbox can pin a deterministic PID.
823
+ * Returns undefined only when no anchor is found; callers fall back to
824
+ * `process.ppid` (the bash wrapper's parent, which is usually the harness binary
825
+ * itself). `HARNERY_AGENT_COORD_TEST_ANCHOR_PID` overrides everything so the
826
+ * test sandbox can pin a deterministic PID.
827
827
  */
828
828
  function findHarnessAnchorPid(harness) {
829
829
  const override = coordEnv("AGENT_COORD_TEST_ANCHOR_PID");
@@ -832,30 +832,35 @@ function findHarnessAnchorPid(harness) {
832
832
  if (Number.isFinite(n) && n > 0)
833
833
  return n;
834
834
  }
835
- // Build the ppid chain (nearest → root, up to 20 hops) from /proc, then hand
836
- // it to the pure selector. Splitting the /proc walk (untestable off a live
837
- // box) from the comm-matching (unit-tested against the real Phase 0 chains in
838
- // resolve/anchor.ts) keeps the cursor `node`-fallback logic verifiable.
835
+ // Build the ppid chain (nearest → root, up to 20 hops), then hand it to the
836
+ // pure selector. Linux/WSL reads /proc; macOS/BSD (no /proc) falls back to
837
+ // `ps -o ppid=,comm=` parsed by the unit-tested `parsePsChainLine`. Splitting
838
+ // the walk (untestable off a live box) from the comm-matching keeps the
839
+ // cursor `node`-fallback logic verifiable.
839
840
  const chain = [];
840
841
  let pid = process.pid;
841
842
  for (let hops = 0; hops < 20; hops++) {
842
- let comm;
843
- let status;
843
+ let hop = null;
844
844
  try {
845
- comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
846
- status = readFileSync(`/proc/${pid}/status`, "utf8");
845
+ const comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
846
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
847
+ const m = status.match(/^PPid:\s+(\d+)/m);
848
+ hop = { comm, ppid: m ? Number(m[1]) : 0 };
847
849
  }
848
850
  catch {
849
- break;
851
+ // no /proc (macOS/BSD) — fall through to ps
852
+ }
853
+ if (!hop) {
854
+ const out = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8" });
855
+ if (out.status === 0)
856
+ hop = parsePsChainLine(out.stdout);
850
857
  }
851
- chain.push({ pid, comm });
852
- const m = status.match(/^PPid:\s+(\d+)/m);
853
- if (!m)
858
+ if (!hop)
854
859
  break;
855
- const ppid = Number(m[1]);
856
- if (!Number.isFinite(ppid) || ppid === 0 || ppid === 1)
860
+ chain.push({ pid, comm: hop.comm });
861
+ if (!Number.isFinite(hop.ppid) || hop.ppid === 0 || hop.ppid === 1)
857
862
  break;
858
- pid = ppid;
863
+ pid = hop.ppid;
859
864
  }
860
865
  return selectAnchorPid(chain, harness);
861
866
  }
@@ -34,4 +34,19 @@ export declare function selectAnchorPid(chain: ReadonlyArray<{
34
34
  pid: number;
35
35
  comm: string;
36
36
  }>, harness?: string): number | undefined;
37
+ /**
38
+ * Parse one `ps -o ppid=,comm= -p <pid>` line into the same `{ ppid, comm }`
39
+ * shape the `/proc` fast path produces, for the macOS/BSD branch of the anchor
40
+ * walk. `ps` prints `comm` as a full executable path (and some Apple helper
41
+ * names contain spaces, e.g. `Code Helper (Plugin)`), so the comm is reduced to
42
+ * its basename to match the harness comm tokens the way Linux's `/proc/<pid>/comm`
43
+ * basename does. Returns null when the line has no leading numeric ppid.
44
+ *
45
+ * Pure (no I/O) so the parsing — the error-prone part — is unit-testable
46
+ * without a live process tree; the caller owns the `ps` spawn.
47
+ */
48
+ export declare function parsePsChainLine(line: string): {
49
+ ppid: number;
50
+ comm: string;
51
+ } | null;
37
52
  //# sourceMappingURL=anchor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"anchor.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/anchor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAMH;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,aAAa,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EACnD,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,GAAG,SAAS,CAUpB"}
1
+ {"version":3,"file":"anchor.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/anchor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAMH;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,aAAa,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EACnD,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,GAAG,SAAS,CAUpB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAQpF"}
@@ -46,3 +46,25 @@ export function selectAnchorPid(chain, harness) {
46
46
  }
47
47
  return undefined;
48
48
  }
49
+ /**
50
+ * Parse one `ps -o ppid=,comm= -p <pid>` line into the same `{ ppid, comm }`
51
+ * shape the `/proc` fast path produces, for the macOS/BSD branch of the anchor
52
+ * walk. `ps` prints `comm` as a full executable path (and some Apple helper
53
+ * names contain spaces, e.g. `Code Helper (Plugin)`), so the comm is reduced to
54
+ * its basename to match the harness comm tokens the way Linux's `/proc/<pid>/comm`
55
+ * basename does. Returns null when the line has no leading numeric ppid.
56
+ *
57
+ * Pure (no I/O) so the parsing — the error-prone part — is unit-testable
58
+ * without a live process tree; the caller owns the `ps` spawn.
59
+ */
60
+ export function parsePsChainLine(line) {
61
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
62
+ if (!m)
63
+ return null;
64
+ const ppid = Number.parseInt(m[1], 10);
65
+ if (!Number.isFinite(ppid))
66
+ return null;
67
+ const commPath = m[2].trim();
68
+ const comm = commPath.split("/").pop() || commPath;
69
+ return { ppid, comm };
70
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"owner.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/owner.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,KAAK,GAAG,SAAS,GAAG,aAAa,GAAG,iBAAiB,CAAA;CAAE,GAAG,IAAI,CA+ChG;AAmBD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ7E;AAED,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUxF;AAED,2DAA2D;AAC3D,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAgBnF"}
1
+ {"version":3,"file":"owner.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/owner.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,KAAK,GAAG,SAAS,GAAG,aAAa,GAAG,iBAAiB,CAAA;CAAE,GAAG,IAAI,CA+ChG;AA2BD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ7E;AAED,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUxF;AAED,2DAA2D;AAC3D,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAgBnF"}
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { coordEnv } from "../../../lib/env.js";
@@ -67,19 +68,30 @@ export function resolveOwner(opts) {
67
68
  return null;
68
69
  }
69
70
  function readPpid(pid) {
70
- // Linux/WSL: /proc/<pid>/status carries `PPid:`. Falls back to null on
71
- // macOS or any read failure; ancestor walk just terminates.
71
+ // Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
72
72
  try {
73
73
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
74
- for (const line of status.split("\n")) {
75
- if (line.startsWith("PPid:")) {
76
- const n = Number(line.split(/\s+/)[1]);
77
- return Number.isFinite(n) ? n : null;
78
- }
74
+ const m = status.match(/^PPid:\s+(\d+)/m);
75
+ if (m) {
76
+ const n = Number.parseInt(m[1], 10);
77
+ if (Number.isFinite(n) && n > 0)
78
+ return n;
79
+ }
80
+ }
81
+ catch {
82
+ /* no /proc (macOS/BSD) — fall through to ps */
83
+ }
84
+ // Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
85
+ try {
86
+ const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
87
+ if (out.status === 0) {
88
+ const n = Number.parseInt(out.stdout.trim(), 10);
89
+ if (Number.isFinite(n) && n > 0)
90
+ return n;
79
91
  }
80
92
  }
81
93
  catch {
82
- /* fallthrough */
94
+ /* ps unavailable — give up */
83
95
  }
84
96
  return null;
85
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Multi-agent coordination + harness adapters + portable CLI utilities for Claude Code / Cursor / Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -38,6 +38,7 @@ import {
38
38
  resolveOwnerWithSource,
39
39
  } from "../core/agents/index.ts";
40
40
  import { resolveBinName } from "../core/config.ts";
41
+ import { parsePsChainLine } from "../core/hooks/resolve/anchor.ts";
41
42
  import {
42
43
  buildCouncilId,
43
44
  buildInviteMarkdown,
@@ -2692,13 +2693,25 @@ function runHarnessProbe(
2692
2693
  for (let hops = 0; hops < 20; hops++) {
2693
2694
  let comm = "?";
2694
2695
  let ppid = 0;
2696
+ let got = false;
2695
2697
  try {
2696
2698
  comm = readFileSync(`/proc/${walkPid}/comm`, "utf8").trim() || "?";
2697
2699
  const status = readFileSync(`/proc/${walkPid}/status`, "utf8");
2698
2700
  const m = status.match(/^PPid:\s+(\d+)/m);
2699
2701
  ppid = m ? Number(m[1]) : 0;
2702
+ got = true;
2700
2703
  } catch {
2701
- // non-Linux (no /proc) or the pid is gone: stop walking.
2704
+ // non-Linux (no /proc): fall through to the portable ps walk below.
2705
+ }
2706
+ if (!got) {
2707
+ const out = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(walkPid)], {
2708
+ encoding: "utf8",
2709
+ });
2710
+ const parsed = out.status === 0 ? parsePsChainLine(out.stdout) : null;
2711
+ if (parsed) {
2712
+ comm = parsed.comm || "?";
2713
+ ppid = parsed.ppid;
2714
+ }
2702
2715
  }
2703
2716
  chainParts.push(`${walkPid}:${comm}`);
2704
2717
  if (!anchorPid && anchorTokens.has(comm)) anchorPid = String(walkPid);
@@ -361,13 +361,26 @@ export function selfDisplayName(): string {
361
361
  }
362
362
 
363
363
  function readPpid(pid: number): number | null {
364
+ // Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
364
365
  try {
365
366
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
366
367
  const m = status.match(/^PPid:\s+(\d+)/m);
367
- if (!m) return null;
368
- const parsed = Number.parseInt(m[1]!, 10);
369
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
368
+ if (m) {
369
+ const parsed = Number.parseInt(m[1]!, 10);
370
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
371
+ }
370
372
  } catch {
371
- return null;
373
+ // no /proc (macOS/BSD) — fall through to ps
372
374
  }
375
+ // Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
376
+ try {
377
+ const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
378
+ if (out.status === 0) {
379
+ const parsed = Number.parseInt(out.stdout.trim(), 10);
380
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
381
+ }
382
+ } catch {
383
+ // ps unavailable — give up
384
+ }
385
+ return null;
373
386
  }
@@ -51,7 +51,7 @@ import {
51
51
  type ParsedPayload,
52
52
  parsePayload,
53
53
  } from "./harness/parse.ts";
54
- import { selectAnchorPid } from "./resolve/anchor.ts";
54
+ import { parsePsChainLine, selectAnchorPid } from "./resolve/anchor.ts";
55
55
  import { findCoordRoot } from "./resolve/coord-root.ts";
56
56
  import { extractIntentComment, resolveIntent } from "./resolve/intent.ts";
57
57
  import { resolveOwner } from "./resolve/owner.ts";
@@ -908,10 +908,10 @@ function healHeartbeatViaCli(
908
908
  * the next tool call rather than going invisible until SessionStart fires
909
909
  * again, which it may never do.
910
910
  *
911
- * Returns undefined on macOS (no /proc) or when no anchor is found; callers
912
- * fall back to `process.ppid` (the bash wrapper's parent, which is usually
913
- * the harness binary itself). `HARNERY_AGENT_COORD_TEST_ANCHOR_PID` overrides
914
- * everything so the test sandbox can pin a deterministic PID.
911
+ * Returns undefined only when no anchor is found; callers fall back to
912
+ * `process.ppid` (the bash wrapper's parent, which is usually the harness binary
913
+ * itself). `HARNERY_AGENT_COORD_TEST_ANCHOR_PID` overrides everything so the
914
+ * test sandbox can pin a deterministic PID.
915
915
  */
916
916
  function findHarnessAnchorPid(harness?: Harness): number | undefined {
917
917
  const override = coordEnv("AGENT_COORD_TEST_ANCHOR_PID");
@@ -919,27 +919,31 @@ function findHarnessAnchorPid(harness?: Harness): number | undefined {
919
919
  const n = Number(override);
920
920
  if (Number.isFinite(n) && n > 0) return n;
921
921
  }
922
- // Build the ppid chain (nearest → root, up to 20 hops) from /proc, then hand
923
- // it to the pure selector. Splitting the /proc walk (untestable off a live
924
- // box) from the comm-matching (unit-tested against the real Phase 0 chains in
925
- // resolve/anchor.ts) keeps the cursor `node`-fallback logic verifiable.
922
+ // Build the ppid chain (nearest → root, up to 20 hops), then hand it to the
923
+ // pure selector. Linux/WSL reads /proc; macOS/BSD (no /proc) falls back to
924
+ // `ps -o ppid=,comm=` parsed by the unit-tested `parsePsChainLine`. Splitting
925
+ // the walk (untestable off a live box) from the comm-matching keeps the
926
+ // cursor `node`-fallback logic verifiable.
926
927
  const chain: Array<{ pid: number; comm: string }> = [];
927
928
  let pid = process.pid;
928
929
  for (let hops = 0; hops < 20; hops++) {
929
- let comm: string;
930
- let status: string;
930
+ let hop: { comm: string; ppid: number } | null = null;
931
931
  try {
932
- comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
933
- status = readFileSync(`/proc/${pid}/status`, "utf8");
932
+ const comm = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
933
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
934
+ const m = status.match(/^PPid:\s+(\d+)/m);
935
+ hop = { comm, ppid: m ? Number(m[1]) : 0 };
934
936
  } catch {
935
- break;
937
+ // no /proc (macOS/BSD) — fall through to ps
936
938
  }
937
- chain.push({ pid, comm });
938
- const m = status.match(/^PPid:\s+(\d+)/m);
939
- if (!m) break;
940
- const ppid = Number(m[1]);
941
- if (!Number.isFinite(ppid) || ppid === 0 || ppid === 1) break;
942
- pid = ppid;
939
+ if (!hop) {
940
+ const out = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8" });
941
+ if (out.status === 0) hop = parsePsChainLine(out.stdout);
942
+ }
943
+ if (!hop) break;
944
+ chain.push({ pid, comm: hop.comm });
945
+ if (!Number.isFinite(hop.ppid) || hop.ppid === 0 || hop.ppid === 1) break;
946
+ pid = hop.ppid;
943
947
  }
944
948
  return selectAnchorPid(chain, harness);
945
949
  }
@@ -49,3 +49,24 @@ export function selectAnchorPid(
49
49
  }
50
50
  return undefined;
51
51
  }
52
+
53
+ /**
54
+ * Parse one `ps -o ppid=,comm= -p <pid>` line into the same `{ ppid, comm }`
55
+ * shape the `/proc` fast path produces, for the macOS/BSD branch of the anchor
56
+ * walk. `ps` prints `comm` as a full executable path (and some Apple helper
57
+ * names contain spaces, e.g. `Code Helper (Plugin)`), so the comm is reduced to
58
+ * its basename to match the harness comm tokens the way Linux's `/proc/<pid>/comm`
59
+ * basename does. Returns null when the line has no leading numeric ppid.
60
+ *
61
+ * Pure (no I/O) so the parsing — the error-prone part — is unit-testable
62
+ * without a live process tree; the caller owns the `ps` spawn.
63
+ */
64
+ export function parsePsChainLine(line: string): { ppid: number; comm: string } | null {
65
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
66
+ if (!m) return null;
67
+ const ppid = Number.parseInt(m[1]!, 10);
68
+ if (!Number.isFinite(ppid)) return null;
69
+ const commPath = m[2]!.trim();
70
+ const comm = commPath.split("/").pop() || commPath;
71
+ return { ppid, comm };
72
+ }
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { coordEnv } from "../../../lib/env.ts";
@@ -73,18 +74,26 @@ export function resolveOwner(opts: {
73
74
  }
74
75
 
75
76
  function readPpid(pid: number): number | null {
76
- // Linux/WSL: /proc/<pid>/status carries `PPid:`. Falls back to null on
77
- // macOS or any read failure; ancestor walk just terminates.
77
+ // Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
78
78
  try {
79
79
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
80
- for (const line of status.split("\n")) {
81
- if (line.startsWith("PPid:")) {
82
- const n = Number(line.split(/\s+/)[1]);
83
- return Number.isFinite(n) ? n : null;
84
- }
80
+ const m = status.match(/^PPid:\s+(\d+)/m);
81
+ if (m) {
82
+ const n = Number.parseInt(m[1]!, 10);
83
+ if (Number.isFinite(n) && n > 0) return n;
84
+ }
85
+ } catch {
86
+ /* no /proc (macOS/BSD) — fall through to ps */
87
+ }
88
+ // Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
89
+ try {
90
+ const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
91
+ if (out.status === 0) {
92
+ const n = Number.parseInt(out.stdout.trim(), 10);
93
+ if (Number.isFinite(n) && n > 0) return n;
85
94
  }
86
95
  } catch {
87
- /* fallthrough */
96
+ /* ps unavailable — give up */
88
97
  }
89
98
  return null;
90
99
  }