harnery 0.46.0 → 0.47.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 (187) hide show
  1. package/README.md +24 -0
  2. package/bin/README.md +10 -0
  3. package/bin/agent-coord.mjs +3 -0
  4. package/bin/agent-hook.mjs +3 -0
  5. package/bin/harn.mjs +3 -0
  6. package/bin/run.mjs +43 -0
  7. package/dist/commander.d.ts +18 -6
  8. package/dist/commander.d.ts.map +1 -1
  9. package/dist/commander.js +74 -2
  10. package/dist/commands/agents.d.ts +25 -0
  11. package/dist/commands/agents.d.ts.map +1 -1
  12. package/dist/commands/agents.js +70 -27
  13. package/dist/commands/autoupdate.d.ts +12 -0
  14. package/dist/commands/autoupdate.d.ts.map +1 -0
  15. package/dist/commands/autoupdate.js +190 -0
  16. package/dist/commands/computer.d.ts +45 -0
  17. package/dist/commands/computer.d.ts.map +1 -0
  18. package/dist/commands/computer.js +509 -0
  19. package/dist/commands/docs.js +3 -1
  20. package/dist/commands/fetch.js +11 -1
  21. package/dist/commands/grep.d.ts +72 -0
  22. package/dist/commands/grep.d.ts.map +1 -1
  23. package/dist/commands/grep.js +200 -47
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +15 -9
  26. package/dist/commands/rm.js +2 -2
  27. package/dist/commands/web.d.ts.map +1 -1
  28. package/dist/commands/web.js +2 -0
  29. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  30. package/dist/core/agents/canonical-emit.js +2 -3
  31. package/dist/core/agents/coord-bin.d.ts +3 -0
  32. package/dist/core/agents/coord-bin.d.ts.map +1 -1
  33. package/dist/core/agents/coord-bin.js +6 -0
  34. package/dist/core/agents/coord-client.d.ts.map +1 -1
  35. package/dist/core/agents/coord-client.js +6 -2
  36. package/dist/core/agents/render/prompt-context.d.ts +55 -7
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +122 -48
  39. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  40. package/dist/core/agents/rules/claim-conflict.js +36 -16
  41. package/dist/core/agents/rules/stop-hook.d.ts +6 -0
  42. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  43. package/dist/core/agents/rules/stop-hook.js +20 -1
  44. package/dist/core/agents/state/proc-start.d.ts +3 -1
  45. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  46. package/dist/core/agents/state/proc-start.js +26 -4
  47. package/dist/core/autoupdate/engine.d.ts +97 -0
  48. package/dist/core/autoupdate/engine.d.ts.map +1 -0
  49. package/dist/core/autoupdate/engine.js +548 -0
  50. package/dist/core/autoupdate/fingerprint.d.ts +16 -0
  51. package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
  52. package/dist/core/autoupdate/fingerprint.js +73 -0
  53. package/dist/core/autoupdate/git.d.ts +30 -0
  54. package/dist/core/autoupdate/git.d.ts.map +1 -0
  55. package/dist/core/autoupdate/git.js +82 -0
  56. package/dist/core/autoupdate/index.d.ts +6 -0
  57. package/dist/core/autoupdate/index.d.ts.map +1 -0
  58. package/dist/core/autoupdate/index.js +4 -0
  59. package/dist/core/autoupdate/state.d.ts +59 -0
  60. package/dist/core/autoupdate/state.d.ts.map +1 -0
  61. package/dist/core/autoupdate/state.js +214 -0
  62. package/dist/core/autoupdate/types.d.ts +113 -0
  63. package/dist/core/autoupdate/types.d.ts.map +1 -0
  64. package/dist/core/autoupdate/types.js +35 -0
  65. package/dist/core/config.d.ts +53 -1
  66. package/dist/core/config.d.ts.map +1 -1
  67. package/dist/core/config.js +114 -1
  68. package/dist/core/events/v3/bootstrap.js +1 -0
  69. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  70. package/dist/core/events/v3/runtime-build.js +1 -0
  71. package/dist/core/hooks/adapter/wiring.d.ts +12 -2
  72. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/wiring.js +38 -8
  74. package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
  75. package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
  76. package/dist/core/hooks/autoupdate-cue.js +53 -0
  77. package/dist/core/hooks/cli.js +52 -4
  78. package/dist/core/hooks/effects/index.js +4 -4
  79. package/dist/core/hooks/resolve/coord-root.js +1 -0
  80. package/dist/core/hooks/resolve/transcript.d.ts +12 -0
  81. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  82. package/dist/core/hooks/resolve/transcript.js +54 -0
  83. package/dist/core/semantic/contract.d.ts +3 -3
  84. package/dist/core/semantic/service.d.ts.map +1 -1
  85. package/dist/core/semantic/service.js +4 -2
  86. package/dist/core/storage/modes.d.ts +1 -1
  87. package/dist/core/storage/modes.d.ts.map +1 -1
  88. package/dist/core/storage/modes.js +5 -1
  89. package/dist/core/workflow/durable-record.d.ts +2 -0
  90. package/dist/core/workflow/durable-record.d.ts.map +1 -1
  91. package/dist/core/workflow/durable-record.js +13 -1
  92. package/dist/core/workflow/engine.js +4 -0
  93. package/dist/core/workflow/spawn-claude.d.ts +7 -0
  94. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  95. package/dist/core/workflow/spawn-claude.js +16 -4
  96. package/dist/core/workflow/specialists.d.ts.map +1 -1
  97. package/dist/core/workflow/specialists.js +1 -0
  98. package/dist/core/workflow/types.d.ts +10 -0
  99. package/dist/core/workflow/types.d.ts.map +1 -1
  100. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  101. package/dist/core/workflow/workspaces/leases.js +1 -9
  102. package/dist/lib/computer/cua.d.ts +50 -0
  103. package/dist/lib/computer/cua.d.ts.map +1 -0
  104. package/dist/lib/computer/cua.js +474 -0
  105. package/dist/lib/computer/host.d.ts +19 -0
  106. package/dist/lib/computer/host.d.ts.map +1 -0
  107. package/dist/lib/computer/host.js +72 -0
  108. package/dist/lib/computer/lease.d.ts +13 -0
  109. package/dist/lib/computer/lease.d.ts.map +1 -0
  110. package/dist/lib/computer/lease.js +269 -0
  111. package/dist/lib/computer/types.d.ts +163 -0
  112. package/dist/lib/computer/types.d.ts.map +1 -0
  113. package/dist/lib/computer/types.js +10 -0
  114. package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
  115. package/dist/lib/docs-metadata-v2.js +6 -5
  116. package/dist/lib/exec.d.ts.map +1 -1
  117. package/dist/lib/exec.js +1 -0
  118. package/dist/lib/guarded-remove.d.ts.map +1 -1
  119. package/dist/lib/guarded-remove.js +5 -4
  120. package/dist/lib/headless/index.d.ts +33 -0
  121. package/dist/lib/headless/index.d.ts.map +1 -1
  122. package/dist/lib/headless/index.js +38 -2
  123. package/dist/lib/http/client.d.ts +2 -9
  124. package/dist/lib/http/client.d.ts.map +1 -1
  125. package/dist/lib/http/client.js +30 -9
  126. package/dist/lib/instructions/apply.d.ts +9 -0
  127. package/dist/lib/instructions/apply.d.ts.map +1 -1
  128. package/dist/lib/instructions/apply.js +92 -14
  129. package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
  130. package/dist/lib/instructions/git-hooks.js +8 -4
  131. package/dist/lib/instructions/templates.d.ts +11 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +81 -1
  134. package/package.json +5 -5
  135. package/schemas/config.schema.json +89 -1
  136. package/src/commander.ts +98 -8
  137. package/src/commands/agents.ts +100 -32
  138. package/src/commands/autoupdate.ts +250 -0
  139. package/src/commands/computer.ts +682 -0
  140. package/src/commands/docs.ts +3 -1
  141. package/src/commands/fetch.ts +11 -1
  142. package/src/commands/grep.ts +274 -59
  143. package/src/commands/init.ts +17 -7
  144. package/src/commands/rm.ts +2 -2
  145. package/src/commands/web.ts +2 -0
  146. package/src/core/agents/canonical-emit.ts +2 -3
  147. package/src/core/agents/coord-bin.ts +30 -0
  148. package/src/core/agents/coord-client.ts +6 -2
  149. package/src/core/agents/render/prompt-context.ts +152 -53
  150. package/src/core/agents/rules/claim-conflict.ts +50 -18
  151. package/src/core/agents/rules/stop-hook.ts +28 -1
  152. package/src/core/agents/state/proc-start.ts +29 -5
  153. package/src/core/autoupdate/engine.ts +691 -0
  154. package/src/core/autoupdate/fingerprint.ts +89 -0
  155. package/src/core/autoupdate/git.ts +120 -0
  156. package/src/core/autoupdate/index.ts +29 -0
  157. package/src/core/autoupdate/state.ts +269 -0
  158. package/src/core/autoupdate/types.ts +135 -0
  159. package/src/core/config.ts +142 -1
  160. package/src/core/events/v3/bootstrap.ts +1 -0
  161. package/src/core/events/v3/runtime-build.ts +1 -0
  162. package/src/core/hooks/adapter/wiring.ts +51 -6
  163. package/src/core/hooks/autoupdate-cue.ts +72 -0
  164. package/src/core/hooks/cli.ts +54 -1
  165. package/src/core/hooks/effects/index.ts +4 -4
  166. package/src/core/hooks/resolve/coord-root.ts +1 -0
  167. package/src/core/hooks/resolve/transcript.ts +61 -0
  168. package/src/core/semantic/service.ts +6 -2
  169. package/src/core/storage/modes.ts +7 -1
  170. package/src/core/workflow/durable-record.ts +15 -1
  171. package/src/core/workflow/engine.ts +3 -0
  172. package/src/core/workflow/spawn-claude.ts +16 -4
  173. package/src/core/workflow/specialists.ts +1 -0
  174. package/src/core/workflow/types.ts +10 -0
  175. package/src/core/workflow/workspaces/leases.ts +1 -9
  176. package/src/lib/computer/cua.ts +572 -0
  177. package/src/lib/computer/host.ts +87 -0
  178. package/src/lib/computer/lease.ts +299 -0
  179. package/src/lib/computer/types.ts +177 -0
  180. package/src/lib/docs-metadata-v2.ts +24 -5
  181. package/src/lib/exec.ts +1 -0
  182. package/src/lib/guarded-remove.ts +5 -3
  183. package/src/lib/headless/index.ts +66 -2
  184. package/src/lib/http/client.ts +33 -19
  185. package/src/lib/instructions/apply.ts +100 -12
  186. package/src/lib/instructions/git-hooks.ts +8 -4
  187. package/src/lib/instructions/templates.ts +95 -1
