harnery 0.31.6 → 0.32.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 (218) hide show
  1. package/dist/commander.d.ts.map +1 -1
  2. package/dist/commander.js +2 -0
  3. package/dist/commands/agents.d.ts +0 -9
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +70 -95
  6. package/dist/commands/browse-session.d.ts +21 -0
  7. package/dist/commands/browse-session.d.ts.map +1 -0
  8. package/dist/commands/browse-session.js +157 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +205 -37
  11. package/dist/commands/checkpoint.js +1 -1
  12. package/dist/commands/deinit.d.ts.map +1 -1
  13. package/dist/commands/deinit.js +4 -0
  14. package/dist/commands/docs.d.ts.map +1 -1
  15. package/dist/commands/docs.js +40 -0
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +100 -17
  18. package/dist/commands/init.d.ts +1 -2
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +16 -5
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +2 -3
  23. package/dist/core/agents/canonical-emit.d.ts +1 -2
  24. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  25. package/dist/core/agents/canonical-emit.js +1 -2
  26. package/dist/core/agents/cli.js +123 -88
  27. package/dist/core/agents/coord-client.d.ts +7 -3
  28. package/dist/core/agents/coord-client.d.ts.map +1 -1
  29. package/dist/core/agents/coord-client.js +5 -4
  30. package/dist/core/agents/finalization.d.ts +68 -0
  31. package/dist/core/agents/finalization.d.ts.map +1 -0
  32. package/dist/core/agents/finalization.js +443 -0
  33. package/dist/core/agents/git-hook.d.ts +51 -0
  34. package/dist/core/agents/git-hook.d.ts.map +1 -0
  35. package/dist/core/agents/git-hook.js +118 -0
  36. package/dist/core/agents/render/prompt-context.d.ts +6 -5
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +26 -13
  39. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/session-context.js +15 -2
  41. package/dist/core/agents/rules/claim-conflict.js +3 -3
  42. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +21 -5
  45. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  46. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  47. package/dist/core/agents/rules/stop-hook.js +59 -23
  48. package/dist/core/agents/session-events.d.ts +8 -16
  49. package/dist/core/agents/session-events.d.ts.map +1 -1
  50. package/dist/core/agents/session-events.js +12 -26
  51. package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
  52. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  53. package/dist/core/agents/state/heartbeat-projector.js +44 -1
  54. package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
  55. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  56. package/dist/core/agents/state/heartbeat-writer.js +54 -17
  57. package/dist/core/agents/state/names.d.ts +22 -1
  58. package/dist/core/agents/state/names.d.ts.map +1 -1
  59. package/dist/core/agents/state/names.js +40 -2
  60. package/dist/core/config.d.ts +30 -2
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +74 -11
  63. package/dist/core/governor/planning.d.ts.map +1 -1
  64. package/dist/core/governor/planning.js +12 -13
  65. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  66. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  67. package/dist/core/hooks/adapter/detect.js +4 -14
  68. package/dist/core/hooks/adapter/output.d.ts +1 -1
  69. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  70. package/dist/core/hooks/adapter/output.js +5 -4
  71. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  72. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/wiring.js +32 -0
  74. package/dist/core/hooks/cli.d.ts +1 -2
  75. package/dist/core/hooks/cli.d.ts.map +1 -1
  76. package/dist/core/hooks/cli.js +169 -27
  77. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  78. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  79. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  80. package/dist/core/hooks/effects/index.d.ts +1 -1
  81. package/dist/core/hooks/effects/index.js +3 -3
  82. package/dist/core/hooks/events/schema.d.ts +27 -0
  83. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  84. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  85. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  86. package/dist/core/hooks/resolve/owner.js +15 -0
  87. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  88. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  89. package/dist/core/hooks/resolve/transcript.js +176 -1
  90. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  91. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  92. package/dist/core/hooks/session-name-presence.js +43 -0
  93. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  94. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  95. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  96. package/dist/core/work/state.d.ts +4 -5
  97. package/dist/core/work/state.d.ts.map +1 -1
  98. package/dist/core/work/state.js +5 -8
  99. package/dist/core/workflow/index.d.ts +1 -1
  100. package/dist/core/workflow/index.d.ts.map +1 -1
  101. package/dist/core/workflow/proof.d.ts +2 -2
  102. package/dist/core/workflow/proof.d.ts.map +1 -1
  103. package/dist/core/workflow/proof.js +2 -2
  104. package/dist/core/workflow/run-state.d.ts +2 -2
  105. package/dist/core/workflow/run-state.d.ts.map +1 -1
  106. package/dist/core/workflow/run-state.js +2 -2
  107. package/dist/core/workflow/types.d.ts +3 -3
  108. package/dist/core/workflow/types.d.ts.map +1 -1
  109. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  110. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  111. package/dist/core/workflow/workspaces/execution.js +5 -0
  112. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  113. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  114. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  115. package/dist/core/workflow/workspaces/local-git.js +1 -2
  116. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  117. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  118. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  119. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  120. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  121. package/dist/core/workflow/workspaces/validate.js +0 -2
  122. package/dist/lib/browser/client.d.ts +118 -1
  123. package/dist/lib/browser/client.d.ts.map +1 -1
  124. package/dist/lib/browser/client.js +435 -6
  125. package/dist/lib/browser/geometry.d.ts.map +1 -1
  126. package/dist/lib/browser/geometry.js +180 -37
  127. package/dist/lib/browser/index.d.ts +5 -1
  128. package/dist/lib/browser/index.d.ts.map +1 -1
  129. package/dist/lib/browser/index.js +5 -1
  130. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  131. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  132. package/dist/lib/browser/netscape-cookies.js +31 -0
  133. package/dist/lib/browser/proxy.d.ts +24 -0
  134. package/dist/lib/browser/proxy.d.ts.map +1 -0
  135. package/dist/lib/browser/proxy.js +84 -0
  136. package/dist/lib/browser/runts.d.ts +6 -0
  137. package/dist/lib/browser/runts.d.ts.map +1 -1
  138. package/dist/lib/browser/runts.js +21 -4
  139. package/dist/lib/browser/session-control.d.ts +112 -0
  140. package/dist/lib/browser/session-control.d.ts.map +1 -0
  141. package/dist/lib/browser/session-control.js +670 -0
  142. package/dist/lib/docs-links.d.ts +108 -0
  143. package/dist/lib/docs-links.d.ts.map +1 -0
  144. package/dist/lib/docs-links.js +555 -0
  145. package/dist/lib/exec.d.ts +1 -1
  146. package/dist/lib/exec.js +1 -1
  147. package/dist/lib/identities/assume.d.ts +4 -2
  148. package/dist/lib/identities/assume.d.ts.map +1 -1
  149. package/dist/lib/identities/assume.js +15 -2
  150. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  151. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  152. package/dist/lib/instructions/git-hooks.js +238 -0
  153. package/dist/lib/instructions/splice.d.ts +10 -4
  154. package/dist/lib/instructions/splice.d.ts.map +1 -1
  155. package/dist/lib/instructions/splice.js +19 -11
  156. package/package.json +1 -1
  157. package/schemas/config.schema.json +32 -0
  158. package/src/commander.ts +2 -0
  159. package/src/commands/agents.ts +96 -115
  160. package/src/commands/browse-session.ts +245 -0
  161. package/src/commands/browse.ts +287 -44
  162. package/src/commands/checkpoint.ts +1 -1
  163. package/src/commands/deinit.ts +7 -1
  164. package/src/commands/docs.ts +56 -0
  165. package/src/commands/doctor.ts +100 -18
  166. package/src/commands/init.ts +17 -9
  167. package/src/commands/tunnel.ts +2 -5
  168. package/src/core/agents/canonical-emit.ts +1 -2
  169. package/src/core/agents/cli.ts +137 -88
  170. package/src/core/agents/coord-client.ts +10 -5
  171. package/src/core/agents/finalization.ts +595 -0
  172. package/src/core/agents/git-hook.ts +126 -0
  173. package/src/core/agents/render/prompt-context.ts +28 -13
  174. package/src/core/agents/render/session-context.ts +15 -2
  175. package/src/core/agents/rules/claim-conflict.ts +3 -3
  176. package/src/core/agents/rules/commit-conflict.ts +35 -8
  177. package/src/core/agents/rules/stop-hook.ts +71 -23
  178. package/src/core/agents/session-events.ts +14 -41
  179. package/src/core/agents/state/heartbeat-projector.ts +45 -1
  180. package/src/core/agents/state/heartbeat-writer.ts +78 -16
  181. package/src/core/agents/state/names.ts +54 -2
  182. package/src/core/config.ts +92 -12
  183. package/src/core/governor/planning.ts +17 -12
  184. package/src/core/hooks/adapter/detect.ts +4 -13
  185. package/src/core/hooks/adapter/output.ts +9 -4
  186. package/src/core/hooks/adapter/wiring.ts +43 -0
  187. package/src/core/hooks/cli.ts +201 -23
  188. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  189. package/src/core/hooks/effects/index.ts +5 -5
  190. package/src/core/hooks/events/schema.ts +27 -0
  191. package/src/core/hooks/resolve/owner.ts +22 -1
  192. package/src/core/hooks/resolve/transcript.ts +164 -1
  193. package/src/core/hooks/session-name-presence.ts +52 -0
  194. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  195. package/src/core/work/state.ts +8 -12
  196. package/src/core/workflow/engine.ts +2 -2
  197. package/src/core/workflow/index.ts +0 -1
  198. package/src/core/workflow/proof.ts +4 -4
  199. package/src/core/workflow/run-state.ts +5 -5
  200. package/src/core/workflow/types.ts +3 -3
  201. package/src/core/workflow/workspaces/execution.ts +11 -4
  202. package/src/core/workflow/workspaces/index.ts +0 -3
  203. package/src/core/workflow/workspaces/local-git.ts +1 -2
  204. package/src/core/workflow/workspaces/paths.ts +2 -2
  205. package/src/core/workflow/workspaces/types.ts +0 -9
  206. package/src/core/workflow/workspaces/validate.ts +1 -5
  207. package/src/lib/browser/client.ts +528 -6
  208. package/src/lib/browser/geometry.ts +197 -37
  209. package/src/lib/browser/index.ts +39 -0
  210. package/src/lib/browser/netscape-cookies.ts +39 -0
  211. package/src/lib/browser/proxy.ts +105 -0
  212. package/src/lib/browser/runts.ts +27 -3
  213. package/src/lib/browser/session-control.ts +892 -0
  214. package/src/lib/docs-links.ts +674 -0
  215. package/src/lib/exec.ts +1 -1
  216. package/src/lib/identities/assume.ts +22 -1
  217. package/src/lib/instructions/git-hooks.ts +259 -0
  218. package/src/lib/instructions/splice.ts +41 -11
