harnery 0.9.0 → 0.11.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 (211) hide show
  1. package/README.md +1 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +6 -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 +2 -3
  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/harness.d.ts +11 -0
  23. package/dist/commands/harness.d.ts.map +1 -0
  24. package/dist/commands/harness.js +118 -0
  25. package/dist/commands/presence.d.ts +9 -4
  26. package/dist/commands/presence.d.ts.map +1 -1
  27. package/dist/commands/presence.js +88 -5
  28. package/dist/commands/relay.d.ts +9 -0
  29. package/dist/commands/relay.d.ts.map +1 -0
  30. package/dist/commands/relay.js +143 -0
  31. package/dist/commands/sync.d.ts.map +1 -1
  32. package/dist/commands/sync.js +5 -0
  33. package/dist/commands/workflow.d.ts.map +1 -1
  34. package/dist/commands/workflow.js +53 -17
  35. package/dist/core/agents/canonical-emit.d.ts +15 -0
  36. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  37. package/dist/core/agents/canonical-emit.js +41 -3
  38. package/dist/core/agents/cli.js +4 -2
  39. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/prompt-context.js +44 -3
  41. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  42. package/dist/core/agents/render/session-context.js +26 -1
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +4 -37
  45. package/dist/core/agents/session-events.d.ts.map +1 -1
  46. package/dist/core/agents/session-events.js +16 -43
  47. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  48. package/dist/core/agents/state/heartbeat-projector.js +11 -2
  49. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  50. package/dist/core/agents/state/heartbeat-writer.js +3 -3
  51. package/dist/core/agents/state/names.d.ts +38 -3
  52. package/dist/core/agents/state/names.d.ts.map +1 -1
  53. package/dist/core/agents/state/names.js +46 -7
  54. package/dist/core/agents/state/pidmap.d.ts +5 -1
  55. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  56. package/dist/core/agents/state/pidmap.js +58 -2
  57. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  58. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  59. package/dist/core/agents/state/stale-sweep.js +6 -6
  60. package/dist/core/config.d.ts +68 -11
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +191 -25
  63. package/dist/core/context/index.d.ts +144 -0
  64. package/dist/core/context/index.d.ts.map +1 -0
  65. package/dist/core/context/index.js +380 -0
  66. package/dist/core/harnesses/bench.d.ts +29 -0
  67. package/dist/core/harnesses/bench.d.ts.map +1 -0
  68. package/dist/core/harnesses/bench.js +151 -0
  69. package/dist/core/harnesses/index.d.ts +9 -0
  70. package/dist/core/harnesses/index.d.ts.map +1 -0
  71. package/dist/core/harnesses/index.js +4 -0
  72. package/dist/core/harnesses/profiles.d.ts +62 -0
  73. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  74. package/dist/core/harnesses/profiles.js +115 -0
  75. package/dist/core/harnesses/registry.d.ts +15 -0
  76. package/dist/core/harnesses/registry.d.ts.map +1 -0
  77. package/dist/core/harnesses/registry.js +113 -0
  78. package/dist/core/harnesses/types.d.ts +64 -0
  79. package/dist/core/harnesses/types.d.ts.map +1 -0
  80. package/dist/core/harnesses/types.js +20 -0
  81. package/dist/core/hooks/cli.js +280 -63
  82. package/dist/core/hooks/events/schema.d.ts +38 -1
  83. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  84. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  85. package/dist/core/hooks/harness/events.js +3 -0
  86. package/dist/core/hooks/harness/parse.d.ts +1 -1
  87. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  88. package/dist/core/hooks/harness/parse.js +4 -0
  89. package/dist/core/presence/blob.d.ts +40 -0
  90. package/dist/core/presence/blob.d.ts.map +1 -0
  91. package/dist/core/presence/blob.js +91 -0
  92. package/dist/core/presence/git.d.ts +68 -0
  93. package/dist/core/presence/git.d.ts.map +1 -0
  94. package/dist/core/presence/git.js +173 -0
  95. package/dist/core/presence/index.d.ts +74 -0
  96. package/dist/core/presence/index.d.ts.map +1 -0
  97. package/dist/core/presence/index.js +224 -0
  98. package/dist/core/presence/relay-client.d.ts +39 -0
  99. package/dist/core/presence/relay-client.d.ts.map +1 -0
  100. package/dist/core/presence/relay-client.js +295 -0
  101. package/dist/core/presence/relay-protocol.d.ts +94 -0
  102. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  103. package/dist/core/presence/relay-protocol.js +169 -0
  104. package/dist/core/workflow/billing.d.ts +1 -1
  105. package/dist/core/workflow/billing.d.ts.map +1 -1
  106. package/dist/core/workflow/billing.js +7 -1
  107. package/dist/core/workflow/engine.d.ts +5 -0
  108. package/dist/core/workflow/engine.d.ts.map +1 -1
  109. package/dist/core/workflow/engine.js +164 -16
  110. package/dist/core/workflow/harnesses.d.ts +3 -3
  111. package/dist/core/workflow/harnesses.d.ts.map +1 -1
  112. package/dist/core/workflow/harnesses.js +8 -17
  113. package/dist/core/workflow/index.d.ts +5 -0
  114. package/dist/core/workflow/index.d.ts.map +1 -0
  115. package/dist/core/workflow/index.js +3 -0
  116. package/dist/core/workflow/proof.d.ts +42 -0
  117. package/dist/core/workflow/proof.d.ts.map +1 -0
  118. package/dist/core/workflow/proof.js +342 -0
  119. package/dist/core/workflow/spawn-claude.d.ts +4 -1
  120. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  121. package/dist/core/workflow/spawn-claude.js +39 -18
  122. package/dist/core/workflow/spawn-codex.d.ts +4 -1
  123. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  124. package/dist/core/workflow/spawn-codex.js +44 -22
  125. package/dist/core/workflow/spawn-cursor.d.ts +4 -1
  126. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  127. package/dist/core/workflow/spawn-cursor.js +35 -16
  128. package/dist/core/workflow/types.d.ts +156 -2
  129. package/dist/core/workflow/types.d.ts.map +1 -1
  130. package/dist/core/workflow/types.js +1 -1
  131. package/dist/lib/browser/client.d.ts +16 -0
  132. package/dist/lib/browser/client.d.ts.map +1 -1
  133. package/dist/lib/browser/client.js +22 -0
  134. package/dist/lib/browser/index.d.ts +1 -0
  135. package/dist/lib/browser/index.d.ts.map +1 -1
  136. package/dist/lib/browser/index.js +1 -0
  137. package/dist/lib/browser/launch-args.d.ts +21 -0
  138. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  139. package/dist/lib/browser/launch-args.js +32 -0
  140. package/dist/lib/claude-desktop.d.ts +105 -0
  141. package/dist/lib/claude-desktop.d.ts.map +1 -0
  142. package/dist/lib/claude-desktop.js +217 -0
  143. package/dist/lib/docs-lint.d.ts.map +1 -1
  144. package/dist/lib/docs-lint.js +5 -2
  145. package/dist/lib/identities/assume.d.ts +51 -0
  146. package/dist/lib/identities/assume.d.ts.map +1 -0
  147. package/dist/lib/identities/assume.js +274 -0
  148. package/dist/lib/identities/index.d.ts +7 -7
  149. package/dist/lib/identities/index.d.ts.map +1 -1
  150. package/dist/lib/identities/index.js +24 -24
  151. package/dist/lib/instructions/templates.d.ts.map +1 -1
  152. package/dist/lib/instructions/templates.js +15 -2
  153. package/package.json +22 -6
  154. package/schemas/config.schema.json +88 -19
  155. package/src/commander.ts +6 -0
  156. package/src/commands/agents.ts +280 -14
  157. package/src/commands/backup.ts +17 -16
  158. package/src/commands/browse.ts +32 -0
  159. package/src/commands/claude-desktop.ts +215 -0
  160. package/src/commands/context.ts +174 -1
  161. package/src/commands/doctor.ts +2 -3
  162. package/src/commands/eml.ts +22 -4
  163. package/src/commands/harness.ts +147 -0
  164. package/src/commands/presence.ts +111 -5
  165. package/src/commands/relay.ts +160 -0
  166. package/src/commands/sync.ts +4 -0
  167. package/src/commands/workflow.ts +62 -19
  168. package/src/core/agents/canonical-emit.ts +41 -3
  169. package/src/core/agents/cli.ts +4 -2
  170. package/src/core/agents/render/prompt-context.ts +48 -3
  171. package/src/core/agents/render/session-context.ts +27 -1
  172. package/src/core/agents/rules/commit-conflict.ts +4 -33
  173. package/src/core/agents/session-events.ts +15 -39
  174. package/src/core/agents/state/heartbeat-projector.ts +10 -1
  175. package/src/core/agents/state/heartbeat-writer.ts +3 -2
  176. package/src/core/agents/state/names.ts +68 -9
  177. package/src/core/agents/state/pidmap.ts +59 -2
  178. package/src/core/agents/state/stale-sweep.ts +6 -11
  179. package/src/core/config.ts +248 -24
  180. package/src/core/context/index.ts +575 -0
  181. package/src/core/harnesses/bench.ts +214 -0
  182. package/src/core/harnesses/index.ts +31 -0
  183. package/src/core/harnesses/profiles.ts +141 -0
  184. package/src/core/harnesses/registry.ts +140 -0
  185. package/src/core/harnesses/types.ts +90 -0
  186. package/src/core/hooks/cli.ts +331 -62
  187. package/src/core/hooks/events/schema.ts +68 -0
  188. package/src/core/hooks/harness/events.ts +3 -0
  189. package/src/core/hooks/harness/parse.ts +7 -1
  190. package/src/core/presence/blob.ts +125 -0
  191. package/src/core/presence/git.ts +191 -0
  192. package/src/core/presence/index.ts +274 -0
  193. package/src/core/presence/relay-client.ts +322 -0
  194. package/src/core/presence/relay-protocol.ts +246 -0
  195. package/src/core/workflow/billing.ts +8 -2
  196. package/src/core/workflow/engine.ts +182 -16
  197. package/src/core/workflow/harnesses.ts +14 -19
  198. package/src/core/workflow/index.ts +43 -0
  199. package/src/core/workflow/proof.ts +434 -0
  200. package/src/core/workflow/spawn-claude.ts +39 -20
  201. package/src/core/workflow/spawn-codex.ts +47 -28
  202. package/src/core/workflow/spawn-cursor.ts +37 -18
  203. package/src/core/workflow/types.ts +176 -2
  204. package/src/lib/browser/client.ts +37 -0
  205. package/src/lib/browser/index.ts +1 -0
  206. package/src/lib/browser/launch-args.ts +33 -0
  207. package/src/lib/claude-desktop.ts +293 -0
  208. package/src/lib/docs-lint.ts +5 -2
  209. package/src/lib/identities/assume.ts +363 -0
  210. package/src/lib/identities/index.ts +28 -24
  211. package/src/lib/instructions/templates.ts +16 -2
