harnery 0.31.6 → 0.33.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 (264) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +5 -1
  4. package/dist/commands/agents.d.ts +0 -9
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +280 -94
  7. package/dist/commands/browse-session.d.ts +21 -0
  8. package/dist/commands/browse-session.d.ts.map +1 -0
  9. package/dist/commands/browse-session.js +157 -0
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +205 -37
  12. package/dist/commands/checkpoint.js +1 -1
  13. package/dist/commands/claude-desktop.d.ts +2 -2
  14. package/dist/commands/claude-desktop.d.ts.map +1 -1
  15. package/dist/commands/claude-desktop.js +74 -3
  16. package/dist/commands/deinit.d.ts.map +1 -1
  17. package/dist/commands/deinit.js +4 -0
  18. package/dist/commands/docs.d.ts.map +1 -1
  19. package/dist/commands/docs.js +40 -0
  20. package/dist/commands/doctor.d.ts +4 -2
  21. package/dist/commands/doctor.d.ts.map +1 -1
  22. package/dist/commands/doctor.js +141 -19
  23. package/dist/commands/init.d.ts +2 -2
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +26 -5
  26. package/dist/commands/instructions.d.ts +4 -0
  27. package/dist/commands/instructions.d.ts.map +1 -0
  28. package/dist/commands/instructions.js +100 -0
  29. package/dist/commands/presence.d.ts.map +1 -1
  30. package/dist/commands/presence.js +4 -1
  31. package/dist/commands/tunnel.d.ts.map +1 -1
  32. package/dist/commands/tunnel.js +2 -3
  33. package/dist/core/agents/canonical-emit.d.ts +2 -3
  34. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  35. package/dist/core/agents/canonical-emit.js +6 -5
  36. package/dist/core/agents/cli.js +128 -89
  37. package/dist/core/agents/coord-client.d.ts +18 -3
  38. package/dist/core/agents/coord-client.d.ts.map +1 -1
  39. package/dist/core/agents/coord-client.js +5 -4
  40. package/dist/core/agents/finalization.d.ts +68 -0
  41. package/dist/core/agents/finalization.d.ts.map +1 -0
  42. package/dist/core/agents/finalization.js +443 -0
  43. package/dist/core/agents/git-hook.d.ts +51 -0
  44. package/dist/core/agents/git-hook.d.ts.map +1 -0
  45. package/dist/core/agents/git-hook.js +118 -0
  46. package/dist/core/agents/index.d.ts +1 -0
  47. package/dist/core/agents/index.d.ts.map +1 -1
  48. package/dist/core/agents/index.js +1 -0
  49. package/dist/core/agents/render/prompt-context.d.ts +12 -5
  50. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  51. package/dist/core/agents/render/prompt-context.js +70 -16
  52. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  53. package/dist/core/agents/render/session-context.js +23 -4
  54. package/dist/core/agents/rules/claim-conflict.js +3 -3
  55. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  56. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  57. package/dist/core/agents/rules/commit-conflict.js +21 -5
  58. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  59. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  60. package/dist/core/agents/rules/stop-hook.js +59 -23
  61. package/dist/core/agents/session-events.d.ts +8 -16
  62. package/dist/core/agents/session-events.d.ts.map +1 -1
  63. package/dist/core/agents/session-events.js +12 -26
  64. package/dist/core/agents/state/heartbeat-projector.d.ts +14 -0
  65. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  66. package/dist/core/agents/state/heartbeat-projector.js +98 -1
  67. package/dist/core/agents/state/heartbeat-writer.d.ts +47 -5
  68. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  69. package/dist/core/agents/state/heartbeat-writer.js +86 -17
  70. package/dist/core/agents/state/names.d.ts +22 -1
  71. package/dist/core/agents/state/names.d.ts.map +1 -1
  72. package/dist/core/agents/state/names.js +40 -2
  73. package/dist/core/agents/state/session-state.d.ts +44 -0
  74. package/dist/core/agents/state/session-state.d.ts.map +1 -0
  75. package/dist/core/agents/state/session-state.js +81 -0
  76. package/dist/core/config.d.ts +30 -2
  77. package/dist/core/config.d.ts.map +1 -1
  78. package/dist/core/config.js +74 -11
  79. package/dist/core/governor/planning.d.ts.map +1 -1
  80. package/dist/core/governor/planning.js +12 -13
  81. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  82. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  83. package/dist/core/hooks/adapter/detect.js +4 -14
  84. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  85. package/dist/core/hooks/adapter/events.js +1 -0
  86. package/dist/core/hooks/adapter/output.d.ts +1 -1
  87. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  88. package/dist/core/hooks/adapter/output.js +5 -4
  89. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  90. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  91. package/dist/core/hooks/adapter/parse.js +2 -0
  92. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  93. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  94. package/dist/core/hooks/adapter/wiring.js +32 -0
  95. package/dist/core/hooks/cli.d.ts +1 -2
  96. package/dist/core/hooks/cli.d.ts.map +1 -1
  97. package/dist/core/hooks/cli.js +258 -32
  98. package/dist/core/hooks/codex-authorization.d.ts +30 -0
  99. package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
  100. package/dist/core/hooks/codex-authorization.js +199 -0
  101. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  102. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  103. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  104. package/dist/core/hooks/effects/index.d.ts +1 -1
  105. package/dist/core/hooks/effects/index.js +3 -3
  106. package/dist/core/hooks/events/schema.d.ts +57 -1
  107. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  108. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  109. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  110. package/dist/core/hooks/resolve/owner.js +15 -0
  111. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  112. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  113. package/dist/core/hooks/resolve/transcript.js +176 -1
  114. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  115. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  116. package/dist/core/hooks/session-name-presence.js +43 -0
  117. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  118. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  119. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  120. package/dist/core/presence/blob.d.ts +4 -0
  121. package/dist/core/presence/blob.d.ts.map +1 -1
  122. package/dist/core/presence/blob.js +12 -0
  123. package/dist/core/presence/index.js +15 -1
  124. package/dist/core/work/state.d.ts +4 -5
  125. package/dist/core/work/state.d.ts.map +1 -1
  126. package/dist/core/work/state.js +5 -8
  127. package/dist/core/workflow/index.d.ts +1 -1
  128. package/dist/core/workflow/index.d.ts.map +1 -1
  129. package/dist/core/workflow/proof.d.ts +2 -2
  130. package/dist/core/workflow/proof.d.ts.map +1 -1
  131. package/dist/core/workflow/proof.js +2 -2
  132. package/dist/core/workflow/run-state.d.ts +2 -2
  133. package/dist/core/workflow/run-state.d.ts.map +1 -1
  134. package/dist/core/workflow/run-state.js +2 -2
  135. package/dist/core/workflow/types.d.ts +3 -3
  136. package/dist/core/workflow/types.d.ts.map +1 -1
  137. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  138. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  139. package/dist/core/workflow/workspaces/execution.js +5 -0
  140. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  141. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  142. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  143. package/dist/core/workflow/workspaces/local-git.js +1 -2
  144. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  145. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  146. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  147. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  148. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  149. package/dist/core/workflow/workspaces/validate.js +0 -2
  150. package/dist/lib/browser/client.d.ts +118 -1
  151. package/dist/lib/browser/client.d.ts.map +1 -1
  152. package/dist/lib/browser/client.js +435 -6
  153. package/dist/lib/browser/geometry.d.ts.map +1 -1
  154. package/dist/lib/browser/geometry.js +180 -37
  155. package/dist/lib/browser/index.d.ts +5 -1
  156. package/dist/lib/browser/index.d.ts.map +1 -1
  157. package/dist/lib/browser/index.js +5 -1
  158. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  159. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  160. package/dist/lib/browser/netscape-cookies.js +31 -0
  161. package/dist/lib/browser/proxy.d.ts +24 -0
  162. package/dist/lib/browser/proxy.d.ts.map +1 -0
  163. package/dist/lib/browser/proxy.js +84 -0
  164. package/dist/lib/browser/runts.d.ts +6 -0
  165. package/dist/lib/browser/runts.d.ts.map +1 -1
  166. package/dist/lib/browser/runts.js +21 -4
  167. package/dist/lib/browser/session-control.d.ts +112 -0
  168. package/dist/lib/browser/session-control.d.ts.map +1 -0
  169. package/dist/lib/browser/session-control.js +670 -0
  170. package/dist/lib/claude-desktop.d.ts +47 -0
  171. package/dist/lib/claude-desktop.d.ts.map +1 -1
  172. package/dist/lib/claude-desktop.js +169 -1
  173. package/dist/lib/docs-links.d.ts +108 -0
  174. package/dist/lib/docs-links.d.ts.map +1 -0
  175. package/dist/lib/docs-links.js +555 -0
  176. package/dist/lib/exec.d.ts +1 -1
  177. package/dist/lib/exec.js +1 -1
  178. package/dist/lib/identities/assume.d.ts +4 -2
  179. package/dist/lib/identities/assume.d.ts.map +1 -1
  180. package/dist/lib/identities/assume.js +15 -2
  181. package/dist/lib/instructions/bundle.d.ts +33 -0
  182. package/dist/lib/instructions/bundle.d.ts.map +1 -0
  183. package/dist/lib/instructions/bundle.js +149 -0
  184. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  185. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  186. package/dist/lib/instructions/git-hooks.js +238 -0
  187. package/dist/lib/instructions/splice.d.ts +10 -4
  188. package/dist/lib/instructions/splice.d.ts.map +1 -1
  189. package/dist/lib/instructions/splice.js +19 -11
  190. package/package.json +2 -2
  191. package/schemas/config.schema.json +32 -0
  192. package/src/commander.ts +5 -1
  193. package/src/commands/agents.ts +341 -113
  194. package/src/commands/browse-session.ts +245 -0
  195. package/src/commands/browse.ts +287 -44
  196. package/src/commands/checkpoint.ts +1 -1
  197. package/src/commands/claude-desktop.ts +106 -5
  198. package/src/commands/deinit.ts +7 -1
  199. package/src/commands/docs.ts +56 -0
  200. package/src/commands/doctor.ts +155 -21
  201. package/src/commands/init.ts +28 -9
  202. package/src/commands/instructions.ts +129 -0
  203. package/src/commands/presence.ts +7 -1
  204. package/src/commands/tunnel.ts +2 -5
  205. package/src/core/agents/canonical-emit.ts +7 -6
  206. package/src/core/agents/cli.ts +143 -89
  207. package/src/core/agents/coord-client.ts +21 -5
  208. package/src/core/agents/finalization.ts +595 -0
  209. package/src/core/agents/git-hook.ts +126 -0
  210. package/src/core/agents/index.ts +1 -0
  211. package/src/core/agents/render/prompt-context.ts +96 -16
  212. package/src/core/agents/render/session-context.ts +29 -4
  213. package/src/core/agents/rules/claim-conflict.ts +3 -3
  214. package/src/core/agents/rules/commit-conflict.ts +35 -8
  215. package/src/core/agents/rules/stop-hook.ts +71 -23
  216. package/src/core/agents/session-events.ts +14 -41
  217. package/src/core/agents/state/heartbeat-projector.ts +108 -1
  218. package/src/core/agents/state/heartbeat-writer.ts +129 -16
  219. package/src/core/agents/state/names.ts +54 -2
  220. package/src/core/agents/state/session-state.ts +131 -0
  221. package/src/core/config.ts +92 -12
  222. package/src/core/governor/planning.ts +17 -12
  223. package/src/core/hooks/adapter/detect.ts +4 -13
  224. package/src/core/hooks/adapter/events.ts +1 -0
  225. package/src/core/hooks/adapter/output.ts +9 -4
  226. package/src/core/hooks/adapter/parse.ts +3 -0
  227. package/src/core/hooks/adapter/wiring.ts +43 -0
  228. package/src/core/hooks/cli.ts +296 -27
  229. package/src/core/hooks/codex-authorization.ts +253 -0
  230. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  231. package/src/core/hooks/effects/index.ts +5 -5
  232. package/src/core/hooks/events/schema.ts +71 -0
  233. package/src/core/hooks/resolve/owner.ts +22 -1
  234. package/src/core/hooks/resolve/transcript.ts +164 -1
  235. package/src/core/hooks/session-name-presence.ts +52 -0
  236. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  237. package/src/core/presence/blob.ts +18 -0
  238. package/src/core/presence/index.ts +18 -1
  239. package/src/core/work/state.ts +8 -12
  240. package/src/core/workflow/engine.ts +2 -2
  241. package/src/core/workflow/index.ts +0 -1
  242. package/src/core/workflow/proof.ts +4 -4
  243. package/src/core/workflow/run-state.ts +5 -5
  244. package/src/core/workflow/types.ts +3 -3
  245. package/src/core/workflow/workspaces/execution.ts +11 -4
  246. package/src/core/workflow/workspaces/index.ts +0 -3
  247. package/src/core/workflow/workspaces/local-git.ts +1 -2
  248. package/src/core/workflow/workspaces/paths.ts +2 -2
  249. package/src/core/workflow/workspaces/types.ts +0 -9
  250. package/src/core/workflow/workspaces/validate.ts +1 -5
  251. package/src/lib/browser/client.ts +528 -6
  252. package/src/lib/browser/geometry.ts +197 -37
  253. package/src/lib/browser/index.ts +39 -0
  254. package/src/lib/browser/netscape-cookies.ts +39 -0
  255. package/src/lib/browser/proxy.ts +105 -0
  256. package/src/lib/browser/runts.ts +27 -3
  257. package/src/lib/browser/session-control.ts +892 -0
  258. package/src/lib/claude-desktop.ts +232 -1
  259. package/src/lib/docs-links.ts +674 -0
  260. package/src/lib/exec.ts +1 -1
  261. package/src/lib/identities/assume.ts +22 -1
  262. package/src/lib/instructions/bundle.ts +199 -0
  263. package/src/lib/instructions/git-hooks.ts +259 -0
  264. package/src/lib/instructions/splice.ts +41 -11