@@ -30,6 +30,12 @@ import type { Command } from "commander";
30
30
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
31
31
  import { coordBinPath } from "../core/agents/coord-bin.ts";
32
32
  import { readStreamTailBounded } from "../core/agents/events/consume.ts";
33
+ import {
34
+ checkGitFinalization,
35
+ formatGitFinalizationFailure,
36
+ type GitFinalizationResult,
37
+ readSessionWriteClaims,
38
+ } from "../core/agents/finalization.ts";
33
39
  import {
34
40
  emitCanonical,
35
41
  type Heartbeat,
@@ -40,6 +46,7 @@ import {
40
46
  resolveOwnerBySessionEnv,
41
47
  resolveOwnerWithSource,
42
48
  } from "../core/agents/index.ts";
49
+ import { buildSuggestedName } from "../core/agents/state/heartbeat-writer.ts";
43
50
  import { coordFreshnessSeconds, resolveBinName } from "../core/config.ts";
44
51
  import { type RemoteMachine, readRemoteMachines } from "../core/presence/index.ts";
45
52
  import { registerContextCommand } from "./context.ts";
@@ -221,13 +228,17 @@ export function registerAgentsCommand(
221
228
  .command("status")
222
229
  .description("End-of-turn status box (name + session age + files held + peer count)")
223
230
  .option("--json", "JSON output instead of the box")
231
+ .option(
232
+ "--end-turn",
233
+ "Treat this as the turn's closing status: issue the box only when this session's held paths are committed and their repositories are pushed",
234
+ )
224
235
  .option(
225
236
  "--session-id <id>",
226
237
  "Lookup heartbeat by session_id directly, bypassing the ppid walk. " +
227
238
  "Use this when calling from a hook (the hook's process tree may not lead back to Claude Code's session pid). " +
228
239
  "The Stop hook payload includes session_id; pass it through.",
229
240
  )
230
- .action((opts: { json?: boolean; sessionId?: string }) => {
241
+ .action((opts: { endTurn?: boolean; json?: boolean; sessionId?: string }) => {
231
242
  runStatus(opts);
232
243
  });
233
244
 
@@ -343,7 +354,7 @@ export function registerAgentsCommand(
343
354
  )
344
355
  .option(
345
356
  "--timeout <dur>",
346
- "Give up after this duration; suffix s/m/h/d (e.g. 30s, 5m, 1h). Bare integer = minutes. Default 60m.",
357
+ "Give up after this duration; suffix s/m/h/d is required (e.g. 30s, 5m, 1h). Default 60m.",
347
358
  "60m",
348
359
  )
349
360
  .option("--poll-secs <n>", "Poll interval in seconds (default 5)", "5")
@@ -381,8 +392,8 @@ export function registerAgentsCommand(
381
392
  cmd
382
393
  .command("health")
383
394
  .description(
384
- "One-screen coord-layer health rollup: heal events, schema validity, " +
385
- "commit-guard activity, council activity, anomalies. Designed for " +
395
+ "One-screen coord-layer health rollup: heal events, council activity, " +
396
+ "zombie detection, and anomalies. Designed for " +
386
397
  "daily glance + dashboard ingestion. Reads .harnery/.",
387
398
  )
388
399
  .option("--since <window>", "Window (Nh | Nd). Default: 24h.", "24h")
@@ -397,7 +408,7 @@ export function registerAgentsCommand(
397
408
  "Adapter wiring probe: ppid chain, comm names, pid-map anchor, sample payload paths. " +
398
409
  "With --replay-samples, also replays every checked-in sample payload against the live " +
399
410
  "adapter in an isolated sandbox to catch adapter / payload-shape drift. " +
400
- "Complements heal-events (drift telemetry). Id: claude_code | cursor.",
411
+ "Complements heal-events (drift telemetry). Id: claude-code | cursor.",
401
412
  )
402
413
  .option("--json", "JSON envelope output")
403
414
  .option(
@@ -552,12 +563,23 @@ function registerIdentityCommands(parent: Command): void {
552
563
  "--session-id <id>",
553
564
  "Bind the heartbeat with this session_id directly, bypassing the ppid walk.",
554
565
  )
555
- .action((target: string, opts: { json?: boolean; sessionId?: string }) => {
556
- runIdentityAssume(target, opts);
557
- });
566
+ .option(
567
+ "--force-ancestor",
568
+ "Allow assuming a persona this session's fork lineage descends from " +
569
+ "(default: refused, because a fork's inherited transcript asserting that " +
570
+ "name is not a role handoff).",
571
+ )
572
+ .action(
573
+ (target: string, opts: { json?: boolean; sessionId?: string; forceAncestor?: boolean }) => {
574
+ runIdentityAssume(target, opts);
575
+ },
576
+ );
558
577
  }
559
578
 
560
- function runIdentityAssume(target: string, opts: { json?: boolean; sessionId?: string }): void {
579
+ function runIdentityAssume(
580
+ target: string,
581
+ opts: { json?: boolean; sessionId?: string; forceAncestor?: boolean },
582
+ ): void {
561
583
  if (opts.json) emit.config({ format: "json" });
562
584
  const root = monorepoRoot();
563
585
  if (!root) {
@@ -578,7 +600,9 @@ function runIdentityAssume(target: string, opts: { json?: boolean; sessionId?: s
578
600
  process.exit(1);
579
601
  }
580
602
  try {
581
- const result = assumeIdentity(root, owner, target);
603
+ const result = assumeIdentity(root, owner, target, {
604
+ forceAncestor: opts.forceAncestor ?? false,
605
+ });
582
606
  emit.data({
583
607
  ...result,
584
608
  display_name: displayAgentName(result.name),
@@ -879,7 +903,7 @@ function runWhoami(opts: { json?: boolean }): void {
879
903
  task: hb.task ?? null,
880
904
  turn_summary: hb.turn_summary ?? null,
881
905
  turn_summary_updated_at: hb.turn_summary_updated_at ?? null,
882
- platform: hb.platform ?? "claude_code",
906
+ platform: hb.platform ?? "claude-code",
883
907
  };
884
908
 
885
909
  emit.data({ ...row, resolution_source: resolved.source, note: SUBAGENT_NOTE });
@@ -977,7 +1001,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
977
1001
  task: h.task ?? null,
978
1002
  turn_summary: h.turn_summary ?? null,
979
1003
  turn_summary_updated_at: h.turn_summary_updated_at ?? null,
980
- platform: h.platform ?? "claude_code",
1004
+ platform: h.platform ?? "claude-code",
981
1005
  });
982
1006
  continue;
983
1007
  }
@@ -1002,7 +1026,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1002
1026
  task: h.task ?? null,
1003
1027
  turn_summary: h.turn_summary ?? null,
1004
1028
  turn_summary_updated_at: h.turn_summary_updated_at ?? null,
1005
- platform: h.platform ?? "claude_code",
1029
+ platform: h.platform ?? "claude-code",
1006
1030
  });
1007
1031
  }
1008
1032
 
@@ -1030,7 +1054,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1030
1054
  task: a.task ?? null,
1031
1055
  turn_summary: a.turn_summary ?? null,
1032
1056
  turn_summary_updated_at: null,
1033
- platform: a.platform ?? "claude_code",
1057
+ platform: a.platform ?? "claude-code",
1034
1058
  machine: rm.machine,
1035
1059
  });
1036
1060
  }
@@ -1495,15 +1519,13 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1495
1519
  process.exit(1);
1496
1520
  }
1497
1521
 
1498
- // Snapshot BEFORE the mutation: a heartbeat that has never carried a task
1499
- // has no `task_updated_at` stamp (setTask always stamps it, even on a clear),
1500
- // so its absence is the "focus never declared this session" signal — i.e. the
1501
- // first set-task of the session. That's the natural moment to also hand the
1502
- // operator a session name built from the same declaration (one source of
1503
- // truth: the focus you declare names the session). Distinct from task===""
1504
- // (a deliberate clear), which carries a stamp and does NOT re-trigger.
1522
+ // Snapshot BEFORE the mutation: the writer stamps `suggested_session_name`
1523
+ // on the session's first NON-EMPTY declaration (one source of truth: the
1524
+ // focus you declare names the session). A bare clear never consumes the
1525
+ // naming window, and subagent/workflow kinds are never named — see
1526
+ // heartbeat-writer.setTask. This call is the naming call exactly when the
1527
+ // stamp appears across the mutation.
1505
1528
  const priorHb = readHeartbeat(myOwner);
1506
- const firstOfSession = !priorHb?.task_updated_at;
1507
1529
 
1508
1530
  // Heartbeat mutation goes through agent-coord (atomic temp+rename).
1509
1531
  const helper = agentCoordOrExit(root);
@@ -1520,46 +1542,47 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1520
1542
  }
1521
1543
 
1522
1544
  const hb = readHeartbeat(myOwner);
1545
+
1546
+ // The naming call: the mutation just stamped `suggested_session_name` for
1547
+ // the first time. `first_of_session: true` means exactly "this call produced
1548
+ // the session name" — it is never true with a null name (an empty first
1549
+ // declaration or a `--session-id` relay both keep the window open instead).
1550
+ const suggestedName =
1551
+ !priorHb?.suggested_session_name && hb?.suggested_session_name
1552
+ ? hb.suggested_session_name
1553
+ : null;
1554
+ const firstOfSession = suggestedName !== null;
1555
+
1523
1556
  emitCanonical({
1524
1557
  type: "state.task_set",
1525
1558
  owner: myOwner,
1526
1559
  session: hb?.session_id ?? myOwner,
1527
1560
  adapter: normalizeAdapter(hb?.platform),
1528
- data: { task, cleared: !task || task.length === 0 },
1561
+ data: {
1562
+ task,
1563
+ cleared: !task || task.length === 0,
1564
+ first_of_session: firstOfSession,
1565
+ suggested_session_name: suggestedName,
1566
+ },
1529
1567
  });
1530
1568
 
1531
- // On the first focus declaration of a session, surface a suggested session
1532
- // name for the operator to set as their adapter tab title. Read the result
1533
- // (`suggested_session_name`) and reproduce it in a fenced code block so the
1534
- // UI's Copy button hands over the exact string. null on every later call.
1535
- const built =
1536
- firstOfSession && !opts?.sessionId ? buildSuggestedName(hb?.name ?? "unknown", [task]) : null;
1537
-
1538
1569
  emit.data({
1539
1570
  instance_id: myOwner,
1540
1571
  name: hb?.name ?? null,
1541
1572
  task: hb?.task ?? null,
1542
1573
  cleared: !task || task.length === 0,
1543
1574
  first_of_session: firstOfSession,
1544
- suggested_session_name: built?.suggestedName ?? null,
1575
+ suggested_session_name: suggestedName,
1576
+ // Right-time instruction: the UserPromptSubmit nudge fires before the name
1577
+ // exists; this result is the moment the model holds the string.
1578
+ ...(suggestedName
1579
+ ? {
1580
+ note: "Reproduce suggested_session_name verbatim, by itself, in a fenced code block at the top of your reply — the operator copies it as the session/tab title.",
1581
+ }
1582
+ : {}),
1545
1583
  });
1546
1584
  }
