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.
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +14 -1
- package/dist/core/agents/coord-client.d.ts +22 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +157 -10
- package/dist/core/hooks/cli.js +25 -20
- package/dist/core/hooks/resolve/anchor.d.ts +15 -0
- package/dist/core/hooks/resolve/anchor.d.ts.map +1 -1
- package/dist/core/hooks/resolve/anchor.js +22 -0
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.js +20 -8
- package/package.json +1 -1
- package/src/commands/agents.ts +14 -1
- package/src/core/agents/coord-client.ts +147 -10
- package/src/core/hooks/cli.ts +24 -20
- package/src/core/hooks/resolve/anchor.ts +21 -0
- package/src/core/hooks/resolve/owner.ts +17 -8
|
@@ -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;
|
|
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"}
|
package/dist/commands/agents.js
CHANGED
|
@@ -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)
|
|
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;
|
|
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
|
-
|
|
137
|
-
owner: fallbackOwner,
|
|
138
|
-
|
|
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 (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/hooks/cli.js
CHANGED
|
@@ -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
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
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)
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
852
|
-
const m = status.match(/^PPid:\s+(\d+)/m);
|
|
853
|
-
if (!m)
|
|
858
|
+
if (!hop)
|
|
854
859
|
break;
|
|
855
|
-
|
|
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":"
|
|
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:`.
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
/*
|
|
94
|
+
/* ps unavailable — give up */
|
|
83
95
|
}
|
|
84
96
|
return null;
|
|
85
97
|
}
|
package/package.json
CHANGED
package/src/commands/agents.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
177
|
-
owner: fallbackOwner,
|
|
178
|
-
|
|
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 (
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/hooks/cli.ts
CHANGED
|
@@ -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
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
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)
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
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
|
-
|
|
937
|
+
// no /proc (macOS/BSD) — fall through to ps
|
|
936
938
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
if (!
|
|
942
|
-
pid
|
|
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:`.
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
/*
|
|
96
|
+
/* ps unavailable — give up */
|
|
88
97
|
}
|
|
89
98
|
return null;
|
|
90
99
|
}
|