harnery 0.31.4 → 0.31.6

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.
@@ -162,7 +162,10 @@ export declare function resolveOwnerByPidmap(root: string): {
162
162
  * `instance_id`, or null if there's no session-id env var or no live heartbeat
163
163
  * carries it. "Live" reuses the same 10-minute freshness window the singleton
164
164
  * fallback applies, so a stale heartbeat from a prior session of the same id
165
- * doesn't resolve.
165
+ * doesn't resolve. When legacy Cursor Glass state contains both the canonical
166
+ * bare id and its raw `bc-` alias, candidate order wins first; ties prefer a
167
+ * named heartbeat, then the newest heartbeat, with instance id as a stable
168
+ * final tie-breaker. Filesystem enumeration order never decides identity.
166
169
  *
167
170
  * Exported for unit testing with an injectable root.
168
171
  */
@@ -1 +1 @@
1
- {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"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"}
@@ -482,7 +482,10 @@ function shouldPreferSessionEnv() {
482
482
  * `instance_id`, or null if there's no session-id env var or no live heartbeat
483
483
  * carries it. "Live" reuses the same 10-minute freshness window the singleton
484
484
  * fallback applies, so a stale heartbeat from a prior session of the same id
485
- * doesn't resolve.
485
+ * doesn't resolve. When legacy Cursor Glass state contains both the canonical
486
+ * bare id and its raw `bc-` alias, candidate order wins first; ties prefer a
487
+ * named heartbeat, then the newest heartbeat, with instance id as a stable
488
+ * final tie-breaker. Filesystem enumeration order never decides identity.
486
489
  *
487
490
  * Exported for unit testing with an injectable root.
488
491
  */
@@ -502,24 +505,38 @@ export function resolveOwnerBySessionEnv(root) {
502
505
  catch {
503
506
  return null;
504
507
  }
508
+ const matches = [];
505
509
  for (const file of files) {
506
510
  if (!file.endsWith(".json"))
507
511
  continue;
508
512
  try {
509
513
  const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
510
- if (!parsed || !sessionIds.includes(parsed.session_id))
514
+ if (!parsed)
515
+ continue;
516
+ const sessionPreference = sessionIds.indexOf(parsed.session_id);
517
+ if (sessionPreference === -1)
511
518
  continue;
512
519
  if (typeof parsed.instance_id !== "string")
513
520
  continue;
514
521
  const ts = Date.parse(parsed.last_heartbeat);
515
- if (Number.isFinite(ts) && ts >= cutoffMs)
516
- return parsed.instance_id;
522
+ if (!Number.isFinite(ts) || ts < cutoffMs)
523
+ continue;
524
+ matches.push({
525
+ instanceId: parsed.instance_id,
526
+ sessionPreference,
527
+ hasName: typeof parsed.name === "string" && parsed.name.trim().length > 0,
528
+ lastHeartbeatMs: ts,
529
+ });
517
530
  }
518
531
  catch {
519
532
  // skip malformed
520
533
  }
521
534
  }
522
- return null;
535
+ matches.sort((a, b) => a.sessionPreference - b.sessionPreference ||
536
+ Number(b.hasName) - Number(a.hasName) ||
537
+ b.lastHeartbeatMs - a.lastHeartbeatMs ||
538
+ a.instanceId.localeCompare(b.instanceId));
539
+ return matches[0]?.instanceId ?? null;
523
540
  }
524
541
  /**
525
542
  * Return the instance_id of the sole live agent in this coord root, or null
@@ -15,6 +15,12 @@
15
15
  * Pure-prose-turn exemption: when zero `tool.pre_use` events fire in
16
16
  * the current turn, rules 1/3 and 3/3 do not apply. Rule 2/3 still applies
17
17
  * as a user-visible mobile cue.
18
+ *
19
+ * Remediation continuity: an adapter whose Stop channel re-prompts by
20
+ * submitting a new user message (Cursor's `followup_message`) opens a fresh
21
+ * turn to repair the previous one. Such a turn inherits the window of the turn
22
+ * it repairs, so ritual events accumulate instead of alternating. See
23
+ * `resolveTurnStartMs`.
18
24
  */
19
25
  export type { VerdictResult } from "./verdict.js";
20
26
  import type { VerdictResult } from "./verdict.js";
@@ -43,6 +49,16 @@ export interface StopHookRequest {
43
49
  * event capture on: the child stays visible to peers and the coord layer. */
44
50
  workflow_child?: boolean;
45
51
  }
52
+ /**
53
+ * Machine marker that opens a Stop-remediation message. Written by
54
+ * `hooks/adapter/output.ts` on the adapters whose Stop channel is a new user
55
+ * message, and read back here off `user_prompt.submit.data.prompt_text` to
56
+ * recognize a turn Harnery itself opened.
57
+ *
58
+ * Leading position is deliberate: `prompt_text` is clamped when recorded, so a
59
+ * trailing marker could be truncated away on a long reason.
60
+ */
61
+ export declare const STOP_REMEDIATION_MARKER = "[harnery:stop-remediation";
46
62
  /**
47
63
  * Evaluate the Stop-hook verdict. Returns the first failing rule (or allow
48
64
  * when all three pass). Soft-fails open when the event stream isn't
@@ -1 +1 @@
1
- {"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAMH,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAclD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;4EACwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,yFAAyF;IACzF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;iFAM6E;IAC7E,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AA+BD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,eAAe,GAAG,aAAa,CAmHvF"}
1
+ {"version":3,"file":"stop-hook.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/stop-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAclD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;4EACwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,WAAW,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,yFAAyF;IACzF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;iFAM6E;IAC7E,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AA+BD;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,8BAA8B,CAAC;AAwEnE;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,eAAe,GAAG,aAAa,CAqHvF"}
@@ -15,6 +15,12 @@
15
15
  * Pure-prose-turn exemption: when zero `tool.pre_use` events fire in
16
16
  * the current turn, rules 1/3 and 3/3 do not apply. Rule 2/3 still applies
17
17
  * as a user-visible mobile cue.
18
+ *
19
+ * Remediation continuity: an adapter whose Stop channel re-prompts by
20
+ * submitting a new user message (Cursor's `followup_message`) opens a fresh
21
+ * turn to repair the previous one. Such a turn inherits the window of the turn
22
+ * it repairs, so ritual events accumulate instead of alternating. See
23
+ * `resolveTurnStartMs`.
18
24
  */
19
25
  import { existsSync, readFileSync } from "node:fs";
20
26
  import { join } from "node:path";
@@ -23,6 +29,80 @@ function ackSignalFor(adapter) {
23
29
  return adapter === "cursor" ? "status_checked" : "status_box_present";
24
30
  }
25
31
  const RECENT_EVENT_WINDOW_LINES = 5_000;
32
+ /**
33
+ * Machine marker that opens a Stop-remediation message. Written by
34
+ * `hooks/adapter/output.ts` on the adapters whose Stop channel is a new user
35
+ * message, and read back here off `user_prompt.submit.data.prompt_text` to
36
+ * recognize a turn Harnery itself opened.
37
+ *
38
+ * Leading position is deliberate: `prompt_text` is clamped when recorded, so a
39
+ * trailing marker could be truncated away on a long reason.
40
+ */
41
+ export const STOP_REMEDIATION_MARKER = "[harnery:stop-remediation";
42
+ /**
43
+ * The pre-marker message shape, still recognized on purpose.
44
+ *
45
+ * A followup already sitting in the stream was written by whichever build was
46
+ * running when it fired. An agent whose session spans an upgrade would otherwise
47
+ * keep alternating until the first new-format followup appeared, which is the
48
+ * exact failure this change removes. Recognizing the older trailing form closes
49
+ * that rollout window at no cost: it only ever appears in a `prompt_text`
50
+ * because Harnery put it there.
51
+ */
52
+ const STOP_REMEDIATION_LEGACY_MARKER = "[agent-hook stop]: rule=";
53
+ /** How far back the remediation walk may travel. Adapters cap their own
54
+ * followup chains (Cursor's `loop_limit`), and each prompt can appear once per
55
+ * observing adapter, so this only guards a pathological stream. */
56
+ const MAX_REMEDIATION_WALK_BACK = 16;
57
+ function isStopRemediationPrompt(event) {
58
+ const text = event.data.prompt_text;
59
+ if (typeof text !== "string")
60
+ return false;
61
+ return text.includes(STOP_REMEDIATION_MARKER) || text.includes(STOP_REMEDIATION_LEGACY_MARKER);
62
+ }
63
+ /**
64
+ * Start of the window this Stop verdict judges.
65
+ *
66
+ * Normally the most recent `user_prompt.submit`. The exception is the
67
+ * remediation chain: Cursor does not continue a turn on Stop, it auto-submits
68
+ * our message as a new user turn (`followup_message`). Scoping the verdict to
69
+ * that new turn alone makes the ritual unsatisfiable in one pass, because
70
+ * remediation itself runs a tool, so the repair turn needs every rule again
71
+ * while the signal from the previous turn sits just outside the window. Running
72
+ * `set-task` then clears rule 3/3 and fails rule 1/3, running `status` clears
73
+ * 1/3 and fails 3/3, and the pair alternates until the adapter's loop cap.
74
+ *
75
+ * Anchoring the window at the last prompt Harnery did not author makes progress
76
+ * monotonic: each repair adds a signal and none are lost, so the chain ends.
77
+ * A genuine human prompt always anchors a fresh window, so this cannot be used
78
+ * to inherit ritual credit across real turns.
79
+ */
80
+ function resolveTurnStartMs(ownerEvents, nowMs, fallbackMs) {
81
+ // Honor the cutoff: `now_ms` marks what is relevant yet, so a prompt after it
82
+ // cannot anchor this turn. In production the cutoff is the clock and nothing
83
+ // follows it, but a replay over recorded history passes a past cutoff, and an
84
+ // unclamped search would anchor on a later turn and hand back an empty window.
85
+ const prompts = ownerEvents.filter((e) => {
86
+ if (e.event_type !== "user_prompt.submit")
87
+ return false;
88
+ const t = Date.parse(e.ts);
89
+ return Number.isFinite(t) && t <= nowMs;
90
+ });
91
+ if (prompts.length === 0)
92
+ return fallbackMs;
93
+ let index = prompts.length - 1;
94
+ let hops = 0;
95
+ while (index > 0 && hops < MAX_REMEDIATION_WALK_BACK) {
96
+ const prompt = prompts[index];
97
+ if (!prompt || !isStopRemediationPrompt(prompt))
98
+ break;
99
+ index -= 1;
100
+ hops += 1;
101
+ }
102
+ const anchor = prompts[index];
103
+ const parsed = anchor ? Date.parse(anchor.ts) : Number.NaN;
104
+ return Number.isFinite(parsed) ? parsed : fallbackMs;
105
+ }
26
106
  /**
27
107
  * Evaluate the Stop-hook verdict. Returns the first failing rule (or allow
28
108
  * when all three pass). Soft-fails open when the event stream isn't
@@ -81,17 +161,19 @@ export function evaluateStopHook(coordRoot, req) {
81
161
  };
82
162
  }
83
163
  // Turn window: from the most recent user_prompt.submit (this owner) up to
84
- // either the explicit now_ms or the last event we see. Fall back to a
164
+ // either the explicit now_ms or the last event we see, walking back over any
165
+ // Harnery-authored Stop-remediation prompts so a repair turn is judged
166
+ // together with the turn it repairs (see `resolveTurnStartMs`). Fall back to a
85
167
  // 5-minute window when no user_prompt.submit is in scope (fresh wiring,
86
168
  // out-of-window prompt, etc.) so a stale stream doesn't silently pass.
87
169
  const nowMs = req.now_ms ?? Date.now();
88
- const lastUserPrompt = [...ownerEvents]
89
- .reverse()
90
- .find((e) => e.event_type === "user_prompt.submit");
91
- const startMs = lastUserPrompt ? Date.parse(lastUserPrompt.ts) : nowMs - 5 * 60 * 1000;
170
+ const startMs = req.turn_window
171
+ ? req.turn_window.start_ms
172
+ : resolveTurnStartMs(ownerEvents, nowMs, nowMs - 5 * 60 * 1000);
173
+ const endMs = req.turn_window ? req.turn_window.end_ms : nowMs;
92
174
  const inTurn = ownerEvents.filter((e) => {
93
175
  const t = Date.parse(e.ts);
94
- return Number.isFinite(t) && t >= startMs && t <= nowMs;
176
+ return Number.isFinite(t) && t >= startMs && t <= endMs;
95
177
  });
96
178
  const toolPreUseInTurn = inTurn.some((e) => e.event_type === "tool.pre_use");
97
179
  const statusChecked = inTurn.some((e) => e.event_type === "state.status_checked");
@@ -1 +1 @@
1
- {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/adapter/output.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,MAAM,WAAW,GAAG,cAAc,GAAG,kBAAkB,GAAG,eAAe,CAAC;AAEhF,+EAA+E;AAC/E,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAIpF;AAED,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAI/D;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,CAAC,GAAG,CAAC,CAYjG"}
1
+ {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/adapter/output.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,MAAM,WAAW,GAAG,cAAc,GAAG,kBAAkB,GAAG,eAAe,CAAC;AAEhF,+EAA+E;AAC/E,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAIpF;AAED,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAI/D;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,CAAC,GAAG,CAAC,CAuBjG"}
@@ -17,6 +17,8 @@
17
17
  * Every helper writes to process.stdout + newline-terminates so callers can
18
18
  * fire-and-forget. Empty text → no-op (no JSON written).
19
19
  */
20
+ import { STOP_REMEDIATION_MARKER } from "../../agents/rules/stop-hook.js";
21
+ import { resolveBinName } from "../../config.js";
20
22
  /** Emit a context-injection (peer table, wiring check, council pending, …). */
21
23
  export function emitContext(adapter, event, text) {
22
24
  if (!text || text.length === 0)
@@ -56,12 +58,25 @@ export function emitDeny(adapter, reason) {
56
58
  export function emitStopBlock(adapter, verdict) {
57
59
  if (adapter === "codex")
58
60
  return 0;
59
- const message = `${verdict.reason ?? "End-of-turn coordination ritual incomplete."}\n[agent-hook stop]: rule=${verdict.rule}`;
61
+ const reason = verdict.reason ?? "End-of-turn coordination ritual incomplete.";
60
62
  if (adapter === "cursor") {
63
+ // The marker leads the message so the Stop verdict can recognize the turn
64
+ // Cursor opens from it (`STOP_REMEDIATION_MARKER`), and so the operator can
65
+ // see in chat that the message came from Harnery rather than from them.
66
+ // Both commands are named because one message that repairs the whole ritual
67
+ // ends the chain in a single followup; the failing rule is context.
68
+ const bin = resolveBinName();
69
+ const message = [
70
+ `${STOP_REMEDIATION_MARKER} rule=${verdict.rule}]`,
71
+ reason,
72
+ `Repair the ritual in this turn: run \`${bin} agents set-task "<short focus>"\` and then \`${bin} agents status\` as your last tool call.`,
73
+ ].join("\n");
61
74
  process.stdout.write(`${JSON.stringify({ followup_message: message })}\n`);
62
75
  return 0;
63
76
  }
64
- process.stderr.write(`${message}\n`);
77
+ // Claude Code continues the SAME turn on exit 2, so the single failing rule is
78
+ // both accurate and sufficient there. Left unchanged deliberately.
79
+ process.stderr.write(`${reason}\n[agent-hook stop]: rule=${verdict.rule}\n`);
65
80
  return 2;
66
81
  }
67
82
  function buildContextJson(adapter, event, text) {
@@ -40,7 +40,7 @@ export interface ParsedPayload {
40
40
  * Parse the raw stdin payload string for any adapter. Returns null when JSON
41
41
  * parse fails (Cursor occasionally fires hooks with no payload).
42
42
  */
43
- export declare function parsePayload(raw: string, _adapter: Adapter): ParsedPayload | null;
43
+ export declare function parsePayload(raw: string, adapter: Adapter): ParsedPayload | null;
44
44
  /**
45
45
  * Pull the bash command string out of a Bash/Shell tool_input. Returns
46
46
  * undefined for non-shell tools.
@@ -1 +1 @@
1
- {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/adapter/parse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CAkCjF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,SAAS,EAAE,OAAO,GACjB,MAAM,GAAG,SAAS,CASpB;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAO7E;AAmBD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAChB;IAAE,UAAU,EAAE,mBAAmB,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAiCjE;AAED,MAAM,MAAM,mBAAmB,GAC3B,eAAe,GACf,aAAa,GACb,oBAAoB,GACpB,WAAW,GACX,gBAAgB,GAChB,eAAe,GACf,cAAc,GACd,eAAe,GACf,uBAAuB,GACvB,4BAA4B,GAC5B,8BAA8B,CAAC"}
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/adapter/parse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CA+ChF;AAQD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,SAAS,EAAE,OAAO,GACjB,MAAM,GAAG,SAAS,CASpB;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAO7E;AAmBD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAChB;IAAE,UAAU,EAAE,mBAAmB,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAiCjE;AAED,MAAM,MAAM,mBAAmB,GAC3B,eAAe,GACf,aAAa,GACb,oBAAoB,GACpB,WAAW,GACX,gBAAgB,GAChB,eAAe,GACf,cAAc,GACd,eAAe,GACf,uBAAuB,GACvB,4BAA4B,GAC5B,8BAA8B,CAAC"}
@@ -13,7 +13,7 @@
13
13
  * Parse the raw stdin payload string for any adapter. Returns null when JSON
14
14
  * parse fails (Cursor occasionally fires hooks with no payload).
15
15
  */
16
- export function parsePayload(raw, _adapter) {
16
+ export function parsePayload(raw, adapter) {
17
17
  if (!raw || raw.trim().length === 0)
18
18
  return null;
19
19
  let json;
@@ -23,13 +23,24 @@ export function parsePayload(raw, _adapter) {
23
23
  catch {
24
24
  return null;
25
25
  }
26
+ const sessionId = normalizeSessionId(adapter, pickStr(json, "session_id"));
27
+ const conversationId = normalizeSessionId(adapter, pickStr(json, "conversation_id"));
28
+ const parentSessionId = normalizeSessionId(adapter, pickStr(json, "parent_session_id"));
29
+ const normalizedRaw = adapter === "cursor"
30
+ ? {
31
+ ...json,
32
+ ...(sessionId ? { session_id: sessionId } : {}),
33
+ ...(conversationId ? { conversation_id: conversationId } : {}),
34
+ ...(parentSessionId ? { parent_session_id: parentSessionId } : {}),
35
+ }
36
+ : json;
26
37
  return {
27
38
  hook_event_name: pickStr(json, "hook_event_name"),
28
- session_id: pickStr(json, "session_id"),
39
+ session_id: sessionId,
29
40
  agent_id: pickStr(json, "agent_id"),
30
41
  subagent_id: pickStr(json, "subagent_id"),
31
- conversation_id: pickStr(json, "conversation_id"),
32
- parent_session_id: pickStr(json, "parent_session_id"),
42
+ conversation_id: conversationId,
43
+ parent_session_id: parentSessionId,
33
44
  turn_id: pickStr(json, "turn_id"),
34
45
  parent_turn_id: pickStr(json, "parent_turn_id"),
35
46
  transcript_path: pickStr(json, "transcript_path"),
@@ -46,9 +57,15 @@ export function parsePayload(raw, _adapter) {
46
57
  clean_exit: pickBool(json, "clean_exit"),
47
58
  exit_status: pickStr(json, "exit_status"),
48
59
  reason: pickStr(json, "reason"),
49
- raw: json,
60
+ raw: normalizedRaw,
50
61
  };
51
62
  }
63
+ /** Cursor Glass prefixes conversation ids with `bc-`; coordination uses the bare id everywhere. */
64
+ function normalizeSessionId(adapter, value) {
65
+ if (adapter !== "cursor" || !value?.startsWith("bc-") || value.length <= 3)
66
+ return value;
67
+ return value.slice(3);
68
+ }
52
69
  /**
53
70
  * Pull the bash command string out of a Bash/Shell tool_input. Returns
54
71
  * undefined for non-shell tools.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.31.4",
3
+ "version": "0.31.6",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -528,7 +528,10 @@ function shouldPreferSessionEnv(): boolean {
528
528
  * `instance_id`, or null if there's no session-id env var or no live heartbeat
529
529
  * carries it. "Live" reuses the same 10-minute freshness window the singleton
530
530
  * fallback applies, so a stale heartbeat from a prior session of the same id
531
- * doesn't resolve.
531
+ * doesn't resolve. When legacy Cursor Glass state contains both the canonical
532
+ * bare id and its raw `bc-` alias, candidate order wins first; ties prefer a
533
+ * named heartbeat, then the newest heartbeat, with instance id as a stable
534
+ * final tie-breaker. Filesystem enumeration order never decides identity.
532
535
  *
533
536
  * Exported for unit testing with an injectable root.
534
537
  */
@@ -546,19 +549,40 @@ export function resolveOwnerBySessionEnv(root: string): string | null {
546
549
  } catch {
547
550
  return null;
548
551
  }
552
+ const matches: Array<{
553
+ instanceId: string;
554
+ sessionPreference: number;
555
+ hasName: boolean;
556
+ lastHeartbeatMs: number;
557
+ }> = [];
549
558
  for (const file of files) {
550
559
  if (!file.endsWith(".json")) continue;
551
560
  try {
552
561
  const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
553
- if (!parsed || !sessionIds.includes(parsed.session_id)) continue;
562
+ if (!parsed) continue;
563
+ const sessionPreference = sessionIds.indexOf(parsed.session_id);
564
+ if (sessionPreference === -1) continue;
554
565
  if (typeof parsed.instance_id !== "string") continue;
555
566
  const ts = Date.parse(parsed.last_heartbeat);
556
- if (Number.isFinite(ts) && ts >= cutoffMs) return parsed.instance_id;
567
+ if (!Number.isFinite(ts) || ts < cutoffMs) continue;
568
+ matches.push({
569
+ instanceId: parsed.instance_id,
570
+ sessionPreference,
571
+ hasName: typeof parsed.name === "string" && parsed.name.trim().length > 0,
572
+ lastHeartbeatMs: ts,
573
+ });
557
574
  } catch {
558
575
  // skip malformed
559
576
  }
560
577
  }
561
- return null;
578
+ matches.sort(
579
+ (a, b) =>
580
+ a.sessionPreference - b.sessionPreference ||
581
+ Number(b.hasName) - Number(a.hasName) ||
582
+ b.lastHeartbeatMs - a.lastHeartbeatMs ||
583
+ a.instanceId.localeCompare(b.instanceId),
584
+ );
585
+ return matches[0]?.instanceId ?? null;
562
586
  }
563
587
 
564
588
  /**
@@ -15,6 +15,12 @@
15
15
  * Pure-prose-turn exemption: when zero `tool.pre_use` events fire in
16
16
  * the current turn, rules 1/3 and 3/3 do not apply. Rule 2/3 still applies
17
17
  * as a user-visible mobile cue.
18
+ *
19
+ * Remediation continuity: an adapter whose Stop channel re-prompts by
20
+ * submitting a new user message (Cursor's `followup_message`) opens a fresh
21
+ * turn to repair the previous one. Such a turn inherits the window of the turn
22
+ * it repairs, so ritual events accumulate instead of alternating. See
23
+ * `resolveTurnStartMs`.
18
24
  */
19
25
 
20
26
  import { existsSync, readFileSync } from "node:fs";
@@ -89,6 +95,87 @@ function ackSignalFor(adapter?: string): AckSignal {
89
95
 
90
96
  const RECENT_EVENT_WINDOW_LINES = 5_000;
91
97
 
98
+ /**
99
+ * Machine marker that opens a Stop-remediation message. Written by
100
+ * `hooks/adapter/output.ts` on the adapters whose Stop channel is a new user
101
+ * message, and read back here off `user_prompt.submit.data.prompt_text` to
102
+ * recognize a turn Harnery itself opened.
103
+ *
104
+ * Leading position is deliberate: `prompt_text` is clamped when recorded, so a
105
+ * trailing marker could be truncated away on a long reason.
106
+ */
107
+ export const STOP_REMEDIATION_MARKER = "[harnery:stop-remediation";
108
+
109
+ /**
110
+ * The pre-marker message shape, still recognized on purpose.
111
+ *
112
+ * A followup already sitting in the stream was written by whichever build was
113
+ * running when it fired. An agent whose session spans an upgrade would otherwise
114
+ * keep alternating until the first new-format followup appeared, which is the
115
+ * exact failure this change removes. Recognizing the older trailing form closes
116
+ * that rollout window at no cost: it only ever appears in a `prompt_text`
117
+ * because Harnery put it there.
118
+ */
119
+ const STOP_REMEDIATION_LEGACY_MARKER = "[agent-hook stop]: rule=";
120
+
121
+ /** How far back the remediation walk may travel. Adapters cap their own
122
+ * followup chains (Cursor's `loop_limit`), and each prompt can appear once per
123
+ * observing adapter, so this only guards a pathological stream. */
124
+ const MAX_REMEDIATION_WALK_BACK = 16;
125
+
126
+ function isStopRemediationPrompt(event: CanonicalEvent): boolean {
127
+ const text = event.data.prompt_text;
128
+ if (typeof text !== "string") return false;
129
+ return text.includes(STOP_REMEDIATION_MARKER) || text.includes(STOP_REMEDIATION_LEGACY_MARKER);
130
+ }
131
+
132
+ /**
133
+ * Start of the window this Stop verdict judges.
134
+ *
135
+ * Normally the most recent `user_prompt.submit`. The exception is the
136
+ * remediation chain: Cursor does not continue a turn on Stop, it auto-submits
137
+ * our message as a new user turn (`followup_message`). Scoping the verdict to
138
+ * that new turn alone makes the ritual unsatisfiable in one pass, because
139
+ * remediation itself runs a tool, so the repair turn needs every rule again
140
+ * while the signal from the previous turn sits just outside the window. Running
141
+ * `set-task` then clears rule 3/3 and fails rule 1/3, running `status` clears
142
+ * 1/3 and fails 3/3, and the pair alternates until the adapter's loop cap.
143
+ *
144
+ * Anchoring the window at the last prompt Harnery did not author makes progress
145
+ * monotonic: each repair adds a signal and none are lost, so the chain ends.
146
+ * A genuine human prompt always anchors a fresh window, so this cannot be used
147
+ * to inherit ritual credit across real turns.
148
+ */
149
+ function resolveTurnStartMs(
150
+ ownerEvents: CanonicalEvent[],
151
+ nowMs: number,
152
+ fallbackMs: number,
153
+ ): number {
154
+ // Honor the cutoff: `now_ms` marks what is relevant yet, so a prompt after it
155
+ // cannot anchor this turn. In production the cutoff is the clock and nothing
156
+ // follows it, but a replay over recorded history passes a past cutoff, and an
157
+ // unclamped search would anchor on a later turn and hand back an empty window.
158
+ const prompts = ownerEvents.filter((e) => {
159
+ if (e.event_type !== "user_prompt.submit") return false;
160
+ const t = Date.parse(e.ts);
161
+ return Number.isFinite(t) && t <= nowMs;
162
+ });
163
+ if (prompts.length === 0) return fallbackMs;
164
+
165
+ let index = prompts.length - 1;
166
+ let hops = 0;
167
+ while (index > 0 && hops < MAX_REMEDIATION_WALK_BACK) {
168
+ const prompt = prompts[index];
169
+ if (!prompt || !isStopRemediationPrompt(prompt)) break;
170
+ index -= 1;
171
+ hops += 1;
172
+ }
173
+
174
+ const anchor = prompts[index];
175
+ const parsed = anchor ? Date.parse(anchor.ts) : Number.NaN;
176
+ return Number.isFinite(parsed) ? parsed : fallbackMs;
177
+ }
178
+
92
179
  /**
93
180
  * Evaluate the Stop-hook verdict. Returns the first failing rule (or allow
94
181
  * when all three pass). Soft-fails open when the event stream isn't
@@ -151,18 +238,20 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
151
238
  }
152
239
 
153
240
  // Turn window: from the most recent user_prompt.submit (this owner) up to
154
- // either the explicit now_ms or the last event we see. Fall back to a
241
+ // either the explicit now_ms or the last event we see, walking back over any
242
+ // Harnery-authored Stop-remediation prompts so a repair turn is judged
243
+ // together with the turn it repairs (see `resolveTurnStartMs`). Fall back to a
155
244
  // 5-minute window when no user_prompt.submit is in scope (fresh wiring,
156
245
  // out-of-window prompt, etc.) so a stale stream doesn't silently pass.
157
246
  const nowMs = req.now_ms ?? Date.now();
158
- const lastUserPrompt = [...ownerEvents]
159
- .reverse()
160
- .find((e) => e.event_type === "user_prompt.submit");
161
- const startMs = lastUserPrompt ? Date.parse(lastUserPrompt.ts) : nowMs - 5 * 60 * 1000;
247
+ const startMs = req.turn_window
248
+ ? req.turn_window.start_ms
249
+ : resolveTurnStartMs(ownerEvents, nowMs, nowMs - 5 * 60 * 1000);
250
+ const endMs = req.turn_window ? req.turn_window.end_ms : nowMs;
162
251
 
163
252
  const inTurn = ownerEvents.filter((e) => {
164
253
  const t = Date.parse(e.ts);
165
- return Number.isFinite(t) && t >= startMs && t <= nowMs;
254
+ return Number.isFinite(t) && t >= startMs && t <= endMs;
166
255
  });
167
256
 
168
257
  const toolPreUseInTurn = inTurn.some((e) => e.event_type === "tool.pre_use");
@@ -18,6 +18,8 @@
18
18
  * fire-and-forget. Empty text → no-op (no JSON written).
19
19
  */
20
20
 
21
+ import { STOP_REMEDIATION_MARKER } from "../../agents/rules/stop-hook.ts";
22
+ import { resolveBinName } from "../../config.ts";
21
23
  import type { Adapter } from "../events/schema.ts";
22
24
 
23
25
  export type SystemEvent = "SessionStart" | "UserPromptSubmit" | "SubagentStart";
@@ -61,14 +63,25 @@ export function emitDeny(adapter: Adapter, reason: string): void {
61
63
  export function emitStopBlock(adapter: Adapter, verdict: { reason?: string; rule: string }): 0 | 2 {
62
64
  if (adapter === "codex") return 0;
63
65
 
64
- const message = `${
65
- verdict.reason ?? "End-of-turn coordination ritual incomplete."
66
- }\n[agent-hook stop]: rule=${verdict.rule}`;
66
+ const reason = verdict.reason ?? "End-of-turn coordination ritual incomplete.";
67
67
  if (adapter === "cursor") {
68
+ // The marker leads the message so the Stop verdict can recognize the turn
69
+ // Cursor opens from it (`STOP_REMEDIATION_MARKER`), and so the operator can
70
+ // see in chat that the message came from Harnery rather than from them.
71
+ // Both commands are named because one message that repairs the whole ritual
72
+ // ends the chain in a single followup; the failing rule is context.
73
+ const bin = resolveBinName();
74
+ const message = [
75
+ `${STOP_REMEDIATION_MARKER} rule=${verdict.rule}]`,
76
+ reason,
77
+ `Repair the ritual in this turn: run \`${bin} agents set-task "<short focus>"\` and then \`${bin} agents status\` as your last tool call.`,
78
+ ].join("\n");
68
79
  process.stdout.write(`${JSON.stringify({ followup_message: message })}\n`);
69
80
  return 0;
70
81
  }
71
- process.stderr.write(`${message}\n`);
82
+ // Claude Code continues the SAME turn on exit 2, so the single failing rule is
83
+ // both accurate and sufficient there. Left unchanged deliberately.
84
+ process.stderr.write(`${reason}\n[agent-hook stop]: rule=${verdict.rule}\n`);
72
85
  return 2;
73
86
  }
74
87
 
@@ -43,7 +43,7 @@ export interface ParsedPayload {
43
43
  * Parse the raw stdin payload string for any adapter. Returns null when JSON
44
44
  * parse fails (Cursor occasionally fires hooks with no payload).
45
45
  */
46
- export function parsePayload(raw: string, _adapter: Adapter): ParsedPayload | null {
46
+ export function parsePayload(raw: string, adapter: Adapter): ParsedPayload | null {
47
47
  if (!raw || raw.trim().length === 0) return null;
48
48
  let json: Record<string, unknown>;
49
49
  try {
@@ -52,13 +52,26 @@ export function parsePayload(raw: string, _adapter: Adapter): ParsedPayload | nu
52
52
  return null;
53
53
  }
54
54
 
55
+ const sessionId = normalizeSessionId(adapter, pickStr(json, "session_id"));
56
+ const conversationId = normalizeSessionId(adapter, pickStr(json, "conversation_id"));
57
+ const parentSessionId = normalizeSessionId(adapter, pickStr(json, "parent_session_id"));
58
+ const normalizedRaw =
59
+ adapter === "cursor"
60
+ ? {
61
+ ...json,
62
+ ...(sessionId ? { session_id: sessionId } : {}),
63
+ ...(conversationId ? { conversation_id: conversationId } : {}),
64
+ ...(parentSessionId ? { parent_session_id: parentSessionId } : {}),
65
+ }
66
+ : json;
67
+
55
68
  return {
56
69
  hook_event_name: pickStr(json, "hook_event_name"),
57
- session_id: pickStr(json, "session_id"),
70
+ session_id: sessionId,
58
71
  agent_id: pickStr(json, "agent_id"),
59
72
  subagent_id: pickStr(json, "subagent_id"),
60
- conversation_id: pickStr(json, "conversation_id"),
61
- parent_session_id: pickStr(json, "parent_session_id"),
73
+ conversation_id: conversationId,
74
+ parent_session_id: parentSessionId,
62
75
  turn_id: pickStr(json, "turn_id"),
63
76
  parent_turn_id: pickStr(json, "parent_turn_id"),
64
77
  transcript_path: pickStr(json, "transcript_path"),
@@ -75,10 +88,16 @@ export function parsePayload(raw: string, _adapter: Adapter): ParsedPayload | nu
75
88
  clean_exit: pickBool(json, "clean_exit"),
76
89
  exit_status: pickStr(json, "exit_status"),
77
90
  reason: pickStr(json, "reason"),
78
- raw: json,
91
+ raw: normalizedRaw,
79
92
  };
80
93
  }
81
94
 
95
+ /** Cursor Glass prefixes conversation ids with `bc-`; coordination uses the bare id everywhere. */
96
+ function normalizeSessionId(adapter: Adapter, value: string | undefined): string | undefined {
97
+ if (adapter !== "cursor" || !value?.startsWith("bc-") || value.length <= 3) return value;
98
+ return value.slice(3);
99
+ }
100
+
82
101
  /**
83
102
  * Pull the bash command string out of a Bash/Shell tool_input. Returns
84
103
  * undefined for non-shell tools.