@@ -24,6 +24,12 @@ import {
24
24
  } from "node:fs";
25
25
  import { dirname, join } from "node:path";
26
26
  import { emit } from "../events/emit.ts";
27
+ import {
28
+ type AgentActivity,
29
+ applySessionStateEvent,
30
+ type SessionStateEvidenceEvent,
31
+ type TaskState,
32
+ } from "./session-state.ts";
27
33
 
28
34
  /** Inline adapter normalizer (mirrors canonical-emit.normalizeAdapter), kept
29
35
  * local so the writer's only cross-module dep is the in-process emit(). */
@@ -33,14 +39,13 @@ function adapterOf(platform: string | undefined): "claude-code" | "cursor" | "co
33
39
  return "claude-code";
34
40
  }
35
41
 
36
- /** Inverse of adapterOf: maps the canonical adapter ("claude-code"/"cursor"/
37
- * "codex") to the legacy underscore platform label stored on the heartbeat.
38
- * Mirrors heartbeat-projector.adapterToPlatform so a healed heartbeat carries
39
- * the same platform value projection would have written. */
42
+ /** Preserve the canonical adapter id in heartbeat state. Mirrors
43
+ * heartbeat-projector.adapterToPlatform so a healed heartbeat carries the same
44
+ * platform value projection would have written. */
40
45
  function adapterToPlatform(adapter: string | undefined): string {
41
46
  if (adapter === "cursor") return "cursor";
42
47
  if (adapter === "codex") return "codex";
43
- return "claude_code";
48
+ return "claude-code";
44
49
  }
