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
@@ -39,7 +39,8 @@ import {
39
39
  resolveOwnerBySessionEnv,
40
40
  resolveOwnerWithSource,
41
41
  } from "../core/agents/index.ts";
42
- import { resolveBinName } from "../core/config.ts";
42
+ import { coordFreshnessSeconds, resolveBinName } from "../core/config.ts";
43
+ import { type RemoteMachine, readRemoteMachines } from "../core/presence/index.ts";
43
44
 
44
45
  /** Cap for CLI scans of the unbounded event ledger (`trace` / `health`). Well
45
46
  * under V8's ~512MB max string length so a `readFileSync` of the whole file can
@@ -75,6 +76,7 @@ import {
75
76
  writeManifest,
76
77
  writePrompt,
77
78
  } from "../lib/council/index.ts";
79
+ import { assumeIdentity, IdentityAssumeError } from "../lib/identities/assume.ts";
78
80
  import {
79
81
  displayName as displayAgentName,
80
82
  ensureIdentity,
@@ -83,13 +85,36 @@ import {
83
85
  lookupByName as lookupIdentityByName,
84
86
  } from "../lib/identities/index.ts";
85
87
 
86
- const FRESHNESS_SECS = 600; // 10-minute heartbeat-freshness window.
88
+ /**
89
+ * Heartbeat-freshness window (seconds). Reads `coord.freshness_seconds` /
90
+ * `HARNERY_AGENT_COORD_FRESHNESS` via the shared accessor (defaults to 600 =
91
+ * 10 min). A function, not a const, so a config edit takes effect without a
92
+ * process restart (mtime-cached).
93
+ */
94
+ function freshnessCutoffSecs(): number {
95
+ return coordFreshnessSeconds();
96
+ }
87
97
 
88
98
  const SUBAGENT_NOTE =
89
99
  "Bash identity is process-level in v1; if you're running inside a subagent, " +
90
100
  "this resolves to the parent group's name, not the subagent's. A subagent-aware " +
91
101
  "bridge (per-shell marker file at .harnery/shells/<pid>) is out of scope.";
92
102
 
103
+ /**
104
+ * Spawn options for agent-coord child processes: pin the coord root the
105
+ * command already resolved (git-superproject-aware via `monorepoRoot()`), so
106
+ * the helper can't re-resolve a DIFFERENT root by walking up from a drifted
107
+ * shell cwd. The concrete failure this prevents: a shell cd'd into an
108
+ * embedded harnery checkout (which carries its own committed `.harnery/`)
109
+ * made agent-coord resolve that nested root and miss the session's real
110
+ * heartbeat — `set-task: no heartbeat at .harnery/active/<id>.json` while
111
+ * `status` (which resolves in-process) worked fine. Mirrors the hooks side's
112
+ * `childEnv()`; every agent-coord spawn must carry this.
113
+ */
114
+ export function coordHelperOpts(root: string): { cwd: string; env: NodeJS.ProcessEnv } {
115
+ return { cwd: root, env: { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: root } };
116
+ }
117
+
93
118
  function formatPlatformLabel(platform?: string | null): string {
94
119
  if (platform === "cursor") return "Cursor";
95
120
  if (platform === "codex") return "Codex";
@@ -98,10 +123,12 @@ function formatPlatformLabel(platform?: string | null): string {
98
123
 
99
124
  interface Row {
100
125
  name: string;
126
+ /** Durable persona UUID after an explicit identity assumption. */
127
+ agent_id?: string | null;
101
128
  instance_id: string;
102
129
  session_id: string;
103
130
  kind: string;
104
- relation: "self" | "group" | "blocks" | "unknown";
131
+ relation: "self" | "group" | "blocks" | "remote" | "unknown";
105
132
  started_at: string;
106
133
  last_heartbeat: string;
107
134
  files_touched: string[];
@@ -111,6 +138,9 @@ interface Row {
111
138
  turn_summary?: string | null;
112
139
  turn_summary_updated_at?: string | null;
113
140
  platform?: string | null;
141
+ /** Set on relation=remote rows: the machine label the row arrived from
142
+ * via the cross-machine presence transport (ADR 0016). */
143
+ machine?: string | null;
114
144
  }
115
145
 
116
146
  let emit: EmitContext;
@@ -153,6 +183,23 @@ export function registerAgentsCommand(program: Command, emitParam: EmitContext):
153
183
  runStatus(opts);
154
184
  });
155
185
 
186
+ cmd
187
+ .command("suggest-name [description...]")
188
+ .description(
189
+ 'Reprint a session name ("Agent <you> - <description>") for the operator to set as their ' +
190
+ "harness tab title. With no arg, derives it from your current task. The primary naming path " +
191
+ "is set-task (it suggests a name on the first focus declaration); reach for this to reprint " +
192
+ "or re-suggest after a topic pivot. Read-only.",
193
+ )
194
+ .option("--json", "JSON output instead of the bare name")
195
+ .option(
196
+ "--session-id <id>",
197
+ "Lookup heartbeat by session_id directly, bypassing the ppid walk.",
198
+ )
199
+ .action((description: string[], opts: { json?: boolean; sessionId?: string }) => {
200
+ runSuggestName(description, opts);
201
+ });
202
+
156
203
  cmd
157
204
  .command("watch")
158
205
  .description(
@@ -445,6 +492,63 @@ function registerIdentityCommands(parent: Command): void {
445
492
  meta: { action: "identity-ensure" },
446
493
  });
447
494
  });
