harnery 0.46.0 → 0.46.1

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.
@@ -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 {
@@ -55,7 +55,7 @@ import { beginHookHealth, observeHookDebug, observeHookError, writeHookHealthCom
55
55
  import { adapterPidFromEnv, parsePsChainLine, selectAnchorPid } from "./resolve/anchor.js";
56
56
  import { extractIntentComment, resolveIntent } from "./resolve/intent.js";
57
57
  import { resolveOwner } from "./resolve/owner.js";
58
- import { inspectSessionNameDisplayImmediately, scanAssistantStatusBoxPresent, scanStatusBoxPresent, scanTranscriptRuntime, } from "./resolve/transcript.js";
58
+ import { inspectSessionNameDisplayImmediately, scanAssistantStatusBoxPresent, scanPendingBackgroundHandoffs, scanStatusBoxPresent, scanTranscriptRuntime, } from "./resolve/transcript.js";
59
59
  import { clearHookRunMarker, writeHookRunMarker } from "./run-markers.js";
60
60
  import { sessionNamePresence } from "./session-name-presence.js";
61
61
  import { shellCommitTreeReason } from "./shell-commit-tree.js";
@@ -1222,6 +1222,14 @@ async function handleStop(run) {
1222
1222
  catch (err) {
1223
1223
  logError(coordRoot, err, { phase: "stop-presence" });
1224
1224
  }
1225
+ // Claude Code may move a running foreground command (often the agent's own
1226
+ // commit and push) into the background to deliver a user message. Until it
1227
+ // reports back, finalization cannot pass, so the verdict defers the status
1228
+ // demands instead of bouncing the agent in a loop.
1229
+ const pendingBackgroundHandoffs = adapter === "claude-code"
1230
+ ? scanPendingBackgroundHandoffs(payload?.transcript_path ??
1231
+ behavior.discoverTranscript?.(payload?.session_id ?? sessionId))
1232
+ : [];
1225
1233
  // Stop verdict (V3 ritual + task/status gate). Direct in-process call: the
1226
1234
  // rule lives in Harnery. agent-hook already emitted this turn.completed
1227
1235
  // with privacy-safe ritual observations, so the evidence is in the stream.
@@ -1235,7 +1243,15 @@ async function handleStop(run) {
1235
1243
  session_name_observation_unavailable: typeof data.session_name_unavailable_reason === "string",
1236
1244
  bypass: coordEnv("AGENT_COORD_BYPASS_STOP") === "1",
1237
1245
  workflow_child: coordEnv("WORKFLOW_CHILD") === "1",
1246
+ pending_background_handoff: pendingBackgroundHandoffs.length > 0,
1238
1247
  });
1248
+ if (verdict.rule === "stop-hook.background_handoff_deferred") {
1249
+ appendDebug(coordRoot, {
1250
+ ...debugBase,
1251
+ effect: "stop-deferred-background-handoff",
1252
+ background_task_ids: pendingBackgroundHandoffs,
1253
+ });
1254
+ }
1239
1255
  if (!verdict.allow) {
1240
1256
  // Backstop for sessions whose evidence can never land (e.g. a headless
1241
1257
  // child whose status command resolves to a different owner): after
@@ -1270,6 +1286,13 @@ async function handleStop(run) {
1270
1286
  // until the adapter has committed turn.completed above. Reconcile only
1271
1287
  // when such a request exists; any later real work cancels it in the
1272
1288
  // finalizer instead of being terminated underneath the agent.
1289
+ // A deferred stop proves nothing about finalization; an explicit end must
1290
+ // wait for the turn that reports the background task's result.
1291
+ if (verdict.rule === "stop-hook.background_handoff_deferred") {
1292
+ if (behavior.runtimeContextRetry)
1293
+ scheduleRuntimeContextRetry(coordRoot, adapter, sessionId);
1294
+ return undefined;
1295
+ }
1273
1296
  try {
1274
1297
  const { hasPendingExplicitSessionEndV3, reconcileSessionFinalizationV3 } = await import("../agents/session-finalizer-v3.js");
1275
1298
  if (hasPendingExplicitSessionEndV3(coordRoot)) {
@@ -33,6 +33,18 @@ export declare function scanAssistantTextIncludes(transcriptPath: string | undef
33
33
  * from the adapter's completed assistant response.
34
34
  */
35
35
  export declare function scanAssistantStatusBoxPresent(transcriptPath: string | undefined, lastAssistantMessage: string | undefined): boolean;
36
+ /** A handoff older than this no longer defers Stop, so a lost notification cannot disable the ritual. */
37
+ export declare const BACKGROUND_HANDOFF_MAX_AGE_MS: number;
38
+ /**
39
+ * Claude Code background tasks it moved off the foreground on its own (to
40
+ * deliver a mid-command user message) that have not yet posted a
41
+ * `<task-notification>`. Only the structured `toolUseResult` fields written by
42
+ * Claude Code open a handoff; the agent cannot forge them through tool output.
43
+ * Any later notification text naming the task closes it, so a spoofed
44
+ * notification can only make Stop stricter. A command the agent chose to
45
+ * background (`run_in_background`) or a Monitor opens nothing.
46
+ */
47
+ export declare function scanPendingBackgroundHandoffs(transcriptPath: string | undefined, nowMs?: number): string[];
36
48
  /** Most recent user-visible assistant text across Claude Code and Codex JSONL. */
37
49
  export declare function scanLatestAssistantText(transcriptPath: string | undefined): string | undefined;
38
50
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/transcript.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,2BAA2B,wBAAc,CAAC;AAEvD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAQhF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CACvC,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,MAAM,EAAE,MAAM,GACb,OAAO,CAiCT;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,oBAAoB,EAAE,MAAM,GAAG,SAAS,GACvC,OAAO,CAKT;AAED,kFAAkF;AAClF,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAgB9F;AAED;;;;;GAKG;AACH,wBAAgB,mCAAmC,CACjD,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,IAAI,EAAE,MAAM,EACZ,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,OAAO,GAC/D,OAAO,CAIT;AAED,MAAM,MAAM,4BAA4B,GACpC;IAAE,KAAK,EAAE,SAAS,CAAA;CAAE,GACpB;IAAE,KAAK,EAAE,QAAQ,CAAA;CAAE,GACnB;IACE,KAAK,EAAE,aAAa,CAAC;IACrB,MAAM,EAAE,oBAAoB,GAAG,sBAAsB,GAAG,wBAAwB,CAAC;CAClF,CAAC;AAEN;;;;;GAKG;AACH,wBAAgB,oCAAoC,CAClD,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,IAAI,EAAE,MAAM,EACZ,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,OAAO,GAC/D,4BAA4B,CAqD9B;AAgBD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAUrF;AAwDD;4CAC4C;AAC5C,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd;+EAC2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,qBAAqB,CACnC,cAAc,EAAE,MAAM,GAAG,SAAS,GACjC,qBAAqB,GAAG,SAAS,CA8BnC;AAgBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,4BAA4B,CAC1C,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,SAAS,CAiBpB;AA6CD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,gBAAgB,CAC9B,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,SAAS,CAkDpB"}
1
+ {"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/transcript.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,2BAA2B,wBAAc,CAAC;AAEvD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAQhF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CACvC,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,MAAM,EAAE,MAAM,GACb,OAAO,CAiCT;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,oBAAoB,EAAE,MAAM,GAAG,SAAS,GACvC,OAAO,CAKT;AAED,yGAAyG;AACzG,eAAO,MAAM,6BAA6B,QAAiB,CAAC;AAQ5D;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAC3C,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,KAAK,GAAE,MAAmB,GACzB,MAAM,EAAE,CAsCV;AAED,kFAAkF;AAClF,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAgB9F;AAED;;;;;GAKG;AACH,wBAAgB,mCAAmC,CACjD,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,IAAI,EAAE,MAAM,EACZ,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,OAAO,GAC/D,OAAO,CAIT;AAED,MAAM,MAAM,4BAA4B,GACpC;IAAE,KAAK,EAAE,SAAS,CAAA;CAAE,GACpB;IAAE,KAAK,EAAE,QAAQ,CAAA;CAAE,GACnB;IACE,KAAK,EAAE,aAAa,CAAC;IACrB,MAAM,EAAE,oBAAoB,GAAG,sBAAsB,GAAG,wBAAwB,CAAC;CAClF,CAAC;AAEN;;;;;GAKG;AACH,wBAAgB,oCAAoC,CAClD,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,IAAI,EAAE,MAAM,EACZ,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,OAAO,GAC/D,4BAA4B,CAqD9B;AAgBD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAUrF;AAwDD;4CAC4C;AAC5C,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd;+EAC2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,qBAAqB,CACnC,cAAc,EAAE,MAAM,GAAG,SAAS,GACjC,qBAAqB,GAAG,SAAS,CA8BnC;AAgBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,4BAA4B,CAC1C,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,SAAS,CAiBpB;AA6CD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,gBAAgB,CAC9B,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,SAAS,CAkDpB"}
@@ -88,6 +88,60 @@ export function scanAssistantStatusBoxPresent(transcriptPath, lastAssistantMessa
88
88
  return (scanAssistantTextIncludes(transcriptPath, ASSISTANT_STATUS_BOX_PREFIX) ||
89
89
  lastAssistantMessage?.includes(ASSISTANT_STATUS_BOX_PREFIX) === true);
90
90
  }
91
+ /** A handoff older than this no longer defers Stop, so a lost notification cannot disable the ritual. */
92
+ export const BACKGROUND_HANDOFF_MAX_AGE_MS = 30 * 60 * 1000;
93
+ /**
94
+ * Claude Code interleaves request-capture rows of several hundred KB each, so
95
+ * the 256KB status-box window can lose a handoff written a minute earlier.
96
+ */
97
+ const BACKGROUND_HANDOFF_TAIL_BYTES = 8 * 1024 * 1024;
98
+ /**
99
+ * Claude Code background tasks it moved off the foreground on its own (to
100
+ * deliver a mid-command user message) that have not yet posted a
101
+ * `<task-notification>`. Only the structured `toolUseResult` fields written by
102
+ * Claude Code open a handoff; the agent cannot forge them through tool output.
103
+ * Any later notification text naming the task closes it, so a spoofed
104
+ * notification can only make Stop stricter. A command the agent chose to
105
+ * background (`run_in_background`) or a Monitor opens nothing.
106
+ */
107
+ export function scanPendingBackgroundHandoffs(transcriptPath, nowMs = Date.now()) {
108
+ const readablePath = resolveTranscriptPath(transcriptPath);
109
+ if (!readablePath)
110
+ return [];
111
+ let size;
112
+ try {
113
+ size = statSync(readablePath).size;
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ const text = readRange(readablePath, Math.max(0, size - BACKGROUND_HANDOFF_TAIL_BYTES), size);
119
+ if (!text)
120
+ return [];
121
+ const opened = new Map();
122
+ for (const line of text.split("\n")) {
123
+ // Request-capture rows echo the same text; the row-type check below keeps
124
+ // only Claude Code's own top-level tool-result rows.
125
+ if (line.includes("backgroundedToDeliverMessage")) {
126
+ const row = parseTranscriptRow(line);
127
+ const result = row?.toolUseResult;
128
+ if (row?.type === "user" &&
129
+ result?.backgroundedToDeliverMessage === true &&
130
+ typeof result.backgroundTaskId === "string" &&
131
+ result.backgroundTaskId) {
132
+ opened.set(result.backgroundTaskId, Date.parse(String(row.timestamp)));
133
+ }
134
+ }
135
+ if (line.includes("task-notification")) {
136
+ for (const match of line.matchAll(/<task-id>([^<]+)<\/task-id>/g)) {
137
+ opened.delete(match[1]);
138
+ }
139
+ }
140
+ }
141
+ return [...opened]
142
+ .filter(([, at]) => Number.isFinite(at) && nowMs - at <= BACKGROUND_HANDOFF_MAX_AGE_MS)
143
+ .map(([id]) => id);
144
+ }
91
145
  /** Most recent user-visible assistant text across Claude Code and Codex JSONL. */
92
146
  export function scanLatestAssistantText(transcriptPath) {
93
147
  const readablePath = resolveTranscriptPath(transcriptPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.46.0",
3
+ "version": "0.46.1",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -18,7 +18,9 @@ import { spawnSync } from "node:child_process";
18
18
  import { existsSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { adapterFromPlatform as sharedAdapterFromPlatform } from "../../adapter.ts";
21
- import { recordLiveClaimChangeV3 } from "../live-authority-v3.ts";
21
+ import { resolveBinName } from "../../config.ts";
22
+ import { writeProducerDiagnosticV3 } from "../../events/v3/producers/intake.ts";
23
+ import { LiveCoordinationAuthorityV3Error, recordLiveClaimChangeV3 } from "../live-authority-v3.ts";
22
24
  import { readLiveCoordinationRows } from "../state/live-coordination-view.ts";
23
25
 
24
26
  const FRESHNESS_SECS = 600;
@@ -82,8 +84,15 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
82
84
  // prune the claim from their heartbeat and allow the edit. Skips when the
83
85
  // file doesn't exist (intent-to-create claim); that surface IS load-bearing.
84
86
  if (isFileCommittedClean(coordRoot, req.path)) {
85
- if (!releaseClaimThroughLedger(coordRoot, conflict.instance_id, req.path, req)) {
86
- return authorityUnavailable(req.path, "release the stale claim on");
87
+ const refused = releaseClaimThroughLedger(coordRoot, conflict.instance_id, req.path, req);
88
+ if (refused) {
89
+ return authorityUnavailable(
90
+ coordRoot,
91
+ req,
92
+ req.path,
93
+ "release the stale claim on",
94
+ refused,
95
+ );
87
96
  }
88
97
  // Fall through to ordering check + allow.
89
98
  } else {
@@ -146,8 +155,9 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
146
155
  // Every blocker is a finished (committed-clean) edit: prune them so they
147
156
  // stop constraining future acquisitions, then fall through to allow.
148
157
  for (const p of blockers) {
149
- if (!releaseClaimThroughLedger(coordRoot, req.instance_id, p, req)) {
150
- return authorityUnavailable(p, "release the completed claim on");
158
+ const refused = releaseClaimThroughLedger(coordRoot, req.instance_id, p, req);
159
+ if (refused) {
160
+ return authorityUnavailable(coordRoot, req, p, "release the completed claim on", refused);
151
161
  }
152
162
  }
153
163
  }
@@ -156,9 +166,8 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
156
166
  // Acquire the claim: atomic check-and-set. Adds req.path to my
157
167
  // files_touched if not already present.
158
168
  if (req.mode !== "read") {
159
- if (!acquireClaimThroughLedger(coordRoot, req)) {
160
- return authorityUnavailable(req.path, "acquire");
161
- }
169
+ const refused = acquireClaimThroughLedger(coordRoot, req);
170
+ if (refused) return authorityUnavailable(coordRoot, req, req.path, "acquire", refused);
162
171
  }
163
172
 
164
173
  return { allow: true, exit_code: 0, rule: "claim.pass" };
@@ -242,7 +251,8 @@ function isFileCommittedClean(coordRoot: string, relPath: string): boolean {
242
251
  }
243
252
  }
244
253
 
245
- function acquireClaimThroughLedger(coordRoot: string, req: ClaimRequest): boolean {
254
+ /** Returns null when the ledger recorded the claim, else the refusal reason. */
255
+ function acquireClaimThroughLedger(coordRoot: string, req: ClaimRequest): string | null {
246
256
  const owner = readPeers(coordRoot).find((peer) => peer.instance_id === req.instance_id);
247
257
  try {
248
258
  recordLiveClaimChangeV3({
@@ -254,9 +264,9 @@ function acquireClaimThroughLedger(coordRoot: string, req: ClaimRequest): boolea
254
264
  path: req.path,
255
265
  access: "write",
256
266
  });
257
- return true;
258
- } catch {
259
- return false;
267
+ return null;
268
+ } catch (error) {
269
+ return refusalReason(error);
260
270
  }
261
271
  }
262
272
 
@@ -265,7 +275,7 @@ function releaseClaimThroughLedger(
265
275
  subjectInstanceId: string,
266
276
  relPath: string,
267
277
  actor: ClaimRequest,
268
- ): boolean {
278
+ ): string | null {
269
279
  const owner = readPeers(coordRoot).find((peer) => peer.instance_id === actor.instance_id);
270
280
  try {
271
281
  recordLiveClaimChangeV3({
@@ -278,17 +288,39 @@ function releaseClaimThroughLedger(
278
288
  path: relPath,
279
289
  access: "write",
280
290
  });
281
- return true;
282
- } catch {
283
- return false;
291
+ return null;
292
+ } catch (error) {
293
+ return refusalReason(error);
284
294
  }
285
295
  }
286
296
 
287
- function authorityUnavailable(path: string, operation: string): VerdictResult {
297
+ function refusalReason(error: unknown): string {
298
+ if (error instanceof LiveCoordinationAuthorityV3Error) return error.reason;
299
+ return error instanceof Error ? error.message : String(error);
300
+ }
301
+
302
+ function authorityUnavailable(
303
+ coordRoot: string,
304
+ req: ClaimRequest,
305
+ path: string,
306
+ operation: string,
307
+ refusal: string,
308
+ ): VerdictResult {
309
+ // The ledger's own reason is the only evidence of why a healthy-looking
310
+ // session cannot claim; a generic message sent agents to diagnose the wrong
311
+ // layer, so keep the reason in the verdict and in a durable diagnostic.
312
+ writeProducerDiagnosticV3(coordRoot, "claim_authority_refused", {
313
+ instance_id: req.instance_id,
314
+ operation,
315
+ reason: refusal,
316
+ });
288
317
  return {
289
318
  allow: false,
290
319
  exit_code: 2,
291
320
  rule: "claim.authority_unavailable",
292
- reason: `Cannot safely ${operation} ${path}; V3 authority is unavailable.`,
321
+ reason:
322
+ `Cannot safely ${operation} ${path}; the coordination ledger refused the claim ` +
323
+ `(${refusal}). Run \`${resolveBinName(coordRoot)} agents health\` and retry; if health ` +
324
+ "is safe, the refusal reason above names the session state that blocked it.",
293
325
  };
294
326
  }
@@ -36,6 +36,12 @@ export interface StopHookRequest {
36
36
  session_name_observation_unavailable?: boolean;
37
37
  bypass?: boolean;
38
38
  workflow_child?: boolean;
39
+ /**
40
+ * Claude Code moved this session's foreground command into the background
41
+ * and has not yet reported it finished. Finalization cannot pass until it
42
+ * does, so status and reply-box demands wait for the notification turn.
43
+ */
44
+ pending_background_handoff?: boolean;
39
45
  }
40
46
 
41
47
  export const STOP_REMEDIATION_MARKER = "[harnery:stop-remediation";
@@ -170,6 +176,7 @@ export function evaluateStopHookV3Events(
170
176
  // Pure prose turns keep the human-visible acknowledgement but do not require
171
177
  // task or final-status mutations. This preserves the pre-V3 policy exactly.
172
178
  if (!usedTool) {
179
+ if (!ackPresent && req.pending_background_handoff) return backgroundHandoffDeferred();
173
180
  if (!ackPresent) return withCycleAnchor(ackBlock(), cycleAnchor);
174
181
  return {
175
182
  allow: true,
@@ -203,12 +210,32 @@ export function evaluateStopHookV3Events(
203
210
  }
204
211
  }
205
212
 
206
- const blocked = mergeStopBlocks(pending);
213
+ // A status check run now would refuse on the in-flight commit and a box
214
+ // cannot be issued without it, so bouncing only loops until the task ends.
215
+ // Task and naming evidence do not depend on the task and stay enforced.
216
+ const enforced = req.pending_background_handoff
217
+ ? pending.filter(
218
+ (block) => block.rule !== "stop-hook.rule_1_3" && block.rule !== "stop-hook.rule_2_3",
219
+ )
220
+ : pending;
221
+ const blocked = mergeStopBlocks(enforced);
207
222
  if (blocked) return withCycleAnchor(blocked, cycleAnchor);
223
+ if (enforced.length < pending.length) return backgroundHandoffDeferred();
208
224
 
209
225
  return { allow: true, exit_code: 0, rule: "stop-hook.pass" };
210
226
  }
211
227
 
228
+ function backgroundHandoffDeferred(): VerdictResult {
229
+ return {
230
+ allow: true,
231
+ exit_code: 0,
232
+ rule: "stop-hook.background_handoff_deferred",
233
+ reason:
234
+ "a command Claude Code moved to the background is still running; status and reply-box " +
235
+ "evidence are required on the turn its completion notification starts",
236
+ };
237
+ }
238
+
212
239
  function unconditionalVerdict(req: StopHookRequest): VerdictResult | undefined {
213
240
  if (req.bypass) {
214
241
  return {
@@ -139,6 +139,7 @@ import type { SessionNameDisplayInspection } from "./resolve/transcript.ts";
139
139
  import {
140
140
  inspectSessionNameDisplayImmediately,
141
141
  scanAssistantStatusBoxPresent,
142
+ scanPendingBackgroundHandoffs,
142
143
  scanStatusBoxPresent,
143
144
  scanTranscriptRuntime,
144
145
  } from "./resolve/transcript.ts";
@@ -1506,6 +1507,18 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
1506
1507
  logError(coordRoot, err, { phase: "stop-presence" });
1507
1508
  }
1508
1509
 
1510
+ // Claude Code may move a running foreground command (often the agent's own
1511
+ // commit and push) into the background to deliver a user message. Until it
1512
+ // reports back, finalization cannot pass, so the verdict defers the status
1513
+ // demands instead of bouncing the agent in a loop.
1514
+ const pendingBackgroundHandoffs =
1515
+ adapter === "claude-code"
1516
+ ? scanPendingBackgroundHandoffs(
1517
+ payload?.transcript_path ??
1518
+ behavior.discoverTranscript?.(payload?.session_id ?? sessionId),
1519
+ )
1520
+ : [];
1521
+
1509
1522
  // Stop verdict (V3 ritual + task/status gate). Direct in-process call: the
1510
1523
  // rule lives in Harnery. agent-hook already emitted this turn.completed
1511
1524
  // with privacy-safe ritual observations, so the evidence is in the stream.
@@ -1519,7 +1532,15 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
1519
1532
  session_name_observation_unavailable: typeof data.session_name_unavailable_reason === "string",
1520
1533
  bypass: coordEnv("AGENT_COORD_BYPASS_STOP") === "1",
1521
1534
  workflow_child: coordEnv("WORKFLOW_CHILD") === "1",
1535
+ pending_background_handoff: pendingBackgroundHandoffs.length > 0,
1522
1536
  });
1537
+ if (verdict.rule === "stop-hook.background_handoff_deferred") {
1538
+ appendDebug(coordRoot, {
1539
+ ...debugBase,
1540
+ effect: "stop-deferred-background-handoff",
1541
+ background_task_ids: pendingBackgroundHandoffs,
1542
+ });
1543
+ }
1523
1544
  if (!verdict.allow) {
1524
1545
  // Backstop for sessions whose evidence can never land (e.g. a headless
1525
1546
  // child whose status command resolves to a different owner): after
@@ -1553,6 +1574,12 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
1553
1574
  // until the adapter has committed turn.completed above. Reconcile only
1554
1575
  // when such a request exists; any later real work cancels it in the
1555
1576
  // finalizer instead of being terminated underneath the agent.
1577
+ // A deferred stop proves nothing about finalization; an explicit end must
1578
+ // wait for the turn that reports the background task's result.
1579
+ if (verdict.rule === "stop-hook.background_handoff_deferred") {
1580
+ if (behavior.runtimeContextRetry) scheduleRuntimeContextRetry(coordRoot, adapter, sessionId);
1581
+ return undefined;
1582
+ }
1556
1583
  try {
1557
1584
  const { hasPendingExplicitSessionEndV3, reconcileSessionFinalizationV3 } = await import(
1558
1585
  "../agents/session-finalizer-v3.ts"
@@ -94,6 +94,67 @@ export function scanAssistantStatusBoxPresent(
94
94
  );
95
95
  }
96
96
 
97
+ /** A handoff older than this no longer defers Stop, so a lost notification cannot disable the ritual. */
98
+ export const BACKGROUND_HANDOFF_MAX_AGE_MS = 30 * 60 * 1000;
99
+
100
+ /**
101
+ * Claude Code interleaves request-capture rows of several hundred KB each, so
102
+ * the 256KB status-box window can lose a handoff written a minute earlier.
103
+ */
104
+ const BACKGROUND_HANDOFF_TAIL_BYTES = 8 * 1024 * 1024;
105
+
106
+ /**
107
+ * Claude Code background tasks it moved off the foreground on its own (to
108
+ * deliver a mid-command user message) that have not yet posted a
109
+ * `<task-notification>`. Only the structured `toolUseResult` fields written by
110
+ * Claude Code open a handoff; the agent cannot forge them through tool output.
111
+ * Any later notification text naming the task closes it, so a spoofed
112
+ * notification can only make Stop stricter. A command the agent chose to
113
+ * background (`run_in_background`) or a Monitor opens nothing.
114
+ */
115
+ export function scanPendingBackgroundHandoffs(
116
+ transcriptPath: string | undefined,
117
+ nowMs: number = Date.now(),
118
+ ): string[] {
119
+ const readablePath = resolveTranscriptPath(transcriptPath);
120
+ if (!readablePath) return [];
121
+ let size: number;
122
+ try {
123
+ size = statSync(readablePath).size;
124
+ } catch {
125
+ return [];
126
+ }
127
+ const text = readRange(readablePath, Math.max(0, size - BACKGROUND_HANDOFF_TAIL_BYTES), size);
128
+ if (!text) return [];
129
+ const opened = new Map<string, number>();
130
+ for (const line of text.split("\n")) {
131
+ // Request-capture rows echo the same text; the row-type check below keeps
132
+ // only Claude Code's own top-level tool-result rows.
133
+ if (line.includes("backgroundedToDeliverMessage")) {
134
+ const row = parseTranscriptRow(line);
135
+ const result = row?.toolUseResult as
136
+ | { backgroundTaskId?: unknown; backgroundedToDeliverMessage?: unknown }
137
+ | undefined;
138
+ if (
139
+ row?.type === "user" &&
140
+ result?.backgroundedToDeliverMessage === true &&
141
+ typeof result.backgroundTaskId === "string" &&
142
+ result.backgroundTaskId
143
+ ) {
144
+ opened.set(result.backgroundTaskId, Date.parse(String(row.timestamp)));
145
+ }
146
+ }
147
+ if (line.includes("task-notification")) {
148
+ for (const match of line.matchAll(/<task-id>([^<]+)<\/task-id>/g)) {
149
+ opened.delete(match[1]!);
150
+ }
151
+ }
152
+ }
153
+ return [...opened]
154
+ .filter(([, at]) => Number.isFinite(at) && nowMs - at <= BACKGROUND_HANDOFF_MAX_AGE_MS)
155
+ .map(([id]) => id);
156
+ }
157
+
97
158
  /** Most recent user-visible assistant text across Claude Code and Codex JSONL. */
98
159
  export function scanLatestAssistantText(transcriptPath: string | undefined): string | undefined {
99
160
  const readablePath = resolveTranscriptPath(transcriptPath);