1547
1585
 
1548
- /**
1549
- * Build the copy-pasteable session name from the coord identity + the agent's
1550
- * description parts. Pure (no coord-state reads) so it's unit-testable; collapses
1551
- * internal whitespace and trims. Returns null when the description is empty.
1552
- */
1553
- export function buildSuggestedName(
1554
- agentName: string,
1555
- descriptionParts: string[],
1556
- ): { suggestedName: string; description: string } | null {
1557
- const description = descriptionParts.join(" ").replace(/\s+/g, " ").trim();
1558
- if (!description) return null;
1559
- const name = agentName?.trim() || "unknown";
1560
- return { suggestedName: `Agent ${name} - ${description}`, description };
1561
- }
1562
-
1563
1586
  function runSuggestName(
1564
1587
  descriptionParts: string[],
1565
1588
  opts: { json?: boolean; sessionId?: string },
@@ -1624,7 +1647,7 @@ function runSuggestName(
1624
1647
  process.stdout.write(`${suggestedName}\n`); // lint-ok-emission: chat-paste path
1625
1648
  }
1626
1649
 
1627
- function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1650
+ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string }): void {
1628
1651
  const root = monorepoRoot();
1629
1652
  if (!root) {
1630
1653
  emit.error({
@@ -1656,20 +1679,23 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1656
1679
  process.exit(1);
1657
1680
  }
1658
1681
 
1659
- // Stamp .last_status_at = NOW. The verdict path reads state.status_checked
1660
- // canonical events (emitted below), but the legacy heartbeat field is still
1661
- // populated for back-compat with consumers reading v1 directly. The stamp
1662
- // goes through agent-coord (atomic write).
1663
- try {
1664
- const helper = agentCoordOrExit(root);
1665
- spawnSync(helper, ["stamp-status-call", myOwner], {
1666
- encoding: "utf8",
1667
- timeout: 2000,
1668
- ...coordHelperOpts(root),
1682
+ let finalization: GitFinalizationResult | null = null;
1683
+ if (opts.endTurn) {
1684
+ const history = readSessionWriteClaims(root, hb.instance_id, hb.session_id ?? myOwner);
1685
+ const touchedPaths = [...new Set([...(hb.files_touched ?? []), ...history.paths])];
1686
+ finalization = checkGitFinalization(root, touchedPaths, {
1687
+ claimHistoryComplete: history.complete,
1669
1688
  });
1670
- } catch {
1671
- /* non-fatal */
1689
+ if (!finalization.ok) {
1690
+ emit.error({
1691
+ code: "git_not_finalized",
1692
+ message: formatGitFinalizationFailure(finalization, resolveBinName(root)),
1693
+ });
1694
+ process.exitCode = 1;
1695
+ return;
1696
+ }
1672
1697
  }
1698
+
1673
1699
  emitCanonical({
1674
1700
  type: "state.status_checked",
1675
1701
  owner: myOwner,
@@ -1677,6 +1703,7 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1677
1703
  adapter: normalizeAdapter(hb.platform),
1678
1704
  data: {
1679
1705
  format: opts.json ? "json" : "box",
1706
+ git_finalization_checked: opts.endTurn === true,
1680
1707
  agent_count: 0, // computed below, not yet available here; Phase 5 verdict reads owner-scope only
1681
1708
  included_self: true,
1682
1709
  },
@@ -1784,6 +1811,7 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1784
1811
  context_window: ctxUsage?.window ?? null,
1785
1812
  timestamp_iso: new Date().toISOString(),
1786
1813
  timestamp_local: timeStr,
1814
+ ...(finalization ? { finalization } : {}),
1787
1815
  };
1788
1816
 
1789
1817
  if (opts.json) {
@@ -2154,13 +2182,12 @@ function readCanonicalEventsInWindow(root: string, cutoffMs: number): CanonicalE
2154
2182
  return out;
2155
2183
  }
2156
2184
 
2157
- /** adapter ("claude-code") → legacy platform label ("claude_code") so the
2158
- * existing formatPlatformLabel rendering keeps working unchanged. */
2185
+ /** Normalize adapter event data into the heartbeat platform value. */
2159
2186
  function adapterToPlatform(adapter: string | undefined): string {
2160
- if (adapter === "claude-code") return "claude_code";
2187
+ if (adapter === "claude-code") return "claude-code";
2161
2188
  if (adapter === "cursor") return "cursor";
2162
2189
  if (adapter === "codex") return "codex";
2163
- return "claude_code";
2190
+ return "claude-code";
2164
2191
  }
2165
2192
 
2166
2193
  /** Project a canonical health.* event into the HealEvent shape the aggregators
@@ -2387,14 +2414,6 @@ interface HealthReport {
2387
2414
  by_platform: Record<string, number>;
2388
2415
  top_agents: Array<{ agent: string; count: number }>;
2389
2416
  };
2390
- schema_invalid: { count: number; samples: string[] };
2391
- commit_guards: {
2392
- blocked: number;
2393
- bypassed: number;
2394
- suppressed: number;
2395
- edit_blocked: number;
2396
- shell_candidates: number;
2397
- };
2398
2417
  councils: {
2399
2418
  active: number;
2400
2419
  archived_in_window: number;
@@ -2736,18 +2755,7 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2736
2755
 
2737
2756
  // Coordination telemetry reads from the canonical events.ndjson stream.
2738
2757
  // Heals come from health.*; council window-activity from council.*. The
2739
- // commit-guard + schema-invalid counters below have NO canonical equivalent
2740
- // yet. Their future home is the decision.* events (defined in schema.ts, not
2741
- // yet wired). Until then they report 0 (fields kept for output-shape
2742
- // compatibility).
2743
2758
  const heal: HealEvent[] = [];
2744
- const schemaInvalid = 0;
2745
- const schemaSamples: string[] = [];
2746
- const commitBlocked = 0;
2747
- const commitBypassed = 0;
2748
- const commitSuppressed = 0;
2749
- const editBlocked = 0;
2750
- const shellCandidates = 0;
2751
2759
  let councilAdvanced = 0;
2752
2760
  let councilClosed = 0;
2753
2761
  let councilArchived = 0;
@@ -2865,12 +2873,6 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2865
2873
 
2866
2874
  // Anomaly detection.
2867
2875
  const anomalies: string[] = [];
2868
- if (schemaInvalid > 0) {
2869
- const sampleList = schemaSamples.slice(0, 3).join(", ");
2870
- anomalies.push(
2871
- `HEARTBEAT_SCHEMA_INVALID fired ${schemaInvalid}x; heartbeat shape failed validation${sampleList ? ` (samples: ${sampleList})` : ""}`,
2872
- );
2873
- }
2874
2876
  for (const { agent, count } of healTopAgents) {
2875
2877
  if (count >= 5) {
2876
2878
  anomalies.push(
@@ -2937,14 +2939,6 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2937
2939
  by_platform: healByPlatform,
2938
2940
  top_agents: healTopAgents,
2939
2941
  },
2940
- schema_invalid: { count: schemaInvalid, samples: schemaSamples },
2941
- commit_guards: {
2942
- blocked: commitBlocked,
2943
- bypassed: commitBypassed,
2944
- suppressed: commitSuppressed,
2945
- edit_blocked: editBlocked,
2946
- shell_candidates: shellCandidates,
2947
- },
2948
2942
  councils: {
2949
2943
  active: activeCouncils,
2950
2944
  archived_in_window: councilArchived,
@@ -2988,17 +2982,6 @@ function renderHealthBox(report: HealthReport): void {
2988
2982
  const topHealer = report.heal_events.top_agents[0];
2989
2983
  const topHealerStr = topHealer ? `${topHealer.agent} x${topHealer.count}` : "(none)";
2990
2984
 
2991
- const guardParts: string[] = [];
2992
- if (report.commit_guards.blocked > 0) guardParts.push(`blocked ${report.commit_guards.blocked}`);
2993
- if (report.commit_guards.bypassed > 0)
2994
- guardParts.push(`bypassed ${report.commit_guards.bypassed}`);
2995
- if (report.commit_guards.suppressed > 0)
2996
- guardParts.push(`suppressed ${report.commit_guards.suppressed}`);
2997
- if (report.commit_guards.edit_blocked > 0)
2998
- guardParts.push(`edit-blocked ${report.commit_guards.edit_blocked}`);
2999
- if (report.commit_guards.shell_candidates > 0)
3000
- guardParts.push(`shell ${report.commit_guards.shell_candidates}`);
3001
-
3002
2985
  const councilParts: string[] = [`${report.councils.active} active`];
3003
2986
  if (report.councils.advanced_in_window > 0)
3004
2987
  councilParts.push(`${report.councils.advanced_in_window} advanced`);
@@ -3035,8 +3018,6 @@ function renderHealthBox(report: HealthReport): void {
3035
3018
  ? "0"
3036
3019
  : `${report.zombies.count} (${report.zombies.samples.join(", ")})`,
3037
3020
  ],
3038
- ["schema invalid", String(report.schema_invalid.count)],
3039
- ["commit guards", guardParts.length ? guardParts.join(", ") : "0"],
3040
3021
  ["councils", councilParts.join(", ")],
3041
3022
  ["anomalies", report.anomalies.length === 0 ? "(clean)" : `${report.anomalies.length} flagged`],
3042
3023
  ];
@@ -3070,10 +3051,10 @@ function runAdapterProbe(
3070
3051
  if (opts.json) emit.config({ format: "json" });
3071
3052
 
3072
3053
  const adapter = id.trim();
3073
- if (adapter !== "claude_code" && adapter !== "cursor") {
3054
+ if (adapter !== "claude-code" && adapter !== "cursor") {
3074
3055
  emit.error({
3075
3056
  code: "bad_adapter",
3076
- message: "adapter id must be claude_code or cursor",
3057
+ message: "adapter id must be claude-code or cursor",
3077
3058
  });
3078
3059
  process.exit(1);
3079
3060
  }
@@ -3293,7 +3274,7 @@ function replayAdapterSamples(
3293
3274
  stopFailure: "stop-failure",
3294
3275
  StopFailure: "stop-failure",
3295
3276
  };
3296
- const adapterFlag = adapter === "claude_code" ? "claude-code" : adapter;
3277
+ const adapterFlag = adapter;
3297
3278
 
3298
3279
  const sandbox = mkdtempSync(join(tmpdir(), "harn-adapter-probe-"));
3299
3280
  const results: SampleReplayResult[] = [];
@@ -3430,13 +3411,13 @@ function collectPath(value: string, prev: string[]): string[] {
3430
3411
  return [...prev, value];
3431
3412
  }
3432
3413
 
3433
- /** Parse "30", "30s", "5m", "1h", "2d" → ms. Bare integer defaults to minutes (back-compat). */
3414
+ /** Parse "30s", "5m", "1h", "2d" into milliseconds. */
3434
3415
  function parseDurationToMs(input: string): number | null {
3435
- const match = input.trim().match(/^(\d+)([smhd]?)$/i);
3416
+ const match = input.trim().match(/^(\d+)([smhd])$/i);
3436
3417
  if (!match) return null;
3437
3418
  const n = Number.parseInt(match[1], 10);
3438
3419
  if (!Number.isFinite(n) || n <= 0) return null;
3439
- const unit = (match[2] || "m").toLowerCase();
3420
+ const unit = match[2].toLowerCase();
3440
3421
  const mult: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
3441
3422
  return n * mult[unit];
3442
3423
  }
@@ -0,0 +1,245 @@
1
+ import type { Readable } from "node:stream";
2
+ import type { Command } from "commander";
3
+ import type { EmitContext } from "../commander.ts";
4
+ import type { BrowserSessionLocator } from "../lib/browser/client.ts";
5
+ import {
6
+ BROWSER_SESSION_MAX_FILL_BYTES,
7
+ BrowserSessionError,
8
+ sendBrowserSessionRequest,
9
+ } from "../lib/browser/session-control.ts";
10
+
11
+ interface ControlFileOpts {
12
+ controlFile: string;
13
+ }
14
+
15
+ interface LocatorOpts extends ControlFileOpts {
16
+ selector?: string;
17
+ role?: string;
18
+ name?: string;
19
+ label?: string;
20
+ text?: string;
21
+ partial?: boolean;
22
+ }
23
+
24
+ export function registerBrowseSessionCommand(program: Command, emit: EmitContext): void {
25
+ const session = program
26
+ .command("browse-session")
27
+ .description("Control an opted-in headed browse session through an owner-only descriptor");
28
+
29
+ session
30
+ .command("status")
31
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
32
+ .action((opts: ControlFileOpts) => runAction(emit, opts.controlFile, "status", {}));
33
+
34
+ addLocatorOptions(
35
+ session
36
+ .command("inspect")
37
+ .description("Inspect sanitized text, controls, focus, and active-tab state")
38
+ .requiredOption("--control-file <path>", "Owner-only session descriptor"),
39
+ ).action((opts: LocatorOpts) => {
40
+ const locator = parseLocatorOptions(opts, false);
41
+ return runAction(emit, opts.controlFile, "inspect", locator ? { locator } : {});
42
+ });
43
+
44
+ session
45
+ .command("screenshot")
46
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
47
+ .requiredOption("--out <path>", "New PNG path in an existing directory")
48
+ .action((opts: ControlFileOpts & { out: string }) =>
49
+ runAction(emit, opts.controlFile, "screenshot", { out: opts.out }),
50
+ );
51
+
52
+ session
53
+ .command("tabs")
54
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
55
+ .action((opts: ControlFileOpts) => runAction(emit, opts.controlFile, "tabs", {}));
56
+
57
+ session
58
+ .command("select-tab")
59
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
60
+ .requiredOption("--index <n>", "Stable session-local tab index", parseIndex)
61
+ .action((opts: ControlFileOpts & { index: number }) =>
62
+ runAction(emit, opts.controlFile, "select_tab", { index: opts.index }),
63
+ );
64
+
65
+ session
66
+ .command("open-tab <url>")
67
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
68
+ .action((url: string, opts: ControlFileOpts) =>
69
+ runAction(emit, opts.controlFile, "open_tab", { url }),
70
+ );
71
+
72
+ session
73
+ .command("close-tab")
74
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
75
+ .requiredOption("--index <n>", "Stable session-local tab index", parseIndex)
76
+ .action((opts: ControlFileOpts & { index: number }) =>
77
+ runAction(emit, opts.controlFile, "close_tab", { index: opts.index }),
78
+ );
79
+
80
+ session
81
+ .command("goto <url>")
82
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
83
+ .action((url: string, opts: ControlFileOpts) =>
84
+ runAction(emit, opts.controlFile, "goto", { url }),
85
+ );
86
+
87
+ session
88
+ .command("reload")
89
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
90
+ .action((opts: ControlFileOpts) => runAction(emit, opts.controlFile, "reload", {}));
91
+
92
+ addLocatorOptions(
93
+ session
94
+ .command("click")
95
+ .requiredOption("--control-file <path>", "Owner-only session descriptor"),
96
+ ).action((opts: LocatorOpts) =>
97
+ runAction(emit, opts.controlFile, "click", { locator: parseLocatorOptions(opts, true) }),
98
+ );
99
+
100
+ addLocatorOptions(
101
+ session
102
+ .command("fill")
103
+ .description("Fill one strict locator with a value read once from standard input")
104
+ .requiredOption("--control-file <path>", "Owner-only session descriptor"),
105
+ ).action(async (opts: LocatorOpts) => {
106
+ const value = await readFillValue(process.stdin);
107
+ return runAction(emit, opts.controlFile, "fill", {
108
+ locator: parseLocatorOptions(opts, true),
109
+ value,
110
+ });
111
+ });
112
+
113
+ session
114
+ .command("press <key>")
115
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
116
+ .action((key: string, opts: ControlFileOpts) =>
117
+ runAction(emit, opts.controlFile, "press", { key }),
118
+ );
119
+
120
+ addLocatorOptions(
121
+ session
122
+ .command("wait")
123
+ .requiredOption("--control-file <path>", "Owner-only session descriptor"),
124
+ ).action((opts: LocatorOpts) =>
125
+ runAction(emit, opts.controlFile, "wait", { locator: parseLocatorOptions(opts, true) }),
126
+ );
127
+
128
+ session
129
+ .command("close")
130
+ .requiredOption("--control-file <path>", "Owner-only session descriptor")
131
+ .action((opts: ControlFileOpts) => runAction(emit, opts.controlFile, "close", {}));
132
+ }
133
+
134
+ function addLocatorOptions(command: Command): Command {
135
+ return command
136
+ .option("--selector <css>", "CSS locator")
137
+ .option("--role <role>", "Accessible role locator")
138
+ .option("--name <name>", "Accessible name used with --role")
139
+ .option("--label <text>", "Associated label locator")
140
+ .option("--text <text>", "Visible text locator")
141
+ .option("--partial", "Allow partial accessible-name, label, or text matching");
142
+ }
143
+
144
+ export function parseLocatorOptions(opts: LocatorOpts, required: true): BrowserSessionLocator;
145
+ export function parseLocatorOptions(
146
+ opts: LocatorOpts,
147
+ required: false,
148
+ ): BrowserSessionLocator | undefined;
149
+ export function parseLocatorOptions(
150
+ opts: LocatorOpts,
151
+ required: boolean,
152
+ ): BrowserSessionLocator | undefined {
153
+ const forms = [opts.selector, opts.role, opts.label, opts.text].filter(
154
+ (value): value is string => value !== undefined,
155
+ );
156
+ if (forms.length === 0) {
157
+ if (!required) {
158
+ if (opts.name !== undefined || opts.partial) {
159
+ throw new BrowserSessionError(
160
+ "invalid_locator",
161
+ "--name and --partial require a locator form.",
162
+ );
163
+ }
164
+ return undefined;
165
+ }
166
+ throw new BrowserSessionError(
167
+ "invalid_locator",
168
+ "Choose exactly one locator: --selector, --role, --label, or --text.",
169
+ );
170
+ }
171
+ if (forms.length !== 1) {
172
+ throw new BrowserSessionError(
173
+ "invalid_locator",
174
+ "Choose exactly one locator: --selector, --role, --label, or --text.",
175
+ );
176
+ }
177
+ if (opts.name !== undefined && opts.role === undefined) {
178
+ throw new BrowserSessionError("invalid_locator", "--name is valid only with --role.");
179
+ }
180
+ if (opts.selector !== undefined) {
181
+ if (opts.partial) {
182
+ throw new BrowserSessionError("invalid_locator", "--partial is not valid with --selector.");
183
+ }
184
+ return { kind: "selector", value: opts.selector, partial: false };
185
+ }
186
+ if (opts.role !== undefined) {
187
+ return {
188
+ kind: "role",
189
+ value: opts.role,
190
+ ...(opts.name === undefined ? {} : { name: opts.name }),
191
+ partial: Boolean(opts.partial),
192
+ };
193
+ }
194
+ if (opts.label !== undefined) {
195
+ return { kind: "label", value: opts.label, partial: Boolean(opts.partial) };
196
+ }
197
+ return { kind: "text", value: opts.text!, partial: Boolean(opts.partial) };
198
+ }
199
+
200
+ export async function readFillValue(input: Readable): Promise<string> {
201
+ const chunks: Buffer[] = [];
202
+ let size = 0;
203
+ for await (const chunk of input) {
204
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
205
+ size += bytes.length;
206
+ if (size > BROWSER_SESSION_MAX_FILL_BYTES) {
207
+ throw new BrowserSessionError("fill_too_large", "Fill input exceeds the 64 KiB limit.");
208
+ }
209
+ chunks.push(bytes);
210
+ }
211
+ const value = Buffer.concat(chunks)
212
+ .toString("utf8")
213
+ .replace(/\r?\n$/, "");
214
+ if (value.length === 0) {
215
+ throw new BrowserSessionError("fill_empty", "Fill input from standard input was empty.");
216
+ }
217
+ return value;
218
+ }
219
+
220
+ async function runAction(
221
+ emit: EmitContext,
222
+ controlFile: string,
223
+ action: Parameters<typeof sendBrowserSessionRequest>[1],
224
+ args: Record<string, unknown>,
225
+ ): Promise<void> {
226
+ try {
227
+ emit.data(await sendBrowserSessionRequest(controlFile, action, args));
228
+ } catch (error) {
229
+ if (error instanceof BrowserSessionError) {
230
+ emit.error({ code: error.code, message: error.message });
231
+ emit.setExitCode(1);
232
+ return;
233
+ }
234
+ emit.error({ code: "browser_session_failed", message: "Browser session command failed." });
235
+ emit.setExitCode(1);
236
+ }
237
+ }
238
+
239
+ function parseIndex(value: string): number {
240
+ const parsed = Number(value);
241
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
242
+ throw new BrowserSessionError("invalid_index", "Tab index must be a non-negative integer.");
243
+ }
244
+ return parsed;
245
+ }