harnery 0.3.0 → 0.3.1

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.
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",
@@ -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
  /**