@@ -12,6 +12,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { resolveBinName, resolveHooksSetupHint } from "../../config.ts";
14
14
  import { harneryVersion, loadHarnessWiring } from "../../hooks/harness/wiring.ts";
15
+ import { readRemoteMachines } from "../../presence/index.ts";
15
16
 
16
17
  interface HeartbeatRow {
17
18
  instance_id?: string;
@@ -48,7 +49,9 @@ export function renderSessionContext(opts: RenderOpts): string {
48
49
 
49
50
  // 1. Self-name line + peer table (folded if peers present)
50
51
  const peers = readActivePeers(coordRoot, instanceId);
51
- const peerTable = formatPeerTable(peers, sessionId);
52
+ const localTable = formatPeerTable(peers, sessionId);
53
+ // Cross-machine presence (ADR 0016): sessions on other machines (advisory).
54
+ const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n");
52
55
  if (agentName) {
53
56
  const suffix = platformLabel ? ` (${platformLabel})` : "";
54
57
  const selfLine = `You are agent-${agentName}${suffix}.`;
@@ -107,6 +110,29 @@ export function renderSessionContext(opts: RenderOpts): string {
107
110
  return messages.join("\n\n");
108
111
  }
109
112
 
113
+ /** One-line-per-agent view of sessions on other machines (presence refs). */
114
+ function formatRemoteMachines(coordRoot: string): string {
115
+ try {
116
+ const remote = readRemoteMachines(coordRoot);
117
+ if (remote.length === 0) return "";
118
+ const lines: string[] = [];
119
+ for (const m of remote) {
120
+ for (const a of m.agents.slice(0, 10)) {
121
+ const task = a.task ? ` "${a.task.slice(0, 60)}"` : "";
122
+ const files = a.files_touched?.length
123
+ ? `holds: ${a.files_touched.slice(0, 3).join(", ")}${a.files_touched.length > 3 ? `, +${a.files_touched.length - 3} more` : ""}`
124
+ : "nothing held";
125
+ lines.push(
126
+ ` - agent-${a.name ?? a.instance_id.slice(0, 8)} @${m.machine}${task} (${files})`,
127
+ );
128
+ }
129
+ }
130
+ return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
131
+ } catch {
132
+ return "";
133
+ }
134
+ }
135
+
110
136
  /** Read all peer heartbeats from .harnery/active/, excluding self. */
111
137
  function readActivePeers(coordRoot: string, selfInstanceId: string): HeartbeatRow[] {
112
138
  const out: HeartbeatRow[] = [];
@@ -23,9 +23,8 @@
23
23
  import { spawnSync } from "node:child_process";
24
24
  import { existsSync, readdirSync, readFileSync } from "node:fs";
25
25
  import { join } from "node:path";
26
- import { coordEnv } from "../../../lib/env.ts";
27
-
28
- const DEFAULT_FRESHNESS_SECS = 600;
26
+ import { coordFreshnessSeconds } from "../../config.ts";
27
+ import { instanceHasLivePid } from "../state/pidmap.ts";
29
28
 
30
29
  interface PeerHeartbeat {
31
30
  instance_id: string;
@@ -86,7 +85,7 @@ export function evaluateCommit(coordRoot: string, req: CommitVerdictRequest): Co
86
85
  }
87
86
 
88
87
  const peers = readActivePeers(coordRoot);
89
- const cutoffMs = Date.now() - freshnessSecs() * 1000;
88
+ const cutoffMs = Date.now() - coordFreshnessSeconds(coordRoot) * 1000;
90
89
  const stagedSet = new Set(req.staged_paths);
91
90
  const gitlinkSet = new Set(req.staged_gitlinks ?? []);
92
91
 
@@ -233,28 +232,7 @@ function isHolderSelfAttributed(
233
232
  }
234
233
 
235
234
  function holderHasLiveForeignPid(coordRoot: string, holderId: string): boolean {
236
- const dir = join(coordRoot, ".harnery", "pid-map");
237
- if (!existsSync(dir)) return false;
238
- for (const f of readdirSync(dir)) {
239
- let row = "";
240
- try {
241
- row = readFileSync(join(dir, f), "utf8").trim();
242
- } catch {
243
- continue;
244
- }
245
- const owner = row.split("\t")[0]?.trim() ?? "";
246
- if (owner !== holderId) continue;
247
- // Process still alive?
248
- const pid = Number.parseInt(f, 10);
249
- if (!Number.isFinite(pid)) continue;
250
- try {
251
- process.kill(pid, 0); // signal 0 = liveness probe
252
- return true; // live foreign pid found
253
- } catch {
254
- // ESRCH (no such process): pid-map entry is stale, skip
255
- }
256
- }
257
- return false;
235
+ return instanceHasLivePid(coordRoot, holderId);
258
236
  }
259
237
 
260
238
  function isPathCleanInHead(coordRoot: string, relPath: string): boolean {
@@ -290,13 +268,6 @@ function readActivePeers(coordRoot: string): PeerHeartbeat[] {
290
268
  return out;
291
269
  }
292
270
 
293
- function freshnessSecs(): number {
294
- const raw = coordEnv("AGENT_FRESHNESS");
295
- if (!raw) return DEFAULT_FRESHNESS_SECS;
296
- const n = Number.parseInt(raw, 10);
297
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_FRESHNESS_SECS;
298
- }
299
-
300
271
  function shortName(peer: PeerHeartbeat): string {
301
272
  if (peer.name && peer.name.length > 0) return `agent-${peer.name}`;
302
273
  return `agent-${peer.instance_id.slice(0, 8)}`;
@@ -9,10 +9,10 @@
9
9
  */
10
10
 
11
11
  import { randomBytes } from "node:crypto";
12
- import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { existsSync, readFileSync } from "node:fs";
13
13
  import { dirname, resolve } from "node:path";
14
14
  // Kept dependency-light: vendored verbatim into a downstream consumer, so no coordEnv import.
15
- import { normalizeHarness } from "./canonical-emit.ts";
15
+ import { normalizeHarness, resolveEmitRoot } from "./canonical-emit.ts";
16
16
  import { emit } from "./events/emit.ts";
17
17
 
18
18
  /** Event types accepted by `writeSessionEvent`. Only the command stream +
@@ -38,25 +38,14 @@ export function sessionEventsPath(): string {
38
38
  // Explicit override (tests + non-monorepo invocations).
39
39
  const explicit = process.env.HARNERY_OUTPUT_SESSION_EVENTS;
40
40
  if (explicit) return explicit;
41
- // Walk up looking for an EXISTING .harnery/ directory; only match dirs
42
- // (submodule .git is a file/gitlink, and CLAUDE.md / AGENTS.md can
43
- // proliferate per-subdirectory in many monorepos, so neither is a safe
44
- // anchor). Don't reintroduce CLAUDE.md / .git as anchors, which creates
41
+ // Superproject-aware root resolution (git first, cwd walk fallback) via
42
+ // resolveEmitRoot. A plain cwd walk here mis-anchored to a NESTED
43
+ // `.harnery/` when the shell sat inside a submodule carrying its own
44
+ // (e.g. an embedded harnery checkout), silently splitting the event
45
+ // stream. Don't reintroduce CLAUDE.md / .git as anchors, which creates
45
46
  // stray .harnery/ dirs under submodule subtrees.
46
- let dir = process.cwd();
47
- for (let i = 0; i < 10; i++) {
48
- const candidate = resolve(dir, ".harnery");
49
- try {
50
- if (existsSync(candidate) && statSync(candidate).isDirectory()) {
51
- return resolve(candidate, "session-events.ndjson");
52
- }
53
- } catch {
54
- /* keep walking */
55
- }
56
- const parent = dirname(dir);
57
- if (parent === dir) break;
58
- dir = parent;
59
- }
47
+ const root = resolveEmitRoot();
48
+ if (root) return resolve(root, ".harnery", "session-events.ndjson");
60
49
  return resolve(process.env.HOME || "/tmp", ".harnery", "session-events.ndjson");
61
50
  }
62
51
 
@@ -75,25 +64,12 @@ export function newCmdId(): string {
75
64
  */
76
65
  export function readLastIntent(instanceId?: string): string | null {
77
66
  if (!instanceId) return null;
78
- // Walk up from cwd looking for .harnery/, same resolution as
79
- // sessionEventsPath() (only match existing directories).
80
- let dir = process.cwd();
81
- let agentsDir: string | null = null;
82
- for (let i = 0; i < 10; i++) {
83
- const candidate = resolve(dir, ".harnery");
84
- try {
85
- if (existsSync(candidate) && statSync(candidate).isDirectory()) {
86
- agentsDir = candidate;
87
- break;
88
- }
89
- } catch {
90
- /* keep walking */
91
- }
92
- const parent = dirname(dir);
93
- if (parent === dir) break;
94
- dir = parent;
95
- }
96
- if (!agentsDir) return null;
67
+ // Same superproject-aware root resolution as sessionEventsPath(): the
68
+ // intent stamp is written by the PreToolUse hook into the SUPERPROJECT's
69
+ // .harnery/, so a nested-`.harnery/` cwd must not redirect the read.
70
+ const root = resolveEmitRoot();
71
+ if (!root) return null;
72
+ const agentsDir = resolve(root, ".harnery");
97
73
  const intentPath = resolve(agentsDir, `.last-intent.${instanceId}`);
98
74
  if (!existsSync(intentPath)) return null;
99
75
  try {
@@ -146,7 +146,8 @@ function seed(ev: CanonicalEvent, coordRoot: string): V2Heartbeat {
146
146
  if (resolved) {
147
147
  hb.name = resolved.name;
148
148
  hb.kind = resolved.kind;
149
- 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);
150
151
  }
151
152
  } catch {
152
153
  /* name-history unavailable: seed stays nameless; sweep + render guards cope */
@@ -287,6 +288,14 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
287
288
  hb.last_status_at = ev.ts;
288
289
  break;
289
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
+
290
299
  case "state.presence_change": {
291
300
  const to = pickStr(d, "to");
292
301
  if (to === "mobile" || to === "office") hb.presence = to;
@@ -311,8 +311,9 @@ export function healHeartbeat(
311
311
  if (resolved) {
312
312
  name = resolved.name;
313
313
  kind = resolved.kind;
314
- // subagent kind: agent_id == instance_id. Else (session/transient): no id stamp.
315
- 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 : "");
316
317
  }
317
318
  } catch {
318
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
  //