harnery 0.3.0 → 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))
@@ -69,8 +69,29 @@ export declare function resolveOwner(): string | null;
69
69
  */
70
70
  export declare function resolveOwnerWithSource(): {
71
71
  owner: string | null;
72
- source: "env" | "pidmap" | "pidmap_fallback" | "none";
72
+ source: "env" | "pidmap" | "pidmap_fallback" | "session_env" | "active_singleton" | "none";
73
73
  };
74
+ /**
75
+ * Resolve the owner by matching the harness session-id env var against the
76
+ * `session_id` of a live heartbeat in this coord root. Returns the matching
77
+ * `instance_id`, or null if there's no session-id env var or no live heartbeat
78
+ * carries it. "Live" reuses the same 10-minute freshness window the singleton
79
+ * fallback applies, so a stale heartbeat from a prior session of the same id
80
+ * doesn't resolve.
81
+ *
82
+ * Exported for unit testing with an injectable root.
83
+ */
84
+ export declare function resolveOwnerBySessionEnv(root: string): string | null;
85
+ /**
86
+ * Return the instance_id of the sole live agent in this coord root, or null
87
+ * if there are zero or more than one. "Live" reuses the 10-minute heartbeat
88
+ * freshness window the rest of the agents surface applies (kept inline as a
89
+ * literal so this file stays node-builtins-only for vendored downstream use).
90
+ *
91
+ * Exported for unit testing with an injectable root (the caller in
92
+ * `resolveOwnerWithSource` passes `monorepoRoot()`).
93
+ */
94
+ export declare function resolveSingleActiveOwner(root: string): string | null;
74
95
  /**
75
96
  * Read and parse a heartbeat file. Returns null if the file is missing,
76
97
  * unreadable, or contains malformed JSON. Does not throw.
@@ -1 +1 @@
1
- {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0IAA0I;IAC1I,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CA0C5C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,MAAM,CAAC;CACvD,CAgCA;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKlE;AAgBD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAIxC"}
1
+ {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0IAA0I;IAC1I,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CA0C5C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,aAAa,GAAG,kBAAkB,GAAG,MAAM,CAAC;CAC5F,CAuDA;AA4BD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA2BpE;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyBpE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKlE;AAgBD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAIxC"}
@@ -7,7 +7,7 @@
7
7
  * either side without divergence.
8
8
  */
9
9
  import { spawnSync } from "node:child_process";
10
- import { existsSync, readFileSync } from "node:fs";
10
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
11
11
  import { resolve } from "node:path";