@@ -7,9 +7,10 @@
7
7
  * and forwards the result as the adapter-shaped additionalContext payload.
8
8
  *
9
9
  * Four subsections combined into one additionalContext payload:
10
- * 1. Peer table: semantically-relevant peer fields hashed; only re-emits
11
- * when peers change (name + instance_id + session_id + kind + started_at
12
- * + sorted(files_touched) + platform, sorted by instance_id).
10
+ * 1. Peer table: re-emits only when a live peer arrives or leaves, changes
11
+ * lifecycle, or changes its claimed files. Activity and task text never
12
+ * trigger a reprint. Peers past the heartbeat-freshness window are left
13
+ * out and counted in "+N more".
13
14
  * 2. Council pending: pending open-council IDs hashed; re-emits when the
14
15
  * ID set changes.
15
16
  * 3. Focus nudge. Until the session has displayed its current suggested
@@ -24,14 +25,37 @@
24
25
  * 5. Codex status footer: a non-deduplicated reminder to put the live status
25
26
  * box after the substantive answer. Stop stays observe-only, so missing the
26
27
  * footer can never trigger a replacement response.
28
+ * 6. Turn ritual: for adapters whose Stop hook enforces the ritual, shown on
29
+ * the first prompt, after a compaction, and after a bounced turn.
27
30
  *