495
+
496
+ identity
497
+ .command("assume <name-or-id>")
498
+ .description(
499
+ "Bind this live session to a durable persona. Reclaims an abandoned " +
500
+ "local namesake (no live process); refuses when another live process " +
501
+ "or known remote session still holds the name. Updates name history, " +
502
+ "the event ledger, and the heartbeat.",
503
+ )
504
+ .option("--json", "JSON envelope output")
505
+ .option(
506
+ "--session-id <id>",
507
+ "Bind the heartbeat with this session_id directly, bypassing the ppid walk.",
508
+ )
509
+ .action((target: string, opts: { json?: boolean; sessionId?: string }) => {
510
+ runIdentityAssume(target, opts);
511
+ });
512
+ }
513
+
514
+ function runIdentityAssume(target: string, opts: { json?: boolean; sessionId?: string }): void {
515
+ if (opts.json) emit.config({ format: "json" });
516
+ const root = monorepoRoot();
517
+ if (!root) {
518
+ emit.error({
519
+ code: "not_in_repo",
520
+ message: "not in an agent session; coord_root() returned null",
521
+ });
522
+ process.exit(1);
523
+ }
524
+ if (!opts.sessionId) ensureCursorSession(root);
525
+ const owner = opts.sessionId ?? resolveOwner();
526
+ if (!owner) {
527
+ emit.error({
528
+ code: "no_pidmap_entry",
529
+ message:
530
+ "not in an agent session; ppid walk found no pid-map entry (pass --session-id to bypass)",
531
+ });
532
+ process.exit(1);
533
+ }
534
+ try {
535
+ const result = assumeIdentity(root, owner, target);
536
+ emit.data({
537
+ ...result,
538
+ display_name: displayAgentName(result.name),
539
+ action: "identity-assume",
540
+ });
541
+ } catch (error) {
542
+ if (error instanceof IdentityAssumeError) {
543
+ emit.error({ code: error.code, message: error.message });
544
+ } else {
545
+ emit.error({
546
+ code: "identity_assume_failed",
547
+ message: error instanceof Error ? error.message : String(error),
548
+ });
549
+ }
550
+ process.exit(1);
551
+ }
448
552
  }
449
553
 