45
50
 
46
51
  /**
@@ -87,6 +92,23 @@ export interface Heartbeat {
87
92
  files_touched: string[];
88
93
  task?: string;
89
94
  task_updated_at?: string | null;
95
+ activity?: AgentActivity;
96
+ activity_updated_at?: string;
97
+ activity_source?: string;
98
+ task_state?: TaskState;
99
+ task_state_updated_at?: string;
100
+ task_state_reason?: string;
101
+ /** Session name built on the first non-empty set-task (never rebuilt). Its
102
+ * presence is the "this session has been named" signal the prompt-context
103
+ * nudge and the Stop-hook naming rule key on. */
104
+ suggested_session_name?: string;
105
+ /** Stamped by turn.stop once the suggested name is seen in assistant reply
106
+ * text, ending the per-turn transcript scan. */
107
+ session_name_seen_at?: string;
108
+ /** WHICH name that sighting was for. The scan is skipped only while this
109
+ * matches the current suggested name, so a re-minted name is detectable
110
+ * again rather than being suppressed by the earlier sighting. */
111
+ session_name_seen_for?: string;
90
112
  last_status_at?: string;
91
113
  turn_summary?: string | null;
92
114
  turn_summary_updated_at?: string | null;
@@ -95,6 +117,7 @@ export interface Heartbeat {
95
117
  last_tool_at?: string;
96
118
  current_turn_id?: string;
97
119
  parent_instance_id?: string;
120
+ workflow_run_id?: string;
98
121
  [extra: string]: unknown;
99
122
  }