28
31
  * Hash files live at:
32
+ * .harnery/.last-turn-ritual.<instance_id>
29
33
  * .harnery/.last-peer-hash.<instance_id>
30
34
  * .harnery/.last-council-hash.<instance_id>
31
35
  * .harnery/.last-task-nudge-hash.<instance_id>
32
36
  *
33
37
  * First call always emits (hash files don't exist).
34
38
  */
39
+ import { type RemoteMachine } from "../../presence/index.js";
40
+ import type { AgentActivity, TaskState } from "../state/session-state.js";
41
+ interface HeartbeatRow {
42
+ instance_id?: string;
43
+ name?: string;
44
+ kind?: string;
45
+ session_id?: string;
46
+ started_at?: string;
47
+ last_heartbeat?: string;
48
+ files_touched?: string[];
49
+ platform?: string;
50
+ task?: string;
51
+ activity?: AgentActivity;
52
+ task_state?: TaskState;
53
+ task_state_reason?: string;
54
+ task_updated_at?: string | null;
55
+ suggested_session_name?: string;
56
+ session_name_seen_for?: string;
57
+ workflow_run_id?: string;
58
+ }
35
59
  export interface PromptContextOpts {
36
60
  coordRoot: string;
37
61
  instanceId: string;
@@ -50,10 +74,9 @@ export interface PromptContextOpts {
50
74
  * The caller enables this only for human-facing Codex sessions. */
51
75
  statusFooterNudge?: boolean;
52
76
  /** Adapter id whose Stop hook ENFORCES the end-of-turn ritual (task_set +
53
- * status check). When set, append a fresh per-prompt reminder of that
54
- * contract so the model satisfies it before ending the turn instead of
55
- * being bounced into a retry. Not hash-deduped, same rationale as the
56
- * status footer. */
77
+ * status check). When set, append a reminder of that contract on the
78
+ * session's first prompt, after a context compaction, and after a turn the
79
+ * Stop hook bounced (see `markTurnRitualBounced`). */
57
80
  turnRitualNudge?: string;
58
81
  /** The emitter acknowledges exactly these IDs only after output succeeds. */
59
82
  onInboxPrepared?: (messageIds: readonly string[]) => void;
@@ -74,4 +97,29 @@ export declare function renderPromptContext(opts: PromptContextOpts): string;
74
97
  * re-emit the same advice.
75
98
  */
76
99
  export declare function resourceWarningIfChanged(coordRoot: string, instanceId: string): string;
100
+ /**
101
+ * Called by the Stop hook when it blocks a turn for a missing ritual step, so
102
+ * the next prompt carries the reminder again.
103
+ */
104
+ export declare function markTurnRitualBounced(coordRoot: string, instanceId: string): void;
105
+ export interface PeerTableInput {
106
+ peers: HeartbeatRow[];
107
+ remote: RemoteMachine[];
108
+ mySessionId: string;
109
+ nowMs: number;
110
+ /** Sessions whose last heartbeat is older than this are folded into "+N more". */
111
+ freshnessSeconds: number;
112
+ relations?: Map<string, string>;
113
+ }
114
+ /**
115
+ * Pure peer-table render plus its change key. The key covers only what makes
116
+ * the table news: which live sessions exist, their lifecycle, and the files
117
+ * they claim. Activity and task text change on nearly every peer tool call, so
118
+ * they render in the table but never trigger a reprint by themselves.
119
+ */
120
+ export declare function renderPeerTable(input: PeerTableInput): {
121
+ hash: string;
122
+ table: string;
123
+ };
124
+ export {};
77
125
  //# sourceMappingURL=prompt-context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"prompt-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/prompt-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAiDH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;8EAC0E;IAC1E,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;kFAC8E;IAC9E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;kFAC8E;IAC9E,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;uEACmE;IACnE,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;wBAIoB;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6EAA6E;IAC7E,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;CAC3D;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CA8EnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CA6CtF"}
1
+ {"version":3,"file":"prompt-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/prompt-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAwBH,OAAO,EAAE,KAAK,aAAa,EAAsB,MAAM,yBAAyB,CAAC;AASjF,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAG1E,UAAU,YAAY;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;8EAC0E;IAC1E,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;kFAC8E;IAC9E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;kFAC8E;IAC9E,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;uEACmE;IACnE,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;0DAGsD;IACtD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6EAA6E;IAC7E,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;CAC3D;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CA6EnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CA6CtF;AAsBD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAEjF;AA0LD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,MAAM,EAAE,aAAa,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,kFAAkF;IAClF,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,cAAc,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA2CtF"}
@@ -7,9 +7,10 @@
7
7
  * and forwards the result as the adapter-shaped additionalContext payload.
8
8
  *
9
9
  * Four subsections combined into one additionalContext payload:
10
- * 1. Peer table: semantically-relevant peer fields hashed; only re-emits
11
- * when peers change (name + instance_id + session_id + kind + started_at
12
- * + sorted(files_touched) + platform, sorted by instance_id).
10
+ * 1. Peer table: re-emits only when a live peer arrives or leaves, changes
11
+ * lifecycle, or changes its claimed files. Activity and task text never
12
+ * trigger a reprint. Peers past the heartbeat-freshness window are left
13
+ * out and counted in "+N more".
13
14
  * 2. Council pending: pending open-council IDs hashed; re-emits when the
14
15
  * ID set changes.
15
16
  * 3. Focus nudge. Until the session has displayed its current suggested
@@ -24,8 +25,11 @@
24
25
  * 5. Codex status footer: a non-deduplicated reminder to put the live status
25
26
  * box after the substantive answer. Stop stays observe-only, so missing the
26
27
  * footer can never trigger a replacement response.
28
+ * 6. Turn ritual: for adapters whose Stop hook enforces the ritual, shown on
29
+ * the first prompt, after a compaction, and after a bounced turn.
27
30
  *
28
31
  * Hash files live at:
32
+ * .harnery/.last-turn-ritual.<instance_id>
29
33
  * .harnery/.last-peer-hash.<instance_id>
30
34
  * .harnery/.last-council-hash.<instance_id>
31
35
  * .harnery/.last-task-nudge-hash.<instance_id>
@@ -36,7 +40,8 @@ import { createHash } from "node:crypto";
36
40
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
37
41
  import { join } from "node:path";
38
42
  import { coordEnv } from "../../../lib/env.js";
39
- import { endOfTurnStatusCommand, hostPromptReminder, resolveBinName } from "../../config.js";
43
+ import { coordFreshnessSeconds, endOfTurnStatusCommand, hostPromptReminder, resolveBinName, } from "../../config.js";
44
+ import { readContextState } from "../../context/index.js";
40
45
  import { formatPeerInbox, peerInbox } from "../../inbox/peer-messages.js";
41
46
  import { readRemoteMachines } from "../../presence/index.js";
42
47
  import { readResourceStatus } from "../../resources/status.js";
@@ -107,13 +112,12 @@ export function renderPromptContext(opts) {
107
112
  if (statusMsg)
108
113
  sections.push(statusMsg);
109
114
  }
110
- // 7. Adapters whose Stop hook ENFORCES the turn ritual get a fresh reminder
111
- // on every prompt. Reminding before the reply is drafted is far cheaper than
112
- // the bounce-and-retry the Stop hook otherwise forces: enforcement-only
113
- // compliance was measured missing on a double-digit share of turns. Not
114
- // hash-deduped for the same reason as the Codex footer.
115
+ // 7. Adapters whose Stop hook ENFORCES the turn ritual get the reminder when
116
+ // it is news: on the session's first prompt, after a context compaction,
117
+ // and after a turn the Stop hook bounced. The Stop hook keeps enforcing on
118
+ // every turn, so a missed ritual still costs one retry and re-arms this.
115
119
  if (turnRitualNudge) {
116
- const ritualMsg = renderTurnRitualReminder(coordRoot, instanceId, turnRitualNudge);
120
+ const ritualMsg = renderTurnRitualReminder(coordRoot, instanceId, sessionId, turnRitualNudge);
117
121
  if (ritualMsg)
118
122
  sections.push(ritualMsg);
119
123
  }
@@ -181,17 +185,46 @@ function renderHostPromptReminder(coordRoot, selfInstanceId) {
181
185
  return "";
182
186
  return hostPromptReminder(coordRoot) ?? "";
183
187
  }
184
- function renderTurnRitualReminder(coordRoot, selfInstanceId, adapter) {
188
+ function turnRitualStatePath(coordRoot, instanceId) {
189
+ return join(coordRoot, ".harnery", `.last-turn-ritual.${instanceId}`);
190
+ }
191
+ const TURN_RITUAL_BOUNCED = "bounced";
192
+ /**
193
+ * Called by the Stop hook when it blocks a turn for a missing ritual step, so
194
+ * the next prompt carries the reminder again.
195
+ */
196
+ export function markTurnRitualBounced(coordRoot, instanceId) {
197
+ writeHashFile(turnRitualStatePath(coordRoot, instanceId), TURN_RITUAL_BOUNCED);
198
+ }
199
+ function renderTurnRitualReminder(coordRoot, selfInstanceId, sessionId, adapter) {
185
200
  const hb = readLiveCoordinationRow(coordRoot, selfInstanceId);
186
201
  if (!hb || hb.kind === "subagent" || hb.kind === "transient" || hb.workflow_run_id)
187
202
  return "";
203
+ // The key changes when native context compaction drops the earlier reminder.
204
+ let generation = 0;
205
+ try {
206
+ generation = readContextState(coordRoot, sessionId)?.generation ?? 0;
207
+ }
208
+ catch {
209
+ /* no continuity state: treat as the first context generation */
210
+ }
211
+ const key = `context-generation=${generation}`;
212
+ const statePath = turnRitualStatePath(coordRoot, selfInstanceId);
213
+ const previous = safeRead(statePath);
214
+ if (previous === key)
215
+ return "";
216
+ writeHashFile(statePath, key);
217
+ const bounced = previous === TURN_RITUAL_BOUNCED
218
+ ? "Your previous turn was bounced by the Stop hook because the ritual was incomplete. "
219
+ : "";
188
220
  const bin = resolveBinName(coordRoot);
189
221
  const statusCommand = endOfTurnStatusCommand(coordRoot);
190
222
  // Claude Code and Cursor can both enforce completed assistant reply text.
191
223
  const paste = adapter === "claude-code" || adapter === "cursor"
192
224
  ? " and paste its output verbatim in a fenced code block at the end of your reply"
193
225
  : "";
194
- return (`Turn ritual (Stop-enforced): on any turn that uses tools, run \`${bin} agents set-task "<short focus>"\` at some point during the turn (required every turn, even when the focus is unchanged; pass an empty string when there is no meaningful focus), then make \`${statusCommand}\` your final tool call${paste}. ` +
226
+ return (bounced +
227
+ `Turn ritual (Stop-enforced): on any turn that uses tools, run \`${bin} agents set-task "<short focus>"\` at some point during the turn (required every turn, even when the focus is unchanged; pass an empty string when there is no meaningful focus), then make \`${statusCommand}\` your final tool call${paste}. ` +
195
228
  "A turn that ends without these is bounced by the Stop hook and costs a retry.");
196
229
  }
197
230
  function renderStatusFooterReminder(coordRoot, selfInstanceId) {
@@ -299,8 +332,43 @@ function computePeerTableIfChanged(coordRoot, selfInstanceId, selfSessionIdFallb
299
332
  const remote = readRemoteMachinesSafe(coordRoot);
300
333
  if (peers.length === 0 && remote.length === 0)
301
334
  return "";
302
- // Build hash basis: sorted-by-instance_id projection of semantically-relevant fields.
303
- const basis = peers
335
+ const rendered = renderPeerTable({
336
+ peers,
337
+ remote,
338
+ mySessionId,
339
+ nowMs: Date.now(),
340
+ freshnessSeconds: coordFreshnessSeconds(coordRoot),
341
+ relations: forkRelationLabels(coordRoot, selfInstanceId, new Set(peers.map((p) => p.instance_id ?? ""))),
342
+ });
343
+ if (!rendered.table)
344
+ return "";
345
+ const hashFile = join(coordRoot, ".harnery", `.last-peer-hash.${selfInstanceId}`);
346
+ const oldHash = safeRead(hashFile);
347
+ if (oldHash && oldHash === rendered.hash)
348
+ return "";
349
+ // Persist hash atomically (temp + rename, same convention as other coord writes).
350
+ writeHashFile(hashFile, rendered.hash);
351
+ return rendered.table;
352
+ }
353
+ /**
354
+ * Pure peer-table render plus its change key. The key covers only what makes
355
+ * the table news: which live sessions exist, their lifecycle, and the files
356
+ * they claim. Activity and task text change on nearly every peer tool call, so
357
+ * they render in the table but never trigger a reprint by themselves.
358
+ */
359
+ export function renderPeerTable(input) {
360
+ const { peers, remote, mySessionId, nowMs, freshnessSeconds } = input;
361
+ const cutoffMs = nowMs - freshnessSeconds * 1000;
362
+ const live = [];
363
+ const stale = [];
364
+ for (const peer of peers) {
365
+ const seenMs = Date.parse(peer.last_heartbeat ?? "");
366
+ if (Number.isFinite(seenMs) && seenMs >= cutoffMs)
367
+ live.push(peer);
368
+ else
369
+ stale.push(peer);
370
+ }
371
+ const basis = live
304
372
  .map((p) => ({
305
373
  name: p.name ?? null,
306
374
  instance_id: p.instance_id ?? null,
@@ -309,39 +377,28 @@ function computePeerTableIfChanged(coordRoot, selfInstanceId, selfSessionIdFallb
309
377
  started_at: p.started_at ?? null,
310
378
  files_touched: Array.from(p.files_touched ?? []).sort(),
311
379
  platform: p.platform ?? null,
312
- activity: p.activity ?? "unknown",
313
380
  task_state: p.task_state ?? "active",
314
381
  task_state_reason: p.task_state_reason ?? null,
315
382
  }))
316
383
  .sort((a, b) => (a.instance_id ?? "").localeCompare(b.instance_id ?? ""));
317
- // Remote basis: machine + per-agent identity/task/files (published_at is
318
- // excluded — a keepalive re-publish alone shouldn't re-emit the table).
384
+ // Remote basis: machine + per-agent identity, lifecycle, and files
385
+ // (published_at, activity, and task are excluded for the same reason).
319
386
  const remoteBasis = remote.map((m) => ({
320
387
  machine: m.machine,
321
388
  agents: m.agents.map((a) => ({
322
389
  instance_id: a.instance_id,
323
390
  name: a.name ?? null,
324
- task: a.task ?? null,
325
- activity: a.activity,
326
391
  task_state: a.task_state,
327
392
  task_state_reason: a.task_state_reason ?? null,
328
393
  files_touched: Array.from(a.files_touched ?? []).sort(),
329
394
  })),
330
395
  }));
331
- const newHash = sha256Hex16(JSON.stringify({ local: basis, remote: remoteBasis }));
332
- const hashFile = join(coordRoot, ".harnery", `.last-peer-hash.${selfInstanceId}`);
333
- const oldHash = safeRead(hashFile);
334
- if (oldHash && oldHash === newHash)
335
- return "";
336
- // Render peer table via the same formatter used at SessionStart.
337
- const local = formatPeerTable(peers, mySessionId, forkRelationLabels(coordRoot, selfInstanceId, new Set(peers.map((p) => p.instance_id ?? ""))));
338
- const remoteTable = formatRemoteTable(remote);
396
+ const hash = sha256Hex16(JSON.stringify({ local: basis, remote: remoteBasis }));
397
+ const nowSec = Math.floor(nowMs / 1000);
398
+ const local = formatPeerTable(live, stale, mySessionId, nowSec, input.relations);
399
+ const remoteTable = formatRemoteTable(remote, nowSec);
339
400
  const table = [local, remoteTable].filter(Boolean).join("\n\n");
340
- if (!table)
341
- return "";
342
- // Persist hash atomically (temp + rename, same convention as other coord writes).
343
- writeHashFile(hashFile, newHash);
344
- return table;
401
+ return { hash, table };
345
402
  }
346
403
  /** readRemoteMachines with a local failure guard (rendering must never throw). */
347
404
  function readRemoteMachinesSafe(coordRoot) {
@@ -353,10 +410,9 @@ function readRemoteMachinesSafe(coordRoot) {
353
410
  }
354
411
  }
355
412
  /** Render sessions on other machines (presence transport) as a subtable. */
356
- function formatRemoteTable(remote) {
413
+ function formatRemoteTable(remote, nowSec) {
357
414
  if (remote.length === 0)
358
415
  return "";
359
- const nowSec = Math.floor(Date.now() / 1000);
360
416
  const lines = [];
361
417
  for (const m of remote) {
362
418
  for (const a of m.agents.slice(0, 10)) {
@@ -451,16 +507,11 @@ function writeHashFile(path, value) {
451
507
  /* swallow */
452
508
  }
453
509
  }
454
- /* The peer-table formatter is a near-duplicate of session-context.ts's, but
455
- * intentionally co-located here so the prompt-context renderer is self-contained
456
- * (no cross-file imports of formatting internals). If both renderers diverge,
457
- * pull the shared bits into a small util module. */
458
- function formatPeerTable(peers, mySessionId, relations = new Map()) {
510
+ function formatPeerTable(peers, stalePeers, mySessionId, nowSec, relations = new Map()) {
459
511
  if (peers.length === 0)
460
512
  return "";
461
- const nowSec = Math.floor(Date.now() / 1000);
462
513
  const fold = {};
463
- for (const p of peers) {
514
+ for (const p of [...peers, ...stalePeers]) {
464
515
  const kind = p.kind ?? "unknown";
465
516
  if (kind === "transient" && p.session_id) {
466
517
  fold[p.session_id] = (fold[p.session_id] ?? []).concat(p.files_touched ?? []);
@@ -474,28 +525,52 @@ function formatPeerTable(peers, mySessionId, relations = new Map()) {
474
525
  const relation = relations.get(p.instance_id ?? "");
475
526
  return { ...p, display_files: display, ...(relation ? { relation } : {}) };
476
527
  });
528
+ assignUniqueLabels(rows);
529
+ const staleRows = stalePeers.filter((p) => (p.kind ?? "unknown") !== "transient");
477
530
  // Related sessions sort first so a fork parent never hides in "+N more".
478
531
  const blocking = rows
479
532
  .filter((p) => p.session_id !== mySessionId)
480
533
  .sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
481
534
  const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
535
+ const staleBlocking = staleRows.filter((p) => p.session_id !== mySessionId).length;
536
+ const staleGroup = staleRows.length - staleBlocking;
482
537
  const out = [];
483
- const blk = renderSubtable(blocking, "Other agent groups active (their files block you):", nowSec);
538
+ const blk = renderSubtable(blocking, staleBlocking, "Other agent groups active (their files block you):", nowSec);
484
539
  if (blk)
485
540
  out.push(blk);
486
- const grp = renderSubtable(group, "Your group (subagents / parent / siblings; no mutual block):", nowSec);
541
+ const grp = renderSubtable(group, staleGroup, "Your group (subagents / parent / siblings; no mutual block):", nowSec);
487
542
  if (grp)
488
543
  out.push(grp);
489
544
  return out.join("\n\n");
490
545
  }
546
+ /**
547
+ * One full, distinct label per row. An unnamed session shows its whole
548
+ * instance id (an 8-character prefix collides for time-ordered ids), and a
549
+ * name two live rows share carries its instance id so they read as two.
550
+ */
551
+ function assignUniqueLabels(rows) {
552
+ const base = (r) => r.name ?? r.instance_id ?? "unknown";
553
+ const counts = new Map();
554
+ for (const r of rows)
555
+ counts.set(base(r), (counts.get(base(r)) ?? 0) + 1);
556
+ for (const r of rows) {
557
+ const label = base(r);
558
+ r.label =
559
+ (counts.get(label) ?? 0) > 1 && r.name && r.instance_id
560
+ ? `${label} (${r.instance_id})`
561
+ : label;
562
+ }
563
+ }
491
564
  function byStartedAt(a, b) {
492
565
  return (a.started_at ?? "").localeCompare(b.started_at ?? "");
493
566
  }
494
- function renderSubtable(rows, header, nowSec) {
567
+ function renderSubtable(rows, staleCount, header, nowSec) {
495
568
  if (rows.length === 0)
496
569
  return "";
497
570
  const first = rows.slice(0, 10).map((r) => formatRow(r, nowSec));
498
- const overflow = rows.length > 10 ? `\n +${rows.length - 10} more` : "";
571
+ const more = Math.max(0, rows.length - 10) + staleCount;
572
+ const staleNote = staleCount > 0 ? ` (${staleCount} with no recent heartbeat)` : "";
573
+ const overflow = more > 0 ? `\n +${more} more${staleNote}` : "";
499
574
  return `${header}\n${first.join("\n")}${overflow}`;
500
575
  }
501
576
  function formatRow(r, nowSec) {
@@ -506,9 +581,8 @@ function formatRow(r, nowSec) {
506
581
  const startedSec = parseIsoSec(r.started_at) ?? parseIsoSec(r.last_heartbeat);
507
582
  const ageFrom = startedSec === null ? "age unknown" : fmtAge(nowSec - startedSec);
508
583
  const filesPart = fmtFiles(r.display_files);
509
- // Prefer a short instance_id over a bare "unknown" so an incomplete row is
510
- // still identifiable.
511
- const label = r.name ?? (r.instance_id ? r.instance_id.slice(0, 8) : "unknown");
584
+ // An unnamed row shows its full instance id; a prefix can collide.
585
+ const label = r.label ?? r.name ?? r.instance_id ?? "unknown";
512
586
  const relation = r.relation ? ` [${r.relation}]` : "";
513
587
  return ` - agent-${label}${relation}${taskPart} (${statePart}, ${ageFrom}, ${filesPart})`;
514
588
  }
@@ -1 +1 @@
1
- {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAWH,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAgBlD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CA4GjF"}
1
+ {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAaH,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAgBlD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAmHjF"}
@@ -17,7 +17,9 @@ import { spawnSync } from "node:child_process";
17
17
  import { existsSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { adapterFromPlatform as sharedAdapterFromPlatform } from "../../adapter.js";
20
- import { recordLiveClaimChangeV3 } from "../live-authority-v3.js";
20
+ import { resolveBinName } from "../../config.js";
21
+ import { writeProducerDiagnosticV3 } from "../../events/v3/producers/intake.js";
22
+ import { LiveCoordinationAuthorityV3Error, recordLiveClaimChangeV3 } from "../live-authority-v3.js";
21
23
  import { readLiveCoordinationRows } from "../state/live-coordination-view.js";
22
24
  const FRESHNESS_SECS = 600;
23
25
  /**
@@ -47,8 +49,9 @@ export function evaluateClaim(coordRoot, req) {
47
49
  // prune the claim from their heartbeat and allow the edit. Skips when the
48
50
  // file doesn't exist (intent-to-create claim); that surface IS load-bearing.
49
51
  if (isFileCommittedClean(coordRoot, req.path)) {
50
- if (!releaseClaimThroughLedger(coordRoot, conflict.instance_id, req.path, req)) {
51
- return authorityUnavailable(req.path, "release the stale claim on");
52
+ const refused = releaseClaimThroughLedger(coordRoot, conflict.instance_id, req.path, req);
53
+ if (refused) {
54
+ return authorityUnavailable(coordRoot, req, req.path, "release the stale claim on", refused);
52
55
  }
53
56
  // Fall through to ordering check + allow.
54
57
  }
@@ -109,8 +112,9 @@ export function evaluateClaim(coordRoot, req) {
109
112
  // Every blocker is a finished (committed-clean) edit: prune them so they
110
113
  // stop constraining future acquisitions, then fall through to allow.
111
114
  for (const p of blockers) {
112
- if (!releaseClaimThroughLedger(coordRoot, req.instance_id, p, req)) {
113
- return authorityUnavailable(p, "release the completed claim on");
115
+ const refused = releaseClaimThroughLedger(coordRoot, req.instance_id, p, req);
116
+ if (refused) {
117
+ return authorityUnavailable(coordRoot, req, p, "release the completed claim on", refused);
114
118
  }
115
119
  }
116
120
  }
@@ -118,9 +122,9 @@ export function evaluateClaim(coordRoot, req) {
118
122
  // Acquire the claim: atomic check-and-set. Adds req.path to my
119
123
  // files_touched if not already present.
120
124
  if (req.mode !== "read") {
121
- if (!acquireClaimThroughLedger(coordRoot, req)) {
122
- return authorityUnavailable(req.path, "acquire");
123
- }
125
+ const refused = acquireClaimThroughLedger(coordRoot, req);
126
+ if (refused)
127
+ return authorityUnavailable(coordRoot, req, req.path, "acquire", refused);
124
128
  }
125
129
  return { allow: true, exit_code: 0, rule: "claim.pass" };
126
130
  }
@@ -205,6 +209,7 @@ function isFileCommittedClean(coordRoot, relPath) {
205
209
  return false;
206
210
  }
207
211
  }
212
+ /** Returns null when the ledger recorded the claim, else the refusal reason. */
208
213
  function acquireClaimThroughLedger(coordRoot, req) {
209
214
  const owner = readPeers(coordRoot).find((peer) => peer.instance_id === req.instance_id);
210
215
  try {
@@ -217,10 +222,10 @@ function acquireClaimThroughLedger(coordRoot, req) {
217
222
  path: req.path,
218
223
  access: "write",
219
224
  });
220
- return true;
225
+ return null;
221
226
  }
222
- catch {
223
- return false;
227
+ catch (error) {
228
+ return refusalReason(error);
224
229
  }
225
230
  }
226
231
  function releaseClaimThroughLedger(coordRoot, subjectInstanceId, relPath, actor) {
@@ -236,17 +241,32 @@ function releaseClaimThroughLedger(coordRoot, subjectInstanceId, relPath, actor)
236
241
  path: relPath,
237
242
  access: "write",
238
243
  });
239
- return true;
244
+ return null;
240
245
  }
241
- catch {
242
- return false;
246
+ catch (error) {
247
+ return refusalReason(error);
243
248
  }
244
249
  }
245
- function authorityUnavailable(path, operation) {
250
+ function refusalReason(error) {
251
+ if (error instanceof LiveCoordinationAuthorityV3Error)
252
+ return error.reason;
253
+ return error instanceof Error ? error.message : String(error);
254
+ }
255
+ function authorityUnavailable(coordRoot, req, path, operation, refusal) {
256
+ // The ledger's own reason is the only evidence of why a healthy-looking
257
+ // session cannot claim; a generic message sent agents to diagnose the wrong
258
+ // layer, so keep the reason in the verdict and in a durable diagnostic.
259
+ writeProducerDiagnosticV3(coordRoot, "claim_authority_refused", {
260
+ instance_id: req.instance_id,
261
+ operation,
262
+ reason: refusal,
263
+ });
246
264
  return {
247
265
  allow: false,
248
266
  exit_code: 2,
249
267
  rule: "claim.authority_unavailable",
250
- reason: `Cannot safely ${operation} ${path}; V3 authority is unavailable.`,
268
+ reason: `Cannot safely ${operation} ${path}; the coordination ledger refused the claim ` +
269
+ `(${refusal}). Run \`${resolveBinName(coordRoot)} agents health\` and retry; if health ` +
270
+ "is safe, the refusal reason above names the session state that blocked it.",
251
271
  };
252
272
  }
@@ -26,6 +26,12 @@ export interface StopHookRequest {
26
26
  session_name_observation_unavailable?: boolean;
27
27
  bypass?: boolean;
28
28
  workflow_child?: boolean;
29
+ /**
30
+ * Claude Code moved this session's foreground command into the background
31
+ * and has not yet reported it finished. Finalization cannot pass until it
32
+ * does, so status and reply-box demands wait for the notification turn.
33
+ */
34
+ pending_background_handoff?: boolean;
29
35
  }
30
36
  export declare const STOP_REMEDIATION_MARKER = "[harnery:stop-remediation";
31
37
  export declare function evaluateStopHook(coordRoot: string, req: StopHookRequest): VerdictResult;
@@ -1 +1 @@
1
- {"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,6BAA6B,CAAC;AAM3D,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,iFAAiF;IACjF,oCAAoC,CAAC,EAAE,OAAO,CAAC;IAC/C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,eAAO,MAAM,uBAAuB,8BAA8B,CAAC;AAInE,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,eAAe,GAAG,aAAa,CA6BvF;AAED,oFAAoF;AACpF,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,SAAS,OAAO,EAAE,GACzB,aAAa,CAiIf"}
1
+ {"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,6BAA6B,CAAC;AAM3D,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,iFAAiF;IACjF,oCAAoC,CAAC,EAAE,OAAO,CAAC;IAC/C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;OAIG;IACH,0BAA0B,CAAC,EAAE,OAAO,CAAC;CACtC;AAED,eAAO,MAAM,uBAAuB,8BAA8B,CAAC;AAInE,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,eAAe,GAAG,aAAa,CA6BvF;AAED,oFAAoF;AACpF,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,SAAS,OAAO,EAAE,GACzB,aAAa,CA2If"}
@@ -101,6 +101,8 @@ export function evaluateStopHookV3Events(coordRoot, req, events) {
101
101
  // Pure prose turns keep the human-visible acknowledgement but do not require
102
102
  // task or final-status mutations. This preserves the pre-V3 policy exactly.
103
103
  if (!usedTool) {
104
+ if (!ackPresent && req.pending_background_handoff)
105
+ return backgroundHandoffDeferred();
104
106
  if (!ackPresent)
105
107
  return withCycleAnchor(ackBlock(), cycleAnchor);
106
108
  return {
@@ -133,11 +135,28 @@ export function evaluateStopHookV3Events(coordRoot, req, events) {
133
135
  pending.push(sessionNameBlock(coordRoot, req.instance_id));
134
136
  }
135
137
  }
136
- const blocked = mergeStopBlocks(pending);
138
+ // A status check run now would refuse on the in-flight commit and a box
139
+ // cannot be issued without it, so bouncing only loops until the task ends.
140
+ // Task and naming evidence do not depend on the task and stay enforced.
141
+ const enforced = req.pending_background_handoff
142
+ ? pending.filter((block) => block.rule !== "stop-hook.rule_1_3" && block.rule !== "stop-hook.rule_2_3")
143
+ : pending;
144
+ const blocked = mergeStopBlocks(enforced);
137
145
  if (blocked)
138
146
  return withCycleAnchor(blocked, cycleAnchor);
147
+ if (enforced.length < pending.length)
148
+ return backgroundHandoffDeferred();
139
149
  return { allow: true, exit_code: 0, rule: "stop-hook.pass" };
140
150
  }
151
+ function backgroundHandoffDeferred() {
152
+ return {
153
+ allow: true,
154
+ exit_code: 0,
155
+ rule: "stop-hook.background_handoff_deferred",
156
+ reason: "a command Claude Code moved to the background is still running; status and reply-box " +
157
+ "evidence are required on the turn its completion notification starts",
158
+ };
159
+ }
141
160
  function unconditionalVerdict(req) {
142
161
  if (req.bypass) {
143
162
  return {
@@ -31,7 +31,7 @@
31
31
  * two callers reading the same live process disagree.
32
32
  */
33
33
  /** Which probe answers "when did this process start?" on this machine. */
34
- export type StartTokenProbe = "procfs" | "ps";
34
+ export type StartTokenProbe = "procfs" | "ps" | "windows";
35
35
  /**
36
36
  * Which probe this machine uses, decided once.
37
37
  *
@@ -68,6 +68,8 @@ export declare function psStartToken(pid: number, run?: (pid: number) => {
68
68
  * will not say.
69
69
  */
70
70
  export declare function processStartToken(pid: number): string | null;
71
+ /** Windows process creation time in invariant UTC ticks, independent of locale. */
72
+ export declare function windowsStartToken(pid: number): string | null;
71
73
  export type ProcessGenerationVerdict = "same" | "different" | "unknown";
72
74
  type ReadProcessStartToken = (pid: number) => string | null;
73
75
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"proc-start.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/proc-start.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAMH,0EAA0E;AAC1E,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,IAAI,CAAC;AAc9C;;;;;;GAMG;AACH,wBAAgB,eAAe,IAAI,eAAe,CAOjD;AAyBD,uDAAuD;AACvD,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc3D;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAiB,GAC7E,MAAM,GAAG,IAAI,CAUf;AAWD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG5D;AAED,MAAM,MAAM,wBAAwB,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;AAExE,KAAK,qBAAqB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;AAE5D;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,SAAS,GAAE,qBAAyC,GACnD,wBAAwB,CAuB1B;AAOD,MAAM,WAAW,mCAAmC;IAClD,SAAS,CAAC,EAAE,qBAAqB,CAAC;IAClC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,KAAK,OAAO,CAAC;CAClE;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,EAC/B,YAAY,GAAE,mCAAwC,GACrD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,wBAAwB,CAAA;CAAE,CAUtD"}
1
+ {"version":3,"file":"proc-start.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/proc-start.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAMH,0EAA0E;AAC1E,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,IAAI,GAAG,SAAS,CAAC;AAc1D;;;;;;GAMG;AACH,wBAAgB,eAAe,IAAI,eAAe,CAQjD;AAyBD,uDAAuD;AACvD,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc3D;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAiB,GAC7E,MAAM,GAAG,IAAI,CAUf;AAWD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAK5D;AAED,mFAAmF;AACnF,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkB5D;AAED,MAAM,MAAM,wBAAwB,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;AAExE,KAAK,qBAAqB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;AAE5D;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,SAAS,GAAE,qBAAyC,GACnD,wBAAwB,CAuB1B;AAOD,MAAM,WAAW,mCAAmC;IAClD,SAAS,CAAC,EAAE,qBAAqB,CAAC;IAClC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,KAAK,OAAO,CAAC;CAClE;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,EAC/B,YAAY,GAAE,mCAAwC,GACrD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,wBAAwB,CAAA;CAAE,CAUtD"}