12
12
  /**
13
13
  * Resolve the monorepo root for coord-state purposes.
@@ -133,10 +133,142 @@ export function resolveOwnerWithSource() {
133
133
  }
134
134
  pid = readPpid(pid);
135
135
  }
136
- return {
137
- owner: fallbackOwner,
138
- source: fallbackOwner ? "pidmap_fallback" : "none",
139
- };
136
+ if (fallbackOwner) {
137
+ return { owner: fallbackOwner, source: "pidmap_fallback" };
138
+ }
139
+ // The ppid walk found nothing (e.g. a Bash-tool subshell whose process tree
140
+ // doesn't climb back to the harness anchor). Before guessing, try the
141
+ // harness-provided session id from the environment: every supported harness
142
+ // exports its session id into the tool subprocess env, and each heartbeat
143
+ // records the `session_id` it was minted under. Matching the two resolves us
144
+ // unambiguously even with multiple live agents, which is the case the
145
+ // singleton fallback below cannot handle.
146
+ const bySession = resolveOwnerBySessionEnv(root);
147
+ if (bySession) {
148
+ return { owner: bySession, source: "session_env" };
149
+ }
150
+ // Last resort: if exactly one agent is live in this coord root, it's
151
+ // unambiguously us — resolve to it. This is what lets the bare `agents
152
+ // status` / `set-task` the stop hook recommends work without a `--session-id`
153
+ // flag in the common single-agent case. With 0 or 2+ live agents it would be
154
+ // a guess, so we stay null and require the explicit flag.
155
+ const singleton = resolveSingleActiveOwner(root);
156
+ if (singleton) {
157
+ return { owner: singleton, source: "active_singleton" };
158
+ }
159
+ return { owner: null, source: "none" };
160
+ }
161
+ /**
162
+ * Harness-exported session-id environment variables, in precedence order. Each
163
+ * supported harness propagates its session id into the env of the subprocess it
164
+ * spawns for a tool call (Claude Code's Bash tool, Cursor's terminal, Codex's
165
+ * shell). A coord CLI invoked as such a tool can therefore recover its own
166
+ * identity from the env even when the ppid walk misses.
167
+ *
168
+ * Kept inline (no shared-helper import) so this file stays node-builtins-only
169
+ * for the vendored downstream consumer.
170
+ */
171
+ const SESSION_ID_ENV_VARS = [
172
+ "HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
173
+ "CLAUDE_CODE_SESSION_ID",
174
+ "CURSOR_SESSION_ID",
175
+ "CODEX_SESSION_ID",
176
+ ];
177
+ /** Read the first non-empty harness session-id env var, or null. */
178
+ function sessionIdFromEnv() {
179
+ for (const key of SESSION_ID_ENV_VARS) {
180
+ const v = process.env[key]?.trim();
181
+ if (v)
182
+ return v;
183
+ }
184
+ return null;
185
+ }
186
+ /**
187
+ * Resolve the owner by matching the harness session-id env var against the
188
+ * `session_id` of a live heartbeat in this coord root. Returns the matching
189
+ * `instance_id`, or null if there's no session-id env var or no live heartbeat
190
+ * carries it. "Live" reuses the same 10-minute freshness window the singleton
191
+ * fallback applies, so a stale heartbeat from a prior session of the same id
192
+ * doesn't resolve.
193
+ *
194
+ * Exported for unit testing with an injectable root.
195
+ */
196
+ export function resolveOwnerBySessionEnv(root) {
197
+ const sessionId = sessionIdFromEnv();
198
+ if (!sessionId)
199
+ return null;
200
+ const activeDir = resolve(root, ".harnery", "active");
201
+ if (!existsSync(activeDir))
202
+ return null;
203
+ const FRESHNESS_SECS = 600;
204
+ const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
205
+ let files;
206
+ try {
207
+ files = readdirSync(activeDir);
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ for (const file of files) {
213
+ if (!file.endsWith(".json"))
214
+ continue;
215
+ try {
216
+ const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
217
+ if (!parsed || parsed.session_id !== sessionId)
218
+ continue;
219
+ if (typeof parsed.instance_id !== "string")
220
+ continue;
221
+ const ts = Date.parse(parsed.last_heartbeat);
222
+ if (Number.isFinite(ts) && ts >= cutoffMs)
223
+ return parsed.instance_id;
224
+ }
225
+ catch {
226
+ // skip malformed
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+ /**
232
+ * Return the instance_id of the sole live agent in this coord root, or null
233
+ * if there are zero or more than one. "Live" reuses the 10-minute heartbeat
234
+ * freshness window the rest of the agents surface applies (kept inline as a
235
+ * literal so this file stays node-builtins-only for vendored downstream use).
236
+ *
237
+ * Exported for unit testing with an injectable root (the caller in
238
+ * `resolveOwnerWithSource` passes `monorepoRoot()`).
239
+ */
240
+ export function resolveSingleActiveOwner(root) {
241
+ const activeDir = resolve(root, ".harnery", "active");
242
+ if (!existsSync(activeDir))
243
+ return null;
244
+ const FRESHNESS_SECS = 600;
245
+ const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
246
+ const live = [];
247
+ let files;
248
+ try {
249
+ files = readdirSync(activeDir);
250
+ }
251
+ catch {
252
+ return null;
253
+ }
254
+ for (const file of files) {
255
+ if (!file.endsWith(".json"))
256
+ continue;
257
+ try {
258
+ const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
259
+ if (!parsed || typeof parsed.instance_id !== "string")
260
+ continue;
261
+ const ts = Date.parse(parsed.last_heartbeat);
262
+ if (Number.isFinite(ts) && ts >= cutoffMs)
263
+ live.push(parsed.instance_id);
264
+ }
265
+ catch {
266
+ // skip malformed
267
+ }
268
+ if (live.length > 1)
269
+ return null; // ambiguous; bail early
270
+ }
271
+ return live.length === 1 ? live[0] : null;
140
272
  }
141
273
  /**
142
274
  * Read and parse a heartbeat file. Returns null if the file is missing,
@@ -198,15 +330,30 @@ export function selfDisplayName() {
198
330
  return displayName(owner);
199
331
  }
200
332
  function readPpid(pid) {
333
+ // Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
201
334
  try {
202
335
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
203
336
  const m = status.match(/^PPid:\s+(\d+)/m);
204
- if (!m)
205
- return null;
206
- const parsed = Number.parseInt(m[1], 10);
207
- 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
+ }
208
342
  }
209
343
  catch {
210
- return null;
344
+ // no /proc (macOS/BSD) — fall through to ps
211
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
357
+ }
358
+ return null;
212
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.0",
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);
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { spawnSync } from "node:child_process";
11
- import { existsSync, readFileSync } from "node:fs";
11
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
  // NOTE: kept dependency-free (node builtins only); this file is vendored verbatim into
14
14
  // a downstream consumer, so it cannot import the coordEnv helper.
@@ -144,7 +144,7 @@ export function resolveOwner(): string | null {
144
144
  */
145
145
  export function resolveOwnerWithSource(): {
146
146
  owner: string | null;
147
- source: "env" | "pidmap" | "pidmap_fallback" | "none";
147
+ source: "env" | "pidmap" | "pidmap_fallback" | "session_env" | "active_singleton" | "none";
148
148
  } {
149
149
  const envOwner = process.env.HARNERY_AGENT_COORD_OWNER?.trim();
150
150
  if (envOwner) {
@@ -173,10 +173,134 @@ export function resolveOwnerWithSource(): {
173
173
  }
174
174
  pid = readPpid(pid);
175
175
  }
176
- return {
177
- owner: fallbackOwner,
178
- source: fallbackOwner ? "pidmap_fallback" : "none",
179
- };
176
+ if (fallbackOwner) {
177
+ return { owner: fallbackOwner, source: "pidmap_fallback" };
178
+ }
179
+
180
+ // The ppid walk found nothing (e.g. a Bash-tool subshell whose process tree
181
+ // doesn't climb back to the harness anchor). Before guessing, try the
182
+ // harness-provided session id from the environment: every supported harness
183
+ // exports its session id into the tool subprocess env, and each heartbeat
184
+ // records the `session_id` it was minted under. Matching the two resolves us
185
+ // unambiguously even with multiple live agents, which is the case the
186
+ // singleton fallback below cannot handle.
187
+ const bySession = resolveOwnerBySessionEnv(root);
188
+ if (bySession) {
189
+ return { owner: bySession, source: "session_env" };
190
+ }
191
+
192
+ // Last resort: if exactly one agent is live in this coord root, it's
193
+ // unambiguously us — resolve to it. This is what lets the bare `agents
194
+ // status` / `set-task` the stop hook recommends work without a `--session-id`
195
+ // flag in the common single-agent case. With 0 or 2+ live agents it would be
196
+ // a guess, so we stay null and require the explicit flag.
197
+ const singleton = resolveSingleActiveOwner(root);
198
+ if (singleton) {
199
+ return { owner: singleton, source: "active_singleton" };
200
+ }
201
+
202
+ return { owner: null, source: "none" };
203
+ }
204
+
205
+ /**
206
+ * Harness-exported session-id environment variables, in precedence order. Each
207
+ * supported harness propagates its session id into the env of the subprocess it
208
+ * spawns for a tool call (Claude Code's Bash tool, Cursor's terminal, Codex's
209
+ * shell). A coord CLI invoked as such a tool can therefore recover its own
210
+ * identity from the env even when the ppid walk misses.
211
+ *
212
+ * Kept inline (no shared-helper import) so this file stays node-builtins-only
213
+ * for the vendored downstream consumer.
214
+ */
215
+ const SESSION_ID_ENV_VARS = [
216
+ "HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
217
+ "CLAUDE_CODE_SESSION_ID",
218
+ "CURSOR_SESSION_ID",
219
+ "CODEX_SESSION_ID",
220
+ ] as const;
221
+
222
+ /** Read the first non-empty harness session-id env var, or null. */
223
+ function sessionIdFromEnv(): string | null {
224
+ for (const key of SESSION_ID_ENV_VARS) {
225
+ const v = process.env[key]?.trim();
226
+ if (v) return v;
227
+ }
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Resolve the owner by matching the harness session-id env var against the
233
+ * `session_id` of a live heartbeat in this coord root. Returns the matching
234
+ * `instance_id`, or null if there's no session-id env var or no live heartbeat
235
+ * carries it. "Live" reuses the same 10-minute freshness window the singleton
236
+ * fallback applies, so a stale heartbeat from a prior session of the same id
237
+ * doesn't resolve.
238
+ *
239
+ * Exported for unit testing with an injectable root.
240
+ */
241
+ export function resolveOwnerBySessionEnv(root: string): string | null {
242
+ const sessionId = sessionIdFromEnv();
243
+ if (!sessionId) return null;
244
+
245
+ const activeDir = resolve(root, ".harnery", "active");
246
+ if (!existsSync(activeDir)) return null;
247
+ const FRESHNESS_SECS = 600;
248
+ const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
249
+ let files: string[];
250
+ try {
251
+ files = readdirSync(activeDir);
252
+ } catch {
253
+ return null;
254
+ }
255
+ for (const file of files) {
256
+ if (!file.endsWith(".json")) continue;
257
+ try {
258
+ const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
259
+ if (!parsed || parsed.session_id !== sessionId) continue;
260
+ if (typeof parsed.instance_id !== "string") continue;
261
+ const ts = Date.parse(parsed.last_heartbeat);
262
+ if (Number.isFinite(ts) && ts >= cutoffMs) return parsed.instance_id;
263
+ } catch {
264
+ // skip malformed
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+
270
+ /**
271
+ * Return the instance_id of the sole live agent in this coord root, or null
272
+ * if there are zero or more than one. "Live" reuses the 10-minute heartbeat
273
+ * freshness window the rest of the agents surface applies (kept inline as a
274
+ * literal so this file stays node-builtins-only for vendored downstream use).
275
+ *
276
+ * Exported for unit testing with an injectable root (the caller in
277
+ * `resolveOwnerWithSource` passes `monorepoRoot()`).
278
+ */
279
+ export function resolveSingleActiveOwner(root: string): string | null {
280
+ const activeDir = resolve(root, ".harnery", "active");
281
+ if (!existsSync(activeDir)) return null;
282
+ const FRESHNESS_SECS = 600;
283
+ const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
284
+ const live: string[] = [];
285
+ let files: string[];
286
+ try {
287
+ files = readdirSync(activeDir);
288
+ } catch {
289
+ return null;
290
+ }
291
+ for (const file of files) {
292
+ if (!file.endsWith(".json")) continue;
293
+ try {
294
+ const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
295
+ if (!parsed || typeof parsed.instance_id !== "string") continue;
296
+ const ts = Date.parse(parsed.last_heartbeat);
297
+ if (Number.isFinite(ts) && ts >= cutoffMs) live.push(parsed.instance_id);
298
+ } catch {
299
+ // skip malformed
300
+ }
301
+ if (live.length > 1) return null; // ambiguous; bail early
302
+ }
303
+ return live.length === 1 ? live[0]! : null;
180
304
  }
181
305
 
182
306
  /**
@@ -237,13 +361,26 @@ export function selfDisplayName(): string {
237
361
  }
238
362
 
239
363
  function readPpid(pid: number): number | null {
364
+ // Linux/WSL fast path: /proc/<pid>/status carries `PPid:`.
240
365
  try {
241
366
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
242
367
  const m = status.match(/^PPid:\s+(\d+)/m);
243
- if (!m) return null;
244
- const parsed = Number.parseInt(m[1]!, 10);
245
- 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
+ }
246
372
  } catch {
247
- return null;
373
+ // no /proc (macOS/BSD) — fall through to ps
248
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;
249
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
  }