450
554
  function registerCouncilCommands(parent: Command): void {
@@ -716,6 +820,7 @@ function runWhoami(opts: { json?: boolean }): void {
716
820
 
717
821
  const row: Row = {
718
822
  name: hb.name || "unknown",
823
+ agent_id: hb.agent_id || null,
719
824
  instance_id: hb.instance_id,
720
825
  session_id: hb.session_id,
721
826
  kind: normalizeKind(hb.kind),
@@ -779,7 +884,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
779
884
 
780
885
  // Apply staleness filter unless --stale.
781
886
  const nowSec = Math.floor(Date.now() / 1000);
782
- const cutoff = nowSec - FRESHNESS_SECS;
887
+ const cutoff = nowSec - freshnessCutoffSecs();
783
888
  const live = opts.stale
784
889
  ? heartbeats
785
890
  : heartbeats.filter((h) => {
@@ -813,6 +918,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
813
918
  // Orphan transient case
814
919
  rows.push({
815
920
  name: h.name || "unknown",
921
+ agent_id: h.agent_id || null,
816
922
  instance_id: h.instance_id,
817
923
  session_id: h.session_id,
818
924
  kind: "transient",
@@ -837,6 +943,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
837
943
  }
838
944
  rows.push({
839
945
  name: h.name || "unknown",
946
+ agent_id: h.agent_id || null,
840
947
  instance_id: h.instance_id,
841
948
  session_id: h.session_id,
842
949
  kind: normalizeKind(h.kind),
@@ -858,6 +965,31 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
858
965
  // what made `harn agents list --all --stale` crash.
859
966
  rows.sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
860
967
 
968
+ // Cross-machine presence (ADR 0016): append sessions on OTHER machines from
969
+ // the locally-fetched presence refs. Advisory rows (relation=remote) — they
970
+ // don't participate in local claim blocking in v1.
971
+ for (const rm of readRemoteMachines(root)) {
972
+ for (const a of rm.agents) {
973
+ rows.push({
974
+ name: a.name || "unknown",
975
+ instance_id: a.instance_id,
976
+ session_id: a.session_id ?? a.instance_id,
977
+ kind: normalizeKind(a.kind),
978
+ relation: "remote",
979
+ started_at: a.started_at ?? "",
980
+ last_heartbeat: a.last_heartbeat ?? "",
981
+ files_touched: [...(a.files_touched ?? [])].sort(),
982
+ last_tool: a.last_tool ?? null,
983
+ last_tool_target: null,
984
+ task: a.task ?? null,
985
+ turn_summary: a.turn_summary ?? null,
986
+ turn_summary_updated_at: null,
987
+ platform: a.platform ?? "claude_code",
988
+ machine: rm.machine,
989
+ });
990
+ }
991
+ }
992
+
861
993
  // Emit. JSON format gets {rows, note}; TTY gets the rows with note as a footnote.
862
994
  emit.data({ rows, note: SUBAGENT_NOTE });
863
995
  if (process.stdout.isTTY && !opts.json) {
@@ -976,7 +1108,7 @@ async function runWatch(pollMs: number): Promise<void> {
976
1108
  function listActiveHeartbeats(activeDir: string): Heartbeat[] {
977
1109
  const out: Heartbeat[] = [];
978
1110
  const nowSec = Math.floor(Date.now() / 1000);
979
- const cutoff = nowSec - FRESHNESS_SECS;
1111
+ const cutoff = nowSec - freshnessCutoffSecs();
980
1112
  for (const file of readdirSync(activeDir)) {
981
1113
  if (!file.endsWith(".json")) continue;
982
1114
  try {
@@ -1020,7 +1152,7 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1020
1152
  // Read all heartbeats; match by name (case-insensitive). Apply freshness filter.
1021
1153
  const matches: Heartbeat[] = [];
1022
1154
  const nowSec = Math.floor(Date.now() / 1000);
1023
- const cutoff = nowSec - FRESHNESS_SECS;
1155
+ const cutoff = nowSec - freshnessCutoffSecs();
1024
1156
  for (const file of readdirSync(activeDir)) {
1025
1157
  if (!file.endsWith(".json")) continue;
1026
1158
  try {
@@ -1185,6 +1317,11 @@ function shouldBootstrapCursorSession(): boolean {
1185
1317
 
1186
1318
  function ensureCursorSession(root: string): void {
1187
1319
  if (!shouldBootstrapCursorSession()) return;
1320
+ // Explicit owner override means the caller already knows who they are.
1321
+ // Bootstrapping sessionStart inherits that env into the child hook, which
1322
+ // re-projects a fresh heartbeat onto the override owner and can wipe an
1323
+ // assumed persona agent_id (seen under Cursor integration fixtures).
1324
+ if (process.env.HARNERY_AGENT_COORD_OWNER?.trim()) return;
1188
1325
  if (resolveOwnerBySessionEnv(root)) return;
1189
1326
 
1190
1327
  const sessionId = cursorEnvSessionId();
@@ -1242,6 +1379,7 @@ function runReleaseClaim(path: string): void {
1242
1379
  const helper = resolve(root, "harnery", "bin", "agent-coord");
1243
1380
  const result = spawnSync(helper, ["release-claim", myOwner, canonical], {
1244
1381
  encoding: "utf8",
1382
+ ...coordHelperOpts(root),
1245
1383
  });
1246
1384
  if (result.status !== 0) {
1247
1385
  emit.error({
@@ -1276,10 +1414,21 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1276
1414
  process.exit(1);
1277
1415
  }
1278
1416
 
1417
+ // Snapshot BEFORE the mutation: a heartbeat that has never carried a task
1418
+ // has no `task_updated_at` stamp (setTask always stamps it, even on a clear),
1419
+ // so its absence is the "focus never declared this session" signal — i.e. the
1420
+ // first set-task of the session. That's the natural moment to also hand the
1421
+ // operator a session name built from the same declaration (one source of
1422
+ // truth: the focus you declare names the session). Distinct from task===""
1423
+ // (a deliberate clear), which carries a stamp and does NOT re-trigger.
1424
+ const priorHb = readHeartbeat(myOwner);
1425
+ const firstOfSession = !priorHb?.task_updated_at;
1426
+
1279
1427
  // Heartbeat mutation goes through agent-coord (atomic temp+rename).
1280
1428
  const helper = resolve(root, "harnery", "bin", "agent-coord");
1281
1429
  const result = spawnSync(helper, ["set-task", myOwner, task], {
1282
1430
  encoding: "utf8",
1431
+ ...coordHelperOpts(root),
1283
1432
  });
1284
1433
  if (result.status !== 0) {
1285
1434
  emit.error({
@@ -1298,14 +1447,102 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1298
1447
  data: { task, cleared: !task || task.length === 0 },
1299
1448
  });
1300
1449
 
1450
+ // On the first focus declaration of a session, surface a suggested session
1451
+ // name for the operator to set as their harness tab title. Read the result
1452
+ // (`suggested_session_name`) and reproduce it in a fenced code block so the
1453
+ // UI's Copy button hands over the exact string. null on every later call.
1454
+ const built =
1455
+ firstOfSession && !opts?.sessionId ? buildSuggestedName(hb?.name ?? "unknown", [task]) : null;
1456
+
1301
1457
  emit.data({
1302
1458
  instance_id: myOwner,
1303
1459
  name: hb?.name ?? null,
1304
1460
  task: hb?.task ?? null,
1305
1461
  cleared: !task || task.length === 0,
1462
+ first_of_session: firstOfSession,
1463
+ suggested_session_name: built?.suggestedName ?? null,
1306
1464
  });
1307
1465
  }
1308
1466
 
1467
+ /**
1468
+ * Build the copy-pasteable session name from the coord identity + the agent's
1469
+ * description parts. Pure (no coord-state reads) so it's unit-testable; collapses
1470
+ * internal whitespace and trims. Returns null when the description is empty.
1471
+ */
1472
+ export function buildSuggestedName(
1473
+ agentName: string,
1474
+ descriptionParts: string[],
1475
+ ): { suggestedName: string; description: string } | null {
1476
+ const description = descriptionParts.join(" ").replace(/\s+/g, " ").trim();
1477
+ if (!description) return null;
1478
+ const name = agentName?.trim() || "unknown";
1479
+ return { suggestedName: `Agent ${name} - ${description}`, description };
1480
+ }
1481
+
1482
+ function runSuggestName(
1483
+ descriptionParts: string[],
1484
+ opts: { json?: boolean; sessionId?: string },
1485
+ ): void {
1486
+ const root = monorepoRoot();
1487
+ if (!root) {
1488
+ emit.error({
1489
+ code: "not_in_repo",
1490
+ message: "not in an agent session; coord_root() returned null",
1491
+ });
1492
+ process.exit(1);
1493
+ }
1494
+
1495
+ // Identity: prefer explicit --session-id (ppid-walk-free escape hatch, mirrors
1496
+ // `status`/`set-task`), fall back to the ppid walk for interactive shell use.
1497
+ if (!opts.sessionId) ensureCursorSession(root);
1498
+ const myOwner = opts.sessionId ?? resolveOwner();
1499
+ if (!myOwner) {
1500
+ emit.error({
1501
+ code: "no_pidmap_entry",
1502
+ message:
1503
+ "not in an agent session; ppid walk found no pid-map entry (pass --session-id to bypass)",
1504
+ });
1505
+ process.exit(1);
1506
+ }
1507
+
1508
+ const hb = readHeartbeat(myOwner);
1509
+ const agentName = hb?.name || "unknown";
1510
+ // Description resolution: an explicit arg wins; with no arg, fall back to the
1511
+ // agent's current declared task, so a bare `suggest-name` reprints the running
1512
+ // session's name (handy after a topic pivot). Read-only — mutates no state;
1513
+ // the primary naming path is set-task's first-of-session suggestion.
1514
+ const parts = descriptionParts.length > 0 ? descriptionParts : hb?.task ? [hb.task] : [];
1515
+ const built = buildSuggestedName(agentName, parts);
1516
+ if (!built) {
1517
+ emit.error({
1518
+ code: "no_description",
1519
+ message:
1520
+ 'pass a 2-5 word topic (e.g. suggest-name "Auth Refactor"), or declare one first with set-task',
1521
+ });
1522
+ process.exit(1);
1523
+ }
1524
+ const { suggestedName, description } = built;
1525
+ const displayName = `agent-${agentName}`;
1526
+
1527
+ if (opts.json) {
1528
+ emit.config({ format: "json" });
1529
+ emit.data({
1530
+ name: displayName,
1531
+ suggested_session_name: suggestedName,
1532
+ agent_name: agentName,
1533
+ description,
1534
+ });
1535
+ return;
1536
+ }
1537
+
1538
+ // Emit the bare name only — no box. The agent reproduces this exact string in
1539
+ // a fenced code block in its reply, which every chat UI decorates with a
1540
+ // one-click Copy button so the operator can grab it without hand-selecting.
1541
+ // (A drawn box would copy its borders + labels too.) Direct write, not
1542
+ // emit.text: runs via Bash with no TTY, same chat-paste contract as runStatus.
1543
+ process.stdout.write(`${suggestedName}\n`); // lint-ok-emission: chat-paste path
1544
+ }
1545
+
1309
1546
  function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1310
1547
  const root = monorepoRoot();
1311
1548
  if (!root) {
@@ -1347,6 +1584,7 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1347
1584
  spawnSync(helper, ["stamp-status-call", myOwner], {
1348
1585
  encoding: "utf8",
1349
1586
  timeout: 2000,
1587
+ ...coordHelperOpts(root),
1350
1588
  });
1351
1589
  } catch {
1352
1590
  /* non-fatal */
@@ -1370,7 +1608,7 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1370
1608
 
1371
1609
  const activeDir = resolve(root, ".harnery", "active");
1372
1610
  const nowSec = Math.floor(Date.now() / 1000);
1373
- const cutoff = nowSec - FRESHNESS_SECS;
1611
+ const cutoff = nowSec - freshnessCutoffSecs();
1374
1612
  const livePeers: Heartbeat[] = [];
1375
1613
  let peersStale = 0;
1376
1614
  if (existsSync(activeDir)) {
@@ -1407,7 +1645,9 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1407
1645
  4,
1408
1646
  "0 held",
1409
1647
  );
1410
- const peersStr = formatPeers(livePeers, 4, peersStale);
1648
+ // Cross-machine presence (ADR 0016): sessions on other machines, advisory.
1649
+ const remoteMachines = readRemoteMachines(root);
1650
+ const peersStr = formatPeers(livePeers, 4, peersStale, remoteMachines);
1411
1651
 
1412
1652
  const ctxUsage = readContextUsage(hb.session_id, hb.platform);
1413
1653
  let ctxStr: string;
@@ -1449,6 +1689,15 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1449
1689
  name: p.name || "unnamed",
1450
1690
  files: p.files_touched?.length ?? 0,
1451
1691
  })),
1692
+ remote_machines: remoteMachines.map((m) => ({
1693
+ machine: m.machine,
1694
+ age_secs: m.age_secs,
1695
+ agents: m.agents.map((a) => ({
1696
+ name: a.name || "unnamed",
1697
+ task: a.task ?? null,
1698
+ files: a.files_touched?.length ?? 0,
1699
+ })),
1700
+ })),
1452
1701
  pending_councils: pendingCouncils,
1453
1702
  context_used: ctxUsage?.used ?? null,
1454
1703
  context_window: ctxUsage?.window ?? null,
@@ -1499,8 +1748,13 @@ function formatList(items: string[], cap: number, emptyLabel: string): string {
1499
1748
  return `${shown}, +${items.length - cap} more`;
1500
1749
  }
1501
1750
 
1502
- function formatPeers(peers: Heartbeat[], cap: number, staleCount: number): string {
1503
- if (peers.length === 0 && staleCount === 0) return "none";
1751
+ function formatPeers(
1752
+ peers: Heartbeat[],
1753
+ cap: number,
1754
+ staleCount: number,
1755
+ remoteMachines: RemoteMachine[] = [],
1756
+ ): string {
1757
+ if (peers.length === 0 && staleCount === 0 && remoteMachines.length === 0) return "none";
1504
1758
  const labels = peers.map((p) => {
1505
1759
  const name = p.name || "unnamed";
1506
1760
  const plat = formatPlatformLabel(p.platform);
@@ -1516,7 +1770,16 @@ function formatPeers(peers: Heartbeat[], cap: number, staleCount: number): strin
1516
1770
  } else {
1517
1771
  main = `${labels.slice(0, cap).join(", ")}, +${labels.length - cap} more`;
1518
1772
  }
1519
- return staleCount > 0 ? `${main}; ${staleCount} stale` : main;
1773
+ if (staleCount > 0) main = `${main}; ${staleCount} stale`;
1774
+ // Remote machines (presence transport): `Name @machine` labels, capped.
1775
+ if (remoteMachines.length > 0) {
1776
+ const remote = remoteMachines
1777
+ .flatMap((m) => m.agents.map((a) => `${a.name || "unnamed"} @${m.machine}`))
1778
+ .slice(0, cap);
1779
+ const extra = remoteMachines.reduce((n, m) => n + m.agents.length, 0) - remote.length;
1780
+ main = `${main}; ${remote.join(", ")}${extra > 0 ? `, +${extra} more` : ""}`;
1781
+ }
1782
+ return main;
1520
1783
  }
1521
1784
 
1522
1785
  function fmtTokens(n: number): string {
@@ -2213,6 +2476,9 @@ function traceLine(ev: CanonicalEvent, allTools: boolean): TraceEntry | null {
2213
2476
  case "state.status_checked":
2214
2477
  detail = "status box rendered";
2215
2478
  break;
2479
+ case "identity.assumed":
2480
+ detail = `${s("previous_name") || "unknown"} → ${s("name") || "unknown"}${s("agent_id") ? ` · persona=${s("agent_id").slice(0, 8)}…` : ""}`;
2481
+ break;
2216
2482
  case "claim.acquire":
2217
2483
  case "claim.release":
2218
2484
  case "claim.conflict":
@@ -2493,7 +2759,7 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2493
2759
  activeBySchema[schemaKey] = (activeBySchema[schemaKey] ?? 0) + 1;
2494
2760
  const lastHbMs = hb.last_heartbeat ? Date.parse(hb.last_heartbeat) : Number.NaN;
2495
2761
  const ageMs = Number.isFinite(lastHbMs) ? nowMs - lastHbMs : Number.POSITIVE_INFINITY;
2496
- if (ageMs > FRESHNESS_SECS * 1000) staleHeartbeats++;
2762
+ if (ageMs > freshnessCutoffSecs() * 1000) staleHeartbeats++;
2497
2763
  // Zombie heuristics on a parseable heartbeat: no name, or an age so large
2498
2764
  // it can only be a broken/epoch timestamp (a real agent would have healed).
2499
2765
  if (!hb.name || hb.name === "unknown" || ageMs > ABSURD_AGE_MS) {
@@ -2533,7 +2799,7 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2533
2799
  }
2534
2800
  if (staleHeartbeats > 0) {
2535
2801
  anomalies.push(
2536
- `${staleHeartbeats} active heartbeat(s) older than ${Math.floor(FRESHNESS_SECS / 60)}min; heal mechanism may not be firing`,
2802
+ `${staleHeartbeats} active heartbeat(s) older than ${Math.floor(freshnessCutoffSecs() / 60)}min; heal mechanism may not be firing`,
2537
2803
  );
2538
2804
  }
2539
2805
  const unexpectedSchemas = Object.keys(activeBySchema).filter((k) => k !== "v1");
@@ -3317,8 +3583,8 @@ function runHeal(opts: {
3317
3583
  // agent-coord binary at harnery/bin/agent-coord.
3318
3584
  const helper = `${root}/harnery/bin/agent-coord`;
3319
3585
  const proc = spawnSync(helper, helperArgs, {
3320
- cwd: root,
3321
3586
  encoding: "utf8",
3587
+ ...coordHelperOpts(root),
3322
3588
  });
3323
3589
 
3324
3590
  if (proc.status !== 0) {
@@ -11,31 +11,27 @@
11
11
  * restore restic restore <id>
12
12
  * prune restic forget --keep-daily 7 --keep-weekly 4 --prune
13
13
  *
14
- * Power users override via env vars (HARNERY_RESTIC_REPO, HARNERY_RESTIC_PASSWORD_FILE)
15
- * or by passing through any restic args after `--`. The wrapper deliberately
16
- * doesn't try to abstract restic; it surfaces it, with opinionated defaults plus
17
- * passthrough.
14
+ * Defaults (repo path, password file, and the keep-daily/weekly/monthly prune
15
+ * policy) come from `.harnery/config.jsonc` `backup.*`, overridable per field by
16
+ * env vars (HARNERY_RESTIC_REPO, HARNERY_RESTIC_PASSWORD_FILE) and CLI flags —
17
+ * see `backupConfig()`. The wrapper deliberately doesn't try to abstract restic;
18
+ * it surfaces it, with opinionated defaults plus passthrough after `--`.
18
19
  */
19
20
 
20
21
  import { spawnSync } from "node:child_process";
21
22
  import { randomBytes } from "node:crypto";
22
23
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
23
- import os from "node:os";
24
24
  import path from "node:path";
25
25
  import type { Command } from "commander";
26
26
  import type { EmitContext } from "../commander.ts";
27
+ import { backupConfig } from "../core/config.ts";
27
28
 
28
29
  function defaultRepo(): string {
29
- const home = os.homedir();
30
- return process.env.HARNERY_RESTIC_REPO ?? path.join(home, ".cache", "harnery", "restic-repo");
30
+ return backupConfig().repo;
31
31
  }
32
32
 
33
33
  function defaultPasswordFile(): string {
34
- const home = os.homedir();
35
- return (
36
- process.env.HARNERY_RESTIC_PASSWORD_FILE ??
37
- path.join(home, ".config", "harnery", "restic-password")
38
- );
34
+ return backupConfig().passwordFile;
39
35
  }
40
36
 
41
37
  function findHarneryDir(): string | null {
@@ -237,12 +233,17 @@ export function registerBackupCommand(program: Command, emit: EmitContext): void
237
233
  });
238
234
 
239
235
  // -- prune --
236
+ // Prune-policy defaults come from backup.keep_* in config.jsonc (falling back
237
+ // to 7/4/6); an explicit --keep-* flag still overrides per invocation.
238
+ const prunePolicy = backupConfig();
240
239
  backup
241
240
  .command("prune")
242
- .description("Forget + prune snapshots (default policy: keep 7 daily / 4 weekly / 6 monthly).")
243
- .option("--keep-daily <n>", "Daily snapshots to keep", "7")
244
- .option("--keep-weekly <n>", "Weekly snapshots to keep", "4")
245
- .option("--keep-monthly <n>", "Monthly snapshots to keep", "6")
241
+ .description(
242
+ `Forget + prune snapshots (default policy: keep ${prunePolicy.keepDaily} daily / ${prunePolicy.keepWeekly} weekly / ${prunePolicy.keepMonthly} monthly).`,
243
+ )
244
+ .option("--keep-daily <n>", "Daily snapshots to keep", String(prunePolicy.keepDaily))
245
+ .option("--keep-weekly <n>", "Weekly snapshots to keep", String(prunePolicy.keepWeekly))
246
+ .option("--keep-monthly <n>", "Monthly snapshots to keep", String(prunePolicy.keepMonthly))
246
247
  .option("--dry-run", "Show what would be pruned without actually pruning")
247
248
  .option("--repo <path>", "Repository path")
248
249
  .option("--password-file <path>", "Password file")
@@ -13,6 +13,7 @@ import {
13
13
  type RuntsResult,
14
14
  type VisibilityResult,
15
15
  type WidthResult,
16
+ wslHeadedLaunchArgs,
16
17
  } from "../lib/browser/index.ts";
17
18
  import {
18
19
  type DiffResult,
@@ -66,6 +67,7 @@ interface BrowseOpts {
66
67
  networkHar?: string;
67
68
  login?: boolean;
68
69
  headed?: boolean;
70
+ browserArg?: string[];
69
71
  cookies?: boolean;
70
72
  store?: string;
71
73
  profile?: string;
@@ -162,6 +164,16 @@ export function registerBrowseCommand(
162
164
  )
163
165
  .option("--login", "Headed mode for one-time auth flow (cookies persist in profile)")
164
166
  .option("--headed", "Headed mode for one-off (no auth-flow framing)")
167
+ .option(
168
+ "--browser-arg <flag>",
169
+ "Extra Chromium launch flag, passed straight to the browser (repeatable). " +
170
+ "e.g. --browser-arg --disable-gpu. Also settable machine-wide via the " +
171
+ "HARNERY_BROWSER_ARGS env var (whitespace-separated). Under WSL, headed " +
172
+ "launches auto-add --disable-gpu to fix blank-window paint; opt out with " +
173
+ "HARNERY_BROWSER_NO_WSL_DEFAULTS=1.",
174
+ (value: string, prev: string[] = []) => [...prev, value],
175
+ [] as string[],
176
+ )
165
177
  .option("--no-cookies", "Skip cookie-jar attach and persist")
166
178
  .option("--store <path>", `Cookie store path (default ${DEFAULT_STORE})`)
167
179
  .option("--profile <dir>", `Persistent Chromium profile dir (default ${DEFAULT_PROFILE})`)
@@ -313,6 +325,7 @@ async function runBrowse(
313
325
  waitUntil: opts.waitUntil as BrowseOpts["waitUntil"] as never,
314
326
  recordHarPath: opts.networkHar ? resolve(opts.networkHar) : undefined,
315
327
  extraHeaders: context?.extraHeaders,
328
+ launchArgs: resolveLaunchArgs(opts, Boolean(headed)),
316
329
  });
317
330
 
318
331
  // Print mode: --snapshot / --html / --json all suppress file writes.
@@ -878,6 +891,25 @@ function summarizeDiagnostics(diag: Diagnostics): Record<string, unknown> {
878
891
  };
879
892
  }
880
893
 
894
+ /**
895
+ * Merge Chromium launch flags from three sources, in order, de-duped:
896
+ * 1. WSLg headed default (`--disable-gpu`) — auto-applied for headed launches
897
+ * under WSL so the window actually paints. Opt out with
898
+ * HARNERY_BROWSER_NO_WSL_DEFAULTS=1.
899
+ * 2. HARNERY_BROWSER_ARGS env (whitespace-separated) — a machine-wide default.
900
+ * 3. --browser-arg flags on this invocation (repeatable).
901
+ */
902
+ function resolveLaunchArgs(opts: BrowseOpts, headed: boolean): string[] {
903
+ const args: string[] = [];
904
+ if (headed && !process.env.HARNERY_BROWSER_NO_WSL_DEFAULTS) {
905
+ args.push(...wslHeadedLaunchArgs());
906
+ }
907
+ const envArgs = process.env.HARNERY_BROWSER_ARGS?.trim();
908
+ if (envArgs) args.push(...envArgs.split(/\s+/));
909
+ if (opts.browserArg && opts.browserArg.length > 0) args.push(...opts.browserArg);
910
+ return [...new Set(args)];
911
+ }
912
+
881
913
  function parseViewport(spec: string): { width: number; height: number } {
882
914
  const preset = VIEWPORT_PRESETS[spec.toLowerCase()];
883
915
  if (preset) return preset;