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.
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +36 -16
- package/dist/core/agents/rules/stop-hook.d.ts +6 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +20 -1
- package/dist/core/hooks/cli.js +24 -1
- package/dist/core/hooks/resolve/transcript.d.ts +12 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +54 -0
- package/package.json +1 -1
- package/src/core/agents/rules/claim-conflict.ts +50 -18
- package/src/core/agents/rules/stop-hook.ts +28 -1
- package/src/core/hooks/cli.ts +27 -0
- package/src/core/hooks/resolve/transcript.ts +61 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;
|
|
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 {
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
225
|
+
return null;
|
|
221
226
|
}
|
|
222
|
-
catch {
|
|
223
|
-
return
|
|
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
|
|
244
|
+
return null;
|
|
240
245
|
}
|
|
241
|
-
catch {
|
|
242
|
-
return
|
|
246
|
+
catch (error) {
|
|
247
|
+
return refusalReason(error);
|
|
243
248
|
}
|
|
244
249
|
}
|
|
245
|
-
function
|
|
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};
|
|
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;
|
|
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
|
-
|
|
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 {
|
package/dist/core/hooks/cli.js
CHANGED
|
@@ -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
|
@@ -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 {
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
258
|
-
} catch {
|
|
259
|
-
return
|
|
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
|
-
):
|
|
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
|
|
282
|
-
} catch {
|
|
283
|
-
return
|
|
291
|
+
return null;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return refusalReason(error);
|
|
284
294
|
}
|
|
285
295
|
}
|
|
286
296
|
|
|
287
|
-
function
|
|
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:
|
|
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
|
-
|
|
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 {
|
package/src/core/hooks/cli.ts
CHANGED
|
@@ -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);
|