harnery 0.8.0 → 0.10.0

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.
Files changed (234) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +8 -0
  4. package/dist/commands/agents.d.ts +24 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +244 -13
  7. package/dist/commands/backup.d.ts +5 -4
  8. package/dist/commands/backup.d.ts.map +1 -1
  9. package/dist/commands/backup.js +15 -14
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +27 -1
  12. package/dist/commands/claude-desktop.d.ts +19 -0
  13. package/dist/commands/claude-desktop.d.ts.map +1 -0
  14. package/dist/commands/claude-desktop.js +168 -0
  15. package/dist/commands/context.d.ts.map +1 -1
  16. package/dist/commands/context.js +149 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +37 -0
  19. package/dist/commands/eml.d.ts +32 -0
  20. package/dist/commands/eml.d.ts.map +1 -1
  21. package/dist/commands/eml.js +16 -3
  22. package/dist/commands/grep.d.ts +35 -2
  23. package/dist/commands/grep.d.ts.map +1 -1
  24. package/dist/commands/grep.js +427 -139
  25. package/dist/commands/harness.d.ts +11 -0
  26. package/dist/commands/harness.d.ts.map +1 -0
  27. package/dist/commands/harness.js +118 -0
  28. package/dist/commands/init.d.ts +15 -5
  29. package/dist/commands/init.d.ts.map +1 -1
  30. package/dist/commands/init.js +125 -14
  31. package/dist/commands/presence.d.ts +9 -4
  32. package/dist/commands/presence.d.ts.map +1 -1
  33. package/dist/commands/presence.js +88 -5
  34. package/dist/commands/relay.d.ts +9 -0
  35. package/dist/commands/relay.d.ts.map +1 -0
  36. package/dist/commands/relay.js +143 -0
  37. package/dist/commands/sync.d.ts.map +1 -1
  38. package/dist/commands/sync.js +5 -0
  39. package/dist/commands/workflow.d.ts +4 -0
  40. package/dist/commands/workflow.d.ts.map +1 -0
  41. package/dist/commands/workflow.js +82 -0
  42. package/dist/core/agents/canonical-emit.d.ts +15 -0
  43. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  44. package/dist/core/agents/canonical-emit.js +41 -3
  45. package/dist/core/agents/cli.js +14 -5
  46. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  47. package/dist/core/agents/render/prompt-context.js +44 -3
  48. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/session-context.js +26 -1
  50. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  51. package/dist/core/agents/rules/claim-conflict.js +26 -1
  52. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  53. package/dist/core/agents/rules/commit-conflict.js +4 -37
  54. package/dist/core/agents/rules/stop-hook.d.ts +8 -0
  55. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  56. package/dist/core/agents/rules/stop-hook.js +8 -0
  57. package/dist/core/agents/session-events.d.ts.map +1 -1
  58. package/dist/core/agents/session-events.js +16 -43
  59. package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
  60. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  61. package/dist/core/agents/state/heartbeat-projector.js +24 -4
  62. package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
  63. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  64. package/dist/core/agents/state/heartbeat-writer.js +24 -7
  65. package/dist/core/agents/state/names.d.ts +38 -3
  66. package/dist/core/agents/state/names.d.ts.map +1 -1
  67. package/dist/core/agents/state/names.js +46 -7
  68. package/dist/core/agents/state/pidmap.d.ts +5 -1
  69. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  70. package/dist/core/agents/state/pidmap.js +58 -2
  71. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  72. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  73. package/dist/core/agents/state/stale-sweep.js +6 -6
  74. package/dist/core/config.d.ts +77 -11
  75. package/dist/core/config.d.ts.map +1 -1
  76. package/dist/core/config.js +210 -25
  77. package/dist/core/context/index.d.ts +144 -0
  78. package/dist/core/context/index.d.ts.map +1 -0
  79. package/dist/core/context/index.js +380 -0
  80. package/dist/core/harnesses/bench.d.ts +29 -0
  81. package/dist/core/harnesses/bench.d.ts.map +1 -0
  82. package/dist/core/harnesses/bench.js +151 -0
  83. package/dist/core/harnesses/index.d.ts +9 -0
  84. package/dist/core/harnesses/index.d.ts.map +1 -0
  85. package/dist/core/harnesses/index.js +4 -0
  86. package/dist/core/harnesses/profiles.d.ts +62 -0
  87. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  88. package/dist/core/harnesses/profiles.js +115 -0
  89. package/dist/core/harnesses/registry.d.ts +15 -0
  90. package/dist/core/harnesses/registry.d.ts.map +1 -0
  91. package/dist/core/harnesses/registry.js +113 -0
  92. package/dist/core/harnesses/types.d.ts +64 -0
  93. package/dist/core/harnesses/types.d.ts.map +1 -0
  94. package/dist/core/harnesses/types.js +20 -0
  95. package/dist/core/hooks/cli.js +309 -66
  96. package/dist/core/hooks/events/schema.d.ts +42 -1
  97. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  98. package/dist/core/hooks/harness/events.d.ts +7 -0
  99. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  100. package/dist/core/hooks/harness/events.js +4 -0
  101. package/dist/core/hooks/harness/parse.d.ts +1 -1
  102. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  103. package/dist/core/hooks/harness/parse.js +4 -0
  104. package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
  105. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  106. package/dist/core/hooks/resolve/coord-root.js +28 -6
  107. package/dist/core/presence/blob.d.ts +40 -0
  108. package/dist/core/presence/blob.d.ts.map +1 -0
  109. package/dist/core/presence/blob.js +91 -0
  110. package/dist/core/presence/git.d.ts +68 -0
  111. package/dist/core/presence/git.d.ts.map +1 -0
  112. package/dist/core/presence/git.js +173 -0
  113. package/dist/core/presence/index.d.ts +74 -0
  114. package/dist/core/presence/index.d.ts.map +1 -0
  115. package/dist/core/presence/index.js +224 -0
  116. package/dist/core/presence/relay-client.d.ts +39 -0
  117. package/dist/core/presence/relay-client.d.ts.map +1 -0
  118. package/dist/core/presence/relay-client.js +295 -0
  119. package/dist/core/presence/relay-protocol.d.ts +94 -0
  120. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  121. package/dist/core/presence/relay-protocol.js +169 -0
  122. package/dist/core/workflow/billing.d.ts +48 -0
  123. package/dist/core/workflow/billing.d.ts.map +1 -0
  124. package/dist/core/workflow/billing.js +108 -0
  125. package/dist/core/workflow/child-env.d.ts +30 -0
  126. package/dist/core/workflow/child-env.d.ts.map +1 -0
  127. package/dist/core/workflow/child-env.js +43 -0
  128. package/dist/core/workflow/engine.d.ts +21 -0
  129. package/dist/core/workflow/engine.d.ts.map +1 -0
  130. package/dist/core/workflow/engine.js +349 -0
  131. package/dist/core/workflow/harnesses.d.ts +17 -0
  132. package/dist/core/workflow/harnesses.d.ts.map +1 -0
  133. package/dist/core/workflow/harnesses.js +21 -0
  134. package/dist/core/workflow/spawn-claude.d.ts +25 -0
  135. package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
  136. package/dist/core/workflow/spawn-claude.js +103 -0
  137. package/dist/core/workflow/spawn-codex.d.ts +22 -0
  138. package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
  139. package/dist/core/workflow/spawn-codex.js +88 -0
  140. package/dist/core/workflow/spawn-cursor.d.ts +29 -0
  141. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
  142. package/dist/core/workflow/spawn-cursor.js +91 -0
  143. package/dist/core/workflow/types.d.ts +152 -0
  144. package/dist/core/workflow/types.d.ts.map +1 -0
  145. package/dist/core/workflow/types.js +9 -0
  146. package/dist/core/workflow/validate.d.ts +15 -0
  147. package/dist/core/workflow/validate.d.ts.map +1 -0
  148. package/dist/core/workflow/validate.js +70 -0
  149. package/dist/lib/browser/client.d.ts +16 -0
  150. package/dist/lib/browser/client.d.ts.map +1 -1
  151. package/dist/lib/browser/client.js +22 -0
  152. package/dist/lib/browser/index.d.ts +1 -0
  153. package/dist/lib/browser/index.d.ts.map +1 -1
  154. package/dist/lib/browser/index.js +1 -0
  155. package/dist/lib/browser/launch-args.d.ts +21 -0
  156. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  157. package/dist/lib/browser/launch-args.js +32 -0
  158. package/dist/lib/claude-desktop.d.ts +105 -0
  159. package/dist/lib/claude-desktop.d.ts.map +1 -0
  160. package/dist/lib/claude-desktop.js +217 -0
  161. package/dist/lib/docs-lint.d.ts.map +1 -1
  162. package/dist/lib/docs-lint.js +5 -2
  163. package/dist/lib/identities/assume.d.ts +51 -0
  164. package/dist/lib/identities/assume.d.ts.map +1 -0
  165. package/dist/lib/identities/assume.js +274 -0
  166. package/dist/lib/identities/index.d.ts +7 -7
  167. package/dist/lib/identities/index.d.ts.map +1 -1
  168. package/dist/lib/identities/index.js +24 -24
  169. package/dist/lib/instructions/templates.d.ts.map +1 -1
  170. package/dist/lib/instructions/templates.js +15 -2
  171. package/package.json +12 -1
  172. package/schemas/config.schema.json +88 -19
  173. package/src/commander.ts +8 -0
  174. package/src/commands/agents.ts +280 -14
  175. package/src/commands/backup.ts +17 -16
  176. package/src/commands/browse.ts +32 -0
  177. package/src/commands/claude-desktop.ts +215 -0
  178. package/src/commands/context.ts +174 -1
  179. package/src/commands/doctor.ts +44 -0
  180. package/src/commands/eml.ts +22 -4
  181. package/src/commands/grep.ts +535 -142
  182. package/src/commands/harness.ts +147 -0
  183. package/src/commands/init.ts +138 -17
  184. package/src/commands/presence.ts +111 -5
  185. package/src/commands/relay.ts +160 -0
  186. package/src/commands/sync.ts +4 -0
  187. package/src/commands/workflow.ts +123 -0
  188. package/src/core/agents/canonical-emit.ts +41 -3
  189. package/src/core/agents/cli.ts +15 -6
  190. package/src/core/agents/render/prompt-context.ts +48 -3
  191. package/src/core/agents/render/session-context.ts +27 -1
  192. package/src/core/agents/rules/claim-conflict.ts +26 -1
  193. package/src/core/agents/rules/commit-conflict.ts +4 -33
  194. package/src/core/agents/rules/stop-hook.ts +17 -0
  195. package/src/core/agents/session-events.ts +15 -39
  196. package/src/core/agents/state/heartbeat-projector.ts +23 -2
  197. package/src/core/agents/state/heartbeat-writer.ts +33 -7
  198. package/src/core/agents/state/names.ts +68 -9
  199. package/src/core/agents/state/pidmap.ts +59 -2
  200. package/src/core/agents/state/stale-sweep.ts +6 -11
  201. package/src/core/config.ts +271 -24
  202. package/src/core/context/index.ts +575 -0
  203. package/src/core/harnesses/bench.ts +214 -0
  204. package/src/core/harnesses/index.ts +31 -0
  205. package/src/core/harnesses/profiles.ts +141 -0
  206. package/src/core/harnesses/registry.ts +140 -0
  207. package/src/core/harnesses/types.ts +90 -0
  208. package/src/core/hooks/cli.ts +361 -65
  209. package/src/core/hooks/events/schema.ts +72 -0
  210. package/src/core/hooks/harness/events.ts +11 -0
  211. package/src/core/hooks/harness/parse.ts +7 -1
  212. package/src/core/hooks/resolve/coord-root.ts +28 -6
  213. package/src/core/presence/blob.ts +125 -0
  214. package/src/core/presence/git.ts +191 -0
  215. package/src/core/presence/index.ts +274 -0
  216. package/src/core/presence/relay-client.ts +322 -0
  217. package/src/core/presence/relay-protocol.ts +246 -0
  218. package/src/core/workflow/billing.ts +152 -0
  219. package/src/core/workflow/child-env.ts +47 -0
  220. package/src/core/workflow/engine.ts +403 -0
  221. package/src/core/workflow/harnesses.ts +33 -0
  222. package/src/core/workflow/spawn-claude.ts +118 -0
  223. package/src/core/workflow/spawn-codex.ts +93 -0
  224. package/src/core/workflow/spawn-cursor.ts +108 -0
  225. package/src/core/workflow/types.ts +160 -0
  226. package/src/core/workflow/validate.ts +75 -0
  227. package/src/lib/browser/client.ts +37 -0
  228. package/src/lib/browser/index.ts +1 -0
  229. package/src/lib/browser/launch-args.ts +33 -0
  230. package/src/lib/claude-desktop.ts +293 -0
  231. package/src/lib/docs-lint.ts +5 -2
  232. package/src/lib/identities/assume.ts +363 -0
  233. package/src/lib/identities/index.ts +28 -24
  234. package/src/lib/instructions/templates.ts +16 -2