100
123
 
@@ -123,6 +146,32 @@ export function readHeartbeat(coordRoot: string, instanceId: string): Heartbeat
123
146
  }
124
147
  }
125
148
 
149
+ /** Apply one evidence event to the live state fields without draining the
150
+ * global event stream. This keeps permission prompts current while avoiding
151
+ * unrelated projector side effects before a tool authorization verdict. */
152
+ export function stampSessionStateEvent(
153
+ coordRoot: string,
154
+ instanceId: string,
155
+ event: Omit<SessionStateEvidenceEvent, "data"> & { data: unknown },
156
+ ): Heartbeat | null {
157
+ return mutate(coordRoot, instanceId, (heartbeat) => {
158
+ const data =
159
+ typeof event.data === "object" && event.data !== null && !Array.isArray(event.data)
160
+ ? (event.data as Record<string, unknown>)
161
+ : {};
162
+ const state = applySessionStateEvent(heartbeat, { ...event, data });
163
+ return {
164
+ ...heartbeat,
165
+ activity: state.activity,
166
+ activity_updated_at: state.activity_updated_at,
167
+ activity_source: state.activity_source,
168
+ task_state: state.task_state,
169
+ task_state_updated_at: state.task_state_updated_at,
170
+ task_state_reason: state.task_state_reason,
171
+ };
172
+ });
173
+ }
174
+
126
175
  function mutate(
127
176
  coordRoot: string,
128
177
  instanceId: string,
@@ -139,18 +188,66 @@ function mutate(
139
188
  export function setTask(coordRoot: string, instanceId: string, task: string): Heartbeat | null {
140
189
  return mutate(coordRoot, instanceId, (hb) => {
141
190
  const cleared = !task || task.length === 0;
191
+ // Name the session on its first NON-EMPTY declaration. Keyed on the
192
+ // suggested_session_name stamp, not task_updated_at, so a bare clear as
193
+ // the first call never burns the naming window. Subagents and workflow
194
+ // children have no human-owned tab to rename, so they are never named.
195
+ const humanFacing = hb.kind !== "subagent" && hb.kind !== "transient" && !hb.workflow_run_id;
196
+ const built =
197
+ !cleared && !hb.suggested_session_name && humanFacing
198
+ ? buildSuggestedName(hb.name ?? "unknown", [task])
199
+ : null;
142
200
  return {
143
201
  ...hb,
144
202
  task: cleared ? undefined : task,
145
203
  task_updated_at: nowIsoSeconds(),
204
+ ...(built ? { suggested_session_name: built.suggestedName } : {}),
146
205
  };
147
206
  });
148
207
  }
149
208
 
150
- export function stampStatusCheck(coordRoot: string, instanceId: string): Heartbeat | null {
209
+ /**
210
+ * Build the copy-pasteable session name from the coord identity + the agent's
211
+ * description parts. Pure (no coord-state reads) so it's unit-testable; collapses
212
+ * internal whitespace and trims. Returns null when the description is empty.
213
+ */
214
+ export function buildSuggestedName(
215
+ agentName: string,
216
+ descriptionParts: string[],
217
+ ): { suggestedName: string; description: string } | null {
218
+ const description = descriptionParts.join(" ").replace(/\s+/g, " ").trim();
219
+ if (!description) return null;
220
+ const name = agentName?.trim() || "unknown";
221
+ return { suggestedName: `Agent ${name} - ${description}`, description };
222
+ }
223
+
224
+ /** Build the operator-facing title projected from an explicit task lifecycle. */
225
+ export function buildLifecycleSuggestedName(
226
+ agentName: string,
227
+ task: string | undefined,
228
+ state: TaskState,
229
+ ): string | null {
230
+ const built = buildSuggestedName(agentName, task ? [task] : []);
231
+ if (!built) return null;
232
+ if (state === "blocked") return `[BLOCKED] - ${built.suggestedName}`;
233
+ if (state === "done") return `[DONE] - ${built.suggestedName}`;
234
+ return built.suggestedName;
235
+ }
236
+
237
+ /** Stamp the sighting once the suggested name has been observed in assistant
238
+ * reply text; later turns skip the transcript scan. Records WHICH name was seen,
239
+ * because the skip is only valid for that one: if a later set-task mints a
240
+ * different suggested name, a bare "already seen" stamp would suppress the scan
241
+ * forever and leave the Stop-hook naming rule permanently unsatisfiable. */
242
+ export function stampSessionNameSeen(
243
+ coordRoot: string,
244
+ instanceId: string,
245
+ name?: string,
246
+ ): Heartbeat | null {
151
247
  return mutate(coordRoot, instanceId, (hb) => ({
152
248
  ...hb,
153
- last_status_at: nowIsoSeconds(),
249
+ session_name_seen_at: hb.session_name_seen_at ?? nowIsoSeconds(),
250
+ ...(name ? { session_name_seen_for: name } : {}),
154
251
  }));
155
252
  }
156
253
 
@@ -200,9 +297,8 @@ export interface GroupUnclaimHit {
200
297
  * durable `claim.release` events — a file-only prune is silently reverted by
201
298
  * the next projector replay.
202
299
  *
203
- * files_touched can hold either absolute-under-coordRoot or canonical
204
- * repo-relative entries (legacy projections stored the raw tool_input path),
205
- * so both sides are normalized before comparing — an exact-string match
300
+ * Tool payloads and release calls can supply either absolute-under-coordRoot or
301
+ * canonical repo-relative entries, so both sides are normalized before comparing. An exact-string match
206
302
  * silently no-ops on the mixed-form case and the claim never releases.
207
303
  *
208
304
  * This is the Option B fix for post-commit's pid-map attribution hole: a
@@ -264,7 +360,7 @@ export function healPidmap(coordRoot: string, instanceId: string, pid: number):
264
360
  const dir = join(coordRoot, ".harnery", "pid-map");
265
361
  mkdirSync(dir, { recursive: true });
266
362
  const hb = readHeartbeat(coordRoot, instanceId);
267
- const platform = hb?.platform ?? "claude_code";
363
+ const platform = hb?.platform ?? "claude-code";
268
364
  const pmPath = join(dir, String(pid));
269
365
  // Drift guard: only write + emit telemetry when the entry is missing or
270
366
  // points at a different owner. Without this, a per-tool-call heal would
@@ -291,6 +387,7 @@ export function healHeartbeat(
291
387
  sessionId?: string,
292
388
  model?: string,
293
389
  adapter?: string,
390
+ opts?: { forkedFrom?: string },
294
391
  ): Heartbeat | null {
295
392
  const path = heartbeatPath(coordRoot, instanceId);
296
393
  if (existsSync(path)) {
@@ -306,14 +403,28 @@ export function healHeartbeat(
306
403
  let agentId = "";
307
404
  try {
308
405
  // eslint-disable-next-line @typescript-eslint/no-require-imports
309
- const { resolveName } = require("./names.ts") as typeof import("./names.ts");
310
- const resolved = resolveName(coordRoot, instanceId, sessionId);
406
+ const names = require("./names.ts") as typeof import("./names.ts");
407
+ const resolved = names.resolveName(coordRoot, instanceId, sessionId);
311
408
  if (resolved) {
312
409
  name = resolved.name;
313
410
  kind = resolved.kind;
314
411
  // An explicitly assumed session carries its durable persona UUID in
315
412
  // name-history. Native subagents continue to use instance_id.
316
413
  agentId = resolved.agent_id ?? (resolved.kind === "subagent" ? instanceId : "");
414
+ } else if (!sessionId || sessionId === instanceId) {
415
+ // A main session with NO history at all never fired session.start —
416
+ // the CC fork flow (SessionStart fires under the parent's id before
417
+ // the fork id is minted), or a partially-wired adapter. Mint its pool
418
+ // name here instead of leaving a nameless heartbeat, and stamp the
419
+ // detected fork lineage while we're at it. A sessionId that differs
420
+ // from instanceId is left alone: that shape is a subagent whose parent
421
+ // is also unknown, and guessing "session" would be wrong.
422
+ name = names.assignName(
423
+ coordRoot,
424
+ instanceId,
425
+ "session",
426
+ opts?.forkedFrom ? { forkedFrom: opts.forkedFrom } : undefined,
427
+ );
317
428
  }
318
429
  } catch {
319
430
  /* names module unavailable, fall back to empty */
@@ -330,19 +441,21 @@ export function healHeartbeat(
330
441
  started_at: now,
331
442
  last_heartbeat: now,
332
443
  files_touched: [],
333
- // Default to claude_code only when the caller can't tell us the adapter
444
+ // Default to claude-code only when the caller can't tell us the adapter
334
445
  // (e.g. manual `harn agents heal`). The live tool.pre_use heal threads the
335
446
  // detected adapter so a pruned Cursor/Codex heartbeat is recreated with
336
- // the correct platform instead of being mislabeled claude_code.
447
+ // the correct platform instead of being mislabeled claude-code.
337
448
  platform: adapterToPlatform(adapter),
338
449
  };
339
450
  atomicWrite(path, JSON.stringify(hb, null, 2));
340
451
  // Write-only telemetry: only the actual-recreate branch reaches here (the
341
452
  // already-alive case returned above), so this records exactly the heals that
342
- // happened.
453
+ // happened. Recorded fork lineage rides the event too, so derived readers
454
+ // rebuilding from the ledger converge with .name-history.
343
455
  emitHealthHeal(coordRoot, "health.heartbeat_heal", instanceId, hb, {
344
456
  reason: "missing",
345
457
  kind: "heartbeat",
458
+ ...(opts?.forkedFrom ? { forked_from: opts.forkedFrom } : {}),
346
459
  });
347
460
  return hb;
348
461
  }
@@ -312,6 +312,10 @@ export interface NameHistoryRow {
312
312
  /** Audit marker distinguishing an explicit role adoption from pool assignment. */
313
313
  source?: "pool" | "identity.assume";
314
314
  previous_name?: string;
315
+ /** Instance this session was forked/branched from (recorded fork lineage).
316
+ * Stamped only on the row that first assigns this instance, when the adapter
317
+ * layer detected or supplied a parent conversation. */
318
+ forked_from?: string;
315
319
  }
316
320
 
317
321
  function atomicWrite(path: string, content: string): void {
@@ -433,8 +437,15 @@ export function recordNameAssumption(
433
437
  * Assign a name to <instanceId> with the given <kind>. Counter-consuming when
434
438
  * the owner is new. Idempotent: returns existing name on resume.
435
439
  */
436
- export function assignName(coordRoot: string, instanceId: string, kind: NameKind): string {
437
- // Check 1: existing history row → original name.
440
+ export function assignName(
441
+ coordRoot: string,
442
+ instanceId: string,
443
+ kind: NameKind,
444
+ opts?: { forkedFrom?: string },
445
+ ): string {
446
+ // Check 1: existing history row → original name. A resume re-enters here,
447
+ // which also makes fork stamping naturally idempotent: lineage lands only on
448
+ // the row that first assigns the instance.
438
449
  const existing = resolveName(coordRoot, instanceId);
439
450
  if (existing) return existing.name;
440
451
 
@@ -447,12 +458,53 @@ export function assignName(coordRoot: string, instanceId: string, kind: NameKind
447
458
  }
448
459
  const name = COORD_NAMES[counter % 260]!;
449
460
  atomicWrite(cPath, String(counter + 1));
461
+ const forkedFrom = opts?.forkedFrom;
450
462
  appendHistory(coordRoot, {
451
463
  instance_id: instanceId,
452
464
  name,
453
465
  kind,
454
466
  source: "pool",
467
+ ...(forkedFrom && forkedFrom !== instanceId ? { forked_from: forkedFrom } : {}),
455
468
  ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
456
469
  });
457
470
  return name;
458
471
  }
472
+
473
+ /** One step of recorded fork lineage: the latest row for <instanceId> that
474
+ * carries `forked_from` (latest-row-wins, matching resolveName). */
475
+ export function readForkParent(
476
+ coordRoot: string,
477
+ instanceId: string,
478
+ ): { instance_id: string; name: string | null } | null {
479
+ const history = readHistory(coordRoot);
480
+ for (let i = history.length - 1; i >= 0; i--) {
481
+ const row = history[i]!;
482
+ if (row.instance_id !== instanceId) continue;
483
+ if (!row.forked_from) return null;
484
+ const parent = resolveName(coordRoot, row.forked_from);
485
+ return { instance_id: row.forked_from, name: parent?.name ?? null };
486
+ }
487
+ return null;
488
+ }
489
+
490
+ /**
491
+ * Full recorded fork ancestry for <instanceId>, nearest ancestor first, each
492
+ * with its latest resolved name. Depth-capped and cycle-guarded: lineage is
493
+ * append-only operational data, not something to trust unboundedly.
494
+ */
495
+ export function resolveForkAncestry(
496
+ coordRoot: string,
497
+ instanceId: string,
498
+ ): Array<{ instance_id: string; name: string | null }> {
499
+ const out: Array<{ instance_id: string; name: string | null }> = [];
500
+ const seen = new Set<string>([instanceId]);
501
+ let cursor = instanceId;
502
+ for (let depth = 0; depth < 20; depth++) {
503
+ const parent = readForkParent(coordRoot, cursor);
504
+ if (!parent || seen.has(parent.instance_id)) break;
505
+ out.push(parent);
506
+ seen.add(parent.instance_id);
507
+ cursor = parent.instance_id;
508
+ }
509
+ return out;
510
+ }
@@ -0,0 +1,131 @@
1
+ /** What the agent is doing now. `unknown` is evidence-safe, not an error. */
2
+ export type AgentActivity = "unknown" | "working" | "needs_input" | "idle";
3
+
4
+ /** Whether the declared task remains open. Activity never changes this axis. */
5
+ export type TaskState = "active" | "blocked" | "done";
6
+
7
+ /** Raw fields stored in a live heartbeat and rebuilt from canonical events. */
8
+ export interface SessionStateFields {
9
+ activity?: AgentActivity;
10
+ activity_updated_at?: string;
11
+ activity_source?: string;
12
+ task_state?: TaskState;
13
+ task_state_updated_at?: string;
14
+ task_state_reason?: string;
15
+ }
16
+
17
+ /** Reader-facing state with compatibility defaults applied. */
18
+ export interface SessionState extends SessionStateFields {
19
+ activity: AgentActivity;
20
+ task_state: TaskState;
21
+ }
22
+
23
+ export interface SessionStateSelector {
24
+ instance_id?: string;
25
+ session_id?: string;
26
+ }
27
+
28
+ export interface SessionStateEvidenceEvent {
29
+ event_type: string;
30
+ ts: string;
31
+ data?: Record<string, unknown>;
32
+ instance_id?: string;
33
+ session_id?: string;
34
+ }
35
+
36
+ const TERMINAL_ACTIVITY_EVENTS = new Set(["session.end", "subagent.stop", "turn.stop"]);
37
+
38
+ /**
39
+ * Apply one canonical event to the two independent state axes.
40
+ *
41
+ * The table is deliberately evidence-only. Output chunks, post-tool events,
42
+ * narration, coordination commands, and elapsed time cannot clear an observed
43
+ * input wait. A command start only counts as progress when the session was
44
+ * already inside an open turn (`working` or `needs_input`).
45
+ */
46
+ export function applySessionStateEvent(
47
+ current: Readonly<SessionStateFields>,
48
+ event: SessionStateEvidenceEvent,
49
+ ): SessionStateFields {
50
+ const next: SessionStateFields = { ...current };
51
+
52
+ if (event.event_type === "session.start" || event.event_type === "subagent.start") {
53
+ setActivity(next, "idle", event);
54
+ } else if (event.event_type === "user_prompt.submit" || event.event_type === "tool.pre_use") {
55
+ setActivity(next, "working", event);
56
+ } else if (event.event_type === "interaction.input_requested") {
57
+ setActivity(next, "needs_input", event);
58
+ } else if (
59
+ event.event_type === "command.start" &&
60
+ (current.activity === "working" || current.activity === "needs_input")
61
+ ) {
62
+ setActivity(next, "working", event);
63
+ } else if (TERMINAL_ACTIVITY_EVENTS.has(event.event_type)) {
64
+ setActivity(next, "idle", event);
65
+ }
66
+
67
+ if (event.event_type === "state.task_state") {
68
+ const data = event.data ?? {};
69
+ const state = field(data, "state");
70
+ if (state === "active" || state === "blocked" || state === "done") {
71
+ next.task_state = state;
72
+ next.task_state_updated_at = event.ts;
73
+ const reason = field(data, "reason");
74
+ if (typeof reason === "string" && reason.length > 0) {
75
+ next.task_state_reason = reason;
76
+ } else {
77
+ delete next.task_state_reason;
78
+ }
79
+ }
80
+ }
81
+
82
+ return next;
83
+ }
84
+
85
+ /**
86
+ * Rebuild both axes from canonical events after a heartbeat has disappeared.
87
+ * Events may be the whole ledger or an already-filtered session slice.
88
+ */
89
+ export function foldSessionState(
90
+ events: readonly SessionStateEvidenceEvent[],
91
+ selector: SessionStateSelector = {},
92
+ ): SessionState {
93
+ const ordered = events
94
+ .map((event, index) => ({ event, index }))
95
+ .filter(({ event }) => matches(event, selector))
96
+ .sort((a, b) => timestamp(a.event) - timestamp(b.event) || a.index - b.index);
97
+
98
+ let fields: SessionStateFields = {};
99
+ for (const { event } of ordered) fields = applySessionStateEvent(fields, event);
100
+
101
+ return {
102
+ ...fields,
103
+ activity: fields.activity ?? "unknown",
104
+ task_state: fields.task_state ?? "active",
105
+ };
106
+ }
107
+
108
+ function setActivity(
109
+ target: SessionStateFields,
110
+ activity: AgentActivity,
111
+ event: SessionStateEvidenceEvent,
112
+ ): void {
113
+ target.activity = activity;
114
+ target.activity_updated_at = event.ts;
115
+ target.activity_source = event.event_type;
116
+ }
117
+
118
+ function matches(event: SessionStateEvidenceEvent, selector: SessionStateSelector): boolean {
119
+ if (selector.instance_id && event.instance_id !== selector.instance_id) return false;
120
+ if (selector.session_id && event.session_id !== selector.session_id) return false;
121
+ return true;
122
+ }
123
+
124
+ function timestamp(event: SessionStateEvidenceEvent): number {
125
+ const parsed = Date.parse(event.ts);
126
+ return Number.isFinite(parsed) ? parsed : 0;
127
+ }
128
+
129
+ function field(data: Record<string, unknown>, key: string): unknown {
130
+ return data[key];
131
+ }
@@ -7,7 +7,7 @@
7
7
  * 2. `<project-root>/.harnery/config.jsonc` — project override (authoritative)
8
8
  *
9
9
  * Fields owned here: `binName` (host CLI name for agent-facing strings),
10
- * `hooksSetupHint`, `tools`, `workflow`, `skills`, `presence`, plus the tunable
10
+ * `hooksSetupHint`, `agents`, `tools`, `workflow`, `skills`, `presence`, plus the tunable
11
11
  * `coord` (heartbeat freshness), `artifacts` (working-file retention),
12
12
  * `backup` (restic repo/password/prune policy), and `sync` (rclone
13
13
  * remote/prefix) sections. The `files` deny/override section
@@ -30,6 +30,13 @@ export const DEFAULT_BIN_NAME = "harn";
30
30
  /** Heartbeat-freshness default (seconds): the sweep window when nothing overrides it. */
31
31
  export const DEFAULT_FRESHNESS_SECS = 600;
32
32
 
33
+ export type AgentFinalizationDisposition = "git" | "output";
34
+
35
+ export interface AgentFinalizationRoot {
36
+ path: string;
37
+ disposition: AgentFinalizationDisposition;
38
+ }
39
+
33
40
  interface HarneryConfig {
34
41
  /** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "acme"). */
35
42
  binName?: string;
@@ -41,6 +48,15 @@ interface HarneryConfig {
41
48
  * declares it here (e.g. "scripts/setup-hooks.sh"). Unset → a generic hint.
42
49
  */
43
50
  hooksSetupHint?: string;
51
+ /**
52
+ * Agent-ritual policy owned by the host project. Git finalization is opt-in:
53
+ * standalone Harnery and embedding hosts keep the ordinary status ritual
54
+ * unless the project deliberately requires the guarded check.
55
+ */
56
+ agents?: {
57
+ requireGitFinalization?: boolean;
58
+ finalizationRoots?: AgentFinalizationRoot[];
59
+ };
44
60
  /**
45
61
  * Managed-tool provisioning consent. `{ ripgrep: { autoInstall: true } }`
46
62
  * lets `grep` download the pinned, checksum-verified ripgrep into the
@@ -157,6 +173,16 @@ function statMtime(p: string): number {
157
173
  }
158
174
  }
159
175
 
176
+ /** Cache signature, or null when the file can't be stat'd (missing). */
177
+ function statSignature(p: string): string | null {
178
+ try {
179
+ const stat = statSync(p);
180
+ return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
160
186
  /** Parse one JSONC config file to an object; missing/unparseable → `{}`. */
161
187
  function parseConfigFile(p: string): HarneryConfig {
162
188
  try {
@@ -186,8 +212,13 @@ function mergeConfig(base: HarneryConfig, override: HarneryConfig): HarneryConfi
186
212
  return out as HarneryConfig;
187
213
  }
188
214
 
189
- // mtime-keyed per-process cache (both layers): a stat is cheap, a parse on every render isn't.
190
- let cache: { root: string; projMtime: number; userMtime: number; cfg: HarneryConfig } | null = null;
215
+ // Stat-signature-keyed per-process cache (both layers): a stat is cheap, a parse on every render isn't.
216
+ let cache: {
217
+ root: string;
218
+ projSignature: string | null;
219
+ userSignature: string | null;
220
+ cfg: HarneryConfig;
221
+ } | null = null;
191
222
 
192
223
  /**
193
224
  * The effective config for `root`: user-global (`~/.config/harnery/config.jsonc`)
@@ -197,20 +228,20 @@ let cache: { root: string; projMtime: number; userMtime: number; cfg: HarneryCon
197
228
  function readConfig(root: string): HarneryConfig {
198
229
  const projPath = join(root, ".harnery", "config.jsonc");
199
230
  const userPath = userConfigPath();
200
- const projMtime = statMtime(projPath);
201
- const userMtime = statMtime(userPath);
231
+ const projSignature = statSignature(projPath);
232
+ const userSignature = statSignature(userPath);
202
233
  if (
203
234
  cache &&
204
235
  cache.root === root &&
205
- cache.projMtime === projMtime &&
206
- cache.userMtime === userMtime
236
+ cache.projSignature === projSignature &&
237
+ cache.userSignature === userSignature
207
238
  ) {
208
239
  return cache.cfg;
209
240
  }
210
- const user = userMtime === -1 ? {} : parseConfigFile(userPath);
211
- const project = projMtime === -1 ? {} : parseConfigFile(projPath);
241
+ const user = userSignature === null ? {} : parseConfigFile(userPath);
242
+ const project = projSignature === null ? {} : parseConfigFile(projPath);
212
243
  const cfg = mergeConfig(user, project);
213
- cache = { root, projMtime, userMtime, cfg };
244
+ cache = { root, projSignature, userSignature, cfg };
214
245
  return cfg;
215
246
  }
216
247
 
@@ -270,6 +301,55 @@ export function resolveHooksSetupHint(coordRoot?: string | null): string | null
270
301
  return typeof hint === "string" && hint.trim() ? hint.trim() : null;
271
302
  }
272
303
 
304
+ /**
305
+ * Whether the host requires the guarded Git check at the end of tool-using
306
+ * turns. Default false: Harnery exposes `agents status --end-turn` as a capability
307
+ * but does not impose a commit-and-push policy on embedding projects.
308
+ *
309
+ * `.harnery/config.jsonc`:
310
+ * `{ "agents": { "requireGitFinalization": true } }`
311
+ *
312
+ * `HARNERY_AGENTS_REQUIRE_GIT_FINALIZATION=1|0` overrides per process.
313
+ */
314
+ export function agentsRequireGitFinalization(coordRoot?: string | null): boolean {
315
+ const env = coordEnv("AGENTS_REQUIRE_GIT_FINALIZATION");
316
+ if (env === "1") return true;
317
+ if (env === "0") return false;
318
+ const root = coordRoot ?? findCoordRoot();
319
+ if (!root) return false;
320
+ return readConfig(root).agents?.requireGitFinalization === true;
321
+ }
322
+
323
+ /**
324
+ * Extra roots whose guarded writes have an explicit end-turn disposition.
325
+ *
326
+ * This trust boundary comes only from the project config. A user-global config
327
+ * may tune ordinary behavior, but it cannot grant one project filesystem
328
+ * authority outside its coordination root. Paths may be absolute or relative
329
+ * to the coordination root. Invalid entries are ignored here and fail closed
330
+ * when the finalization policy validates them.
331
+ */
332
+ export function agentsFinalizationRoots(coordRoot?: string | null): AgentFinalizationRoot[] {
333
+ const root = coordRoot ?? findCoordRoot();
334
+ if (!root) return [];
335
+ const entries = readProjectConfig(root).agents?.finalizationRoots;
336
+ if (!Array.isArray(entries)) return [];
337
+ return entries.flatMap((entry) => {
338
+ if (!entry || typeof entry !== "object") return [];
339
+ const path = typeof entry.path === "string" ? entry.path.trim() : "";
340
+ const disposition = entry.disposition;
341
+ if (!path || (disposition !== "git" && disposition !== "output")) return [];
342
+ return [{ path, disposition }];
343
+ });
344
+ }
345
+
346
+ /** The status command automatic prompts and Stop remediation should request. */
347
+ export function endOfTurnStatusCommand(coordRoot?: string | null): string {
348
+ const root = coordRoot ?? findCoordRoot();
349
+ const suffix = agentsRequireGitFinalization(root) ? " --end-turn" : "";
350
+ return `${resolveBinName(root)} agents status${suffix}`;
351
+ }
352
+
273
353
  /**
274
354
  * Whether the host project consented to automatic ripgrep provisioning:
275
355
  * `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall": true } } }`.
@@ -349,13 +429,13 @@ function posIntOr(v: unknown, fallback: number): number {
349
429
  * The heartbeat-freshness window (seconds): the age above which the sweeper
350
430
  * prunes an agent, and the cutoff the `agents` surface uses to fold stale peers.
351
431
  * Precedence:
352
- * 1. `HARNERY_AGENT_COORD_FRESHNESS` env (canonical), or `HARNERY_AGENT_FRESHNESS` (legacy alias)
432
+ * 1. `HARNERY_AGENT_COORD_FRESHNESS` env
353
433
  * 2. `.harnery/config.jsonc` `coord.freshness_seconds`
354
434
  * 3. `600` (10 minutes)
355
435
  * `coordRoot` is resolved via `findCoordRoot()` when not passed.
356
436
  */
357
437
  export function coordFreshnessSeconds(coordRoot?: string | null): number {
358
- const env = coordEnv("AGENT_COORD_FRESHNESS") ?? coordEnv("AGENT_FRESHNESS");
438
+ const env = coordEnv("AGENT_COORD_FRESHNESS");
359
439
  if (env !== undefined) {
360
440
  const n = Number.parseInt(env, 10);
361
441
  if (Number.isFinite(n) && n > 0) return n;