@@ -24,6 +24,8 @@ export interface V2Heartbeat {
24
24
  platform?: string;
25
25
  subagent_call_id?: string;
26
26
  parent_session_id?: string;
27
+ /** Set iff this owner is a `workflow run` child (joins to the run journal). */
28
+ workflow_run_id?: string;
27
29
  started_at?: string;
28
30
  last_heartbeat: string;
29
31
  last_tool?: string;
@@ -144,7 +146,8 @@ function seed(ev: CanonicalEvent, coordRoot: string): V2Heartbeat {
144
146
  if (resolved) {
145
147
  hb.name = resolved.name;
146
148
  hb.kind = resolved.kind;
147
- if (resolved.kind === "subagent") hb.agent_id = ev.instance_id;
149
+ hb.agent_id =
150
+ resolved.agent_id ?? (resolved.kind === "subagent" ? ev.instance_id : undefined);
148
151
  }
149
152
  } catch {
150
153
  /* name-history unavailable: seed stays nameless; sweep + render guards cope */
@@ -184,6 +187,8 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
184
187
  if (subagentCallId) hb.subagent_call_id = subagentCallId;
185
188
  const parentSession = pickStr(d, "parent_session_id");
186
189
  if (parentSession) hb.parent_session_id = parentSession;
190
+ const workflowRunId = pickStr(d, "workflow_run_id");
191
+ if (workflowRunId) hb.workflow_run_id = workflowRunId;
187
192
  if (!hb.files_touched) hb.files_touched = [];
188
193
  }
189
194
  break;
@@ -248,8 +253,15 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
248
253
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
249
254
  const target = extractFilePath(d);
250
255
  if (target) {
256
+ // Canonicalize to repo-relative before storing: the claim guard
257
+ // writes canonical paths directly, so an absolute entry here would
258
+ // double-count the same file (inflated "N files" display) and
259
+ // defeat exact-match pruning on commit.
260
+ const canonical = target.startsWith(`${coordRoot}/`)
261
+ ? target.slice(coordRoot.length + 1)
262
+ : target;
251
263
  if (!hb.files_touched) hb.files_touched = [];
252
- if (!hb.files_touched.includes(target)) hb.files_touched.push(target);
264
+ if (!hb.files_touched.includes(canonical)) hb.files_touched.push(canonical);
253
265
  }
254
266
  }
255
267
  break;
@@ -276,6 +288,14 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
276
288
  hb.last_status_at = ev.ts;
277
289
  break;
278
290
 
291
+ case "identity.assumed": {
292
+ const name = pickStr(d, "name");
293
+ const agentId = pickStr(d, "agent_id");
294
+ if (name) hb.name = name;
295
+ if (agentId) hb.agent_id = agentId;
296
+ break;
297
+ }
298
+
279
299
  case "state.presence_change": {
280
300
  const to = pickStr(d, "to");
281
301
  if (to === "mobile" || to === "office") hb.presence = to;
@@ -437,6 +457,7 @@ function writeHeartbeat(coordRoot: string, instanceId: string, hb: V2Heartbeat):
437
457
  setIfDefined(merged, "kind", hb.kind);
438
458
  setIfDefined(merged, "agent_id", hb.agent_id);
439
459
  setIfDefined(merged, "subagent_call_id", hb.subagent_call_id);
460
+ setIfDefined(merged, "workflow_run_id", hb.workflow_run_id);
440
461
  setIfDefined(merged, "model", hb.model);
441
462
  setIfDefined(merged, "platform", hb.platform);
442
463
  setIfDefined(merged, "started_at", hb.started_at);
@@ -184,20 +184,39 @@ export function releaseClaim(
184
184
  }));
185
185
  }
186
186
 
187
+ /** A heartbeat that actually dropped a path during a group unclaim. */
188
+ export interface GroupUnclaimHit {
189
+ instance_id: string;
190
+ session_id?: string;
191
+ platform?: string;
192
+ }
193
+
187
194
  /**
188
195
  * Session-group-wide unclaim. Walks every heartbeat sharing `groupId`
189
196
  * (parent's session_id == group_id;
190
197
  * subagents inherit it) and removes the path from each one's files_touched.
191
- * Idempotent: heartbeats that don't hold the path are untouched.
198
+ * Idempotent: heartbeats that don't hold the path are untouched. Returns the
199
+ * heartbeats that actually dropped the path so the caller can emit the
200
+ * durable `claim.release` events — a file-only prune is silently reverted by
201
+ * the next projector replay.
202
+ *
203
+ * files_touched can hold either absolute-under-coordRoot or canonical
204
+ * repo-relative entries (legacy projections stored the raw tool_input path),
205
+ * so both sides are normalized before comparing — an exact-string match
206
+ * silently no-ops on the mixed-form case and the claim never releases.
192
207
  *
193
208
  * This is the Option B fix for post-commit's pid-map attribution hole: a
194
209
  * subagent-held claim that doesn't live on the parent's heartbeat still gets
195
210
  * pruned because the walk covers the whole group.
196
211
  */
197
- export function groupUnclaim(coordRoot: string, groupId: string, path: string): void {
198
- if (!groupId || !path) return;
212
+ export function groupUnclaim(coordRoot: string, groupId: string, path: string): GroupUnclaimHit[] {
213
+ const hits: GroupUnclaimHit[] = [];
214
+ if (!groupId || !path) return hits;
199
215
  const activeDir = join(coordRoot, ".harnery", "active");
200
- if (!existsSync(activeDir)) return;
216
+ if (!existsSync(activeDir)) return hits;
217
+ const norm = (p: string): string =>
218
+ p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
219
+ const target = norm(path);
201
220
  for (const f of readdirSync(activeDir)) {
202
221
  if (!f.endsWith(".json")) continue;
203
222
  const hbPath = join(activeDir, f);
@@ -211,17 +230,23 @@ export function groupUnclaim(coordRoot: string, groupId: string, path: string):
211
230
  (body.session_id as string | undefined) ?? (body.instance_id as string | undefined);
212
231
  if (peerSession !== groupId) continue;
213
232
  const files = (body.files_touched as string[] | undefined) ?? [];
214
- const next = files.filter((p) => p !== path);
233
+ const next = files.filter((p) => norm(p) !== target);
215
234
  if (next.length === files.length) continue;
216
235
  body.files_touched = next;
217
236
  try {
218
237
  const tmp = `${hbPath}.tmp.${process.pid}`;
219
238
  writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
220
239
  renameSync(tmp, hbPath);
240
+ hits.push({
241
+ instance_id: (body.instance_id as string | undefined) ?? f.replace(/\.json$/, ""),
242
+ session_id: body.session_id as string | undefined,
243
+ platform: body.platform as string | undefined,
244
+ });
221
245
  } catch {
222
246
  /* silent */
223
247
  }
224
248
  }
249
+ return hits;
225
250
  }
226
251
 
227
252
  export function killHeartbeat(coordRoot: string, instanceId: string): boolean {
@@ -286,8 +311,9 @@ export function healHeartbeat(
286
311
  if (resolved) {
287
312
  name = resolved.name;
288
313
  kind = resolved.kind;
289
- // subagent kind: agent_id == instance_id. Else (session/transient): no id stamp.
290
- if (resolved.kind === "subagent") agentId = instanceId;
314
+ // An explicitly assumed session carries its durable persona UUID in
315
+ // name-history. Native subagents continue to use instance_id.
316
+ agentId = resolved.agent_id ?? (resolved.kind === "subagent" ? instanceId : "");
291
317
  }
292
318
  } catch {
293
319
  /* names module unavailable, fall back to empty */
@@ -8,9 +8,14 @@
8
8
  * Durable persistence: `.harnery/.name-history` (JSONL, one row per assignment)
9
9
  * + `.harnery/.name-counter` (current counter, atomic temp+rename).
10
10
  *
11
- * Recreation rule (mirrors v1):
12
- * 1. Own instance_id in name-history → (original name, original kind)
13
- * 2. session_id in name-history (owner != session) → (parent's name, "transient")
11
+ * Recreation rule:
12
+ * 1. Own instance_id in name-history → latest (name, kind, agent_id)
13
+ * 2. session_id in name-history (owner != session) → latest parent's
14
+ * (name, agent_id) with kind="transient"
15
+ *
16
+ * Multiple rows for one instance are intentional. `identity assume` appends a
17
+ * new binding instead of rewriting history; readers therefore resolve from the
18
+ * end of the file. Older one-row histories retain their original behavior.
14
19
  * 3. Else: new assignment, consume a counter slot.
15
20
  */
16
21
 
@@ -297,11 +302,16 @@ if (COORD_NAMES.length !== 260) {
297
302
 
298
303
  export type NameKind = "session" | "subagent" | "transient";
299
304
 
300
- interface NameHistoryRow {
305
+ export interface NameHistoryRow {
301
306
  instance_id: string;
302
307
  name: string;
303
308
  kind: NameKind;
304
309
  ts: string;
310
+ /** Durable persona UUID. Present after `agents identity assume`. */
311
+ agent_id?: string;
312
+ /** Audit marker distinguishing an explicit role adoption from pool assignment. */
313
+ source?: "pool" | "identity.assume";
314
+ previous_name?: string;
305
315
  }
306
316
 
307
317
  function atomicWrite(path: string, content: string): void {
@@ -351,19 +361,29 @@ export function resolveName(
351
361
  coordRoot: string,
352
362
  instanceId: string,
353
363
  sessionId?: string,
354
- ): { name: string; kind: NameKind } | null {
364
+ ): { name: string; kind: NameKind; agent_id?: string } | null {
355
365
  const history = readHistory(coordRoot);
356
366
 
357
- for (const row of history) {
367
+ for (let i = history.length - 1; i >= 0; i--) {
368
+ const row = history[i]!;
358
369
  if (row.instance_id === instanceId) {
359
- return { name: row.name, kind: row.kind };
370
+ return {
371
+ name: row.name,
372
+ kind: row.kind,
373
+ ...(row.agent_id ? { agent_id: row.agent_id } : {}),
374
+ };
360
375
  }
361
376
  }
362
377
 
363
378
  if (sessionId && sessionId !== instanceId) {
364
- for (const row of history) {
379
+ for (let i = history.length - 1; i >= 0; i--) {
380
+ const row = history[i]!;
365
381
  if (row.instance_id === sessionId) {
366
- return { name: row.name, kind: "transient" };
382
+ return {
383
+ name: row.name,
384
+ kind: "transient",
385
+ ...(row.agent_id ? { agent_id: row.agent_id } : {}),
386
+ };
367
387
  }
368
388
  }
369
389
  }
@@ -371,6 +391,44 @@ export function resolveName(
371
391
  return null;
372
392
  }
373
393
 
394
+ export interface NameAssumptionResult {
395
+ changed: boolean;
396
+ previous: { name: string; kind: NameKind; agent_id?: string } | null;
397
+ current: { name: string; kind: NameKind; agent_id: string };
398
+ }
399
+
400
+ /**
401
+ * Append an explicit instance → durable-persona binding. Latest-row-wins makes
402
+ * this auditable and retry-safe without mutating prior assignment history.
403
+ */
404
+ export function recordNameAssumption(
405
+ coordRoot: string,
406
+ instanceId: string,
407
+ name: string,
408
+ agentId: string,
409
+ kind: NameKind = "session",
410
+ ): NameAssumptionResult {
411
+ const previous = resolveName(coordRoot, instanceId);
412
+ const current = { name, kind, agent_id: agentId };
413
+ if (
414
+ previous?.name === current.name &&
415
+ previous.kind === current.kind &&
416
+ previous.agent_id === current.agent_id
417
+ ) {
418
+ return { changed: false, previous, current };
419
+ }
420
+ appendHistory(coordRoot, {
421
+ instance_id: instanceId,
422
+ name,
423
+ kind,
424
+ agent_id: agentId,
425
+ source: "identity.assume",
426
+ previous_name: previous?.name,
427
+ ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
428
+ });
429
+ return { changed: true, previous, current };
430
+ }
431
+
374
432
  /**
375
433
  * Assign a name to <instanceId> with the given <kind>. Counter-consuming when
376
434
  * the owner is new. Idempotent: returns existing name on resume.
@@ -393,6 +451,7 @@ export function assignName(coordRoot: string, instanceId: string, kind: NameKind
393
451
  instance_id: instanceId,
394
452
  name,
395
453
  kind,
454
+ source: "pool",
396
455
  ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
397
456
  });
398
457
  return name;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pid-map writer.
2
+ * Pid-map writer + liveness helpers.
3
3
  *
4
4
  * Per-harness pid-map at `.harnery/pid-map/<pid>` containing
5
5
  * `<instance_id>\t<platform>`. `harn agents whoami` walks ppid up 20 hops looking
@@ -8,7 +8,15 @@
8
8
  * Atomic temp+rename. Idempotent: re-writing the same row is a no-op.
9
9
  */
10
10
 
11
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ renameSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from "node:fs";
12
20
  import { dirname, join } from "node:path";
13
21
 
14
22
  function atomicWrite(path: string, content: string): void {
@@ -36,3 +44,52 @@ export function writePidmapRow(
36
44
  }
37
45
  atomicWrite(path, row);
38
46
  }
47
+
48
+ /** True when any pid-map row for `instanceId` still belongs to a live process. */
49
+ export function instanceHasLivePid(coordRoot: string, instanceId: string): boolean {
50
+ const dir = join(coordRoot, ".harnery", "pid-map");
51
+ if (!existsSync(dir)) return false;
52
+ for (const f of readdirSync(dir)) {
53
+ let row = "";
54
+ try {
55
+ row = readFileSync(join(dir, f), "utf8").trim();
56
+ } catch {
57
+ continue;
58
+ }
59
+ const owner = row.split("\t")[0]?.trim() ?? "";
60
+ if (owner !== instanceId) continue;
61
+ const pid = Number.parseInt(f, 10);
62
+ if (!Number.isFinite(pid)) continue;
63
+ try {
64
+ process.kill(pid, 0); // signal 0 = liveness probe
65
+ return true;
66
+ } catch {
67
+ // ESRCH (no such process): pid-map entry is stale, keep scanning
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** Drop every pid-map row owned by `instanceId`. Returns how many files were removed. */
74
+ export function removePidmapRowsForInstance(coordRoot: string, instanceId: string): number {
75
+ const dir = join(coordRoot, ".harnery", "pid-map");
76
+ if (!existsSync(dir)) return 0;
77
+ let removed = 0;
78
+ for (const f of readdirSync(dir)) {
79
+ let row = "";
80
+ try {
81
+ row = readFileSync(join(dir, f), "utf8").trim();
82
+ } catch {
83
+ continue;
84
+ }
85
+ const owner = row.split("\t")[0]?.trim() ?? "";
86
+ if (owner !== instanceId) continue;
87
+ try {
88
+ unlinkSync(join(dir, f));
89
+ removed += 1;
90
+ } catch {
91
+ /* best-effort cleanup */
92
+ }
93
+ }
94
+ return removed;
95
+ }
@@ -5,17 +5,16 @@
5
5
  * Fires at session.start to clean up crashed-peer detritus before the new
6
6
  * session's UX layer reads peer state.
7
7
  *
8
- * Freshness threshold defaults to 600s; configurable via
9
- * HARNERY_AGENT_COORD_FRESHNESS env var.
8
+ * Freshness threshold defaults to 600s; configurable via the
9
+ * HARNERY_AGENT_COORD_FRESHNESS env var or `.harnery/config.jsonc`
10
+ * `coord.freshness_seconds` (see `coordFreshnessSeconds`).
10
11
  */
11
12
 
12
13
  import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
13
14
  import { join } from "node:path";
14
- import { coordEnv } from "../../../lib/env.ts";
15
+ import { coordFreshnessSeconds } from "../../config.ts";
15
16
  import { emit } from "../events/emit.ts";
16
17
 
17
- const DEFAULT_FRESHNESS_SECS = 600;
18
-
19
18
  /** platform → harness, for the swept-event envelope (mirrors heartbeat-writer's harnessOf). */
20
19
  function harnessFromPlatform(platform: unknown): "claude-code" | "cursor" | "codex" {
21
20
  if (platform === "cursor") return "cursor";
@@ -65,13 +64,9 @@ export function staleSweep(coordRoot: string): {
65
64
  let pidmapsRemoved = 0;
66
65
  let peerHashesRemoved = 0;
67
66
 
68
- const freshness = Number.parseInt(
69
- coordEnv("AGENT_COORD_FRESHNESS") ?? String(DEFAULT_FRESHNESS_SECS),
70
- 10,
71
- );
67
+ const freshness = coordFreshnessSeconds(coordRoot);
72
68
  const nowSec = Math.floor(Date.now() / 1000);
73
- const cutoff =
74
- nowSec - (Number.isFinite(freshness) && freshness > 0 ? freshness : DEFAULT_FRESHNESS_SECS);
69
+ const cutoff = nowSec - freshness;
75
70
 
76
71
  // 1. Prune stale heartbeats from the canonical `.harnery/active/` dir.
77
72
  //