harnery 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +3 -1
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +212 -1
  6. package/dist/commands/claude-desktop.d.ts +2 -2
  7. package/dist/commands/claude-desktop.d.ts.map +1 -1
  8. package/dist/commands/claude-desktop.js +74 -3
  9. package/dist/commands/doctor.d.ts +4 -2
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/doctor.js +45 -6
  12. package/dist/commands/init.d.ts +1 -0
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +10 -0
  15. package/dist/commands/instructions.d.ts +4 -0
  16. package/dist/commands/instructions.d.ts.map +1 -0
  17. package/dist/commands/instructions.js +100 -0
  18. package/dist/commands/presence.d.ts.map +1 -1
  19. package/dist/commands/presence.js +4 -1
  20. package/dist/core/agents/canonical-emit.d.ts +1 -1
  21. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  22. package/dist/core/agents/canonical-emit.js +5 -3
  23. package/dist/core/agents/cli.js +5 -1
  24. package/dist/core/agents/coord-client.d.ts +11 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/index.d.ts +1 -0
  27. package/dist/core/agents/index.d.ts.map +1 -1
  28. package/dist/core/agents/index.js +1 -0
  29. package/dist/core/agents/render/prompt-context.d.ts +6 -0
  30. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  31. package/dist/core/agents/render/prompt-context.js +46 -5
  32. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  33. package/dist/core/agents/render/session-context.js +8 -2
  34. package/dist/core/agents/rules/stop-hook.js +4 -4
  35. package/dist/core/agents/state/heartbeat-projector.d.ts +11 -0
  36. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  37. package/dist/core/agents/state/heartbeat-projector.js +54 -0
  38. package/dist/core/agents/state/heartbeat-writer.d.ts +15 -0
  39. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  40. package/dist/core/agents/state/heartbeat-writer.js +32 -0
  41. package/dist/core/agents/state/session-state.d.ts +44 -0
  42. package/dist/core/agents/state/session-state.d.ts.map +1 -0
  43. package/dist/core/agents/state/session-state.js +81 -0
  44. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  45. package/dist/core/hooks/adapter/events.js +1 -0
  46. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  47. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  48. package/dist/core/hooks/adapter/parse.js +2 -0
  49. package/dist/core/hooks/cli.js +90 -6
  50. package/dist/core/hooks/codex-authorization.d.ts +30 -0
  51. package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
  52. package/dist/core/hooks/codex-authorization.js +199 -0
  53. package/dist/core/hooks/events/schema.d.ts +30 -1
  54. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  55. package/dist/core/presence/blob.d.ts +4 -0
  56. package/dist/core/presence/blob.d.ts.map +1 -1
  57. package/dist/core/presence/blob.js +12 -0
  58. package/dist/core/presence/index.js +15 -1
  59. package/dist/lib/claude-desktop.d.ts +47 -0
  60. package/dist/lib/claude-desktop.d.ts.map +1 -1
  61. package/dist/lib/claude-desktop.js +169 -1
  62. package/dist/lib/instructions/bundle.d.ts +33 -0
  63. package/dist/lib/instructions/bundle.d.ts.map +1 -0
  64. package/dist/lib/instructions/bundle.js +149 -0
  65. package/package.json +2 -2
  66. package/src/commander.ts +3 -1
  67. package/src/commands/agents.ts +248 -1
  68. package/src/commands/claude-desktop.ts +106 -5
  69. package/src/commands/doctor.ts +59 -7
  70. package/src/commands/init.ts +11 -0
  71. package/src/commands/instructions.ts +129 -0
  72. package/src/commands/presence.ts +7 -1
  73. package/src/core/agents/canonical-emit.ts +6 -4
  74. package/src/core/agents/cli.ts +6 -1
  75. package/src/core/agents/coord-client.ts +11 -0
  76. package/src/core/agents/index.ts +1 -0
  77. package/src/core/agents/render/prompt-context.ts +71 -6
  78. package/src/core/agents/render/session-context.ts +14 -2
  79. package/src/core/agents/rules/stop-hook.ts +5 -5
  80. package/src/core/agents/state/heartbeat-projector.ts +63 -0
  81. package/src/core/agents/state/heartbeat-writer.ts +51 -0
  82. package/src/core/agents/state/session-state.ts +131 -0
  83. package/src/core/hooks/adapter/events.ts +1 -0
  84. package/src/core/hooks/adapter/parse.ts +3 -0
  85. package/src/core/hooks/cli.ts +98 -7
  86. package/src/core/hooks/codex-authorization.ts +253 -0
  87. package/src/core/hooks/events/schema.ts +44 -0
  88. package/src/core/presence/blob.ts +18 -0
  89. package/src/core/presence/index.ts +18 -1
  90. package/src/lib/claude-desktop.ts +232 -1
  91. package/src/lib/instructions/bundle.ts +199 -0
@@ -14,6 +14,7 @@ import { resolveBinName, resolveHooksSetupHint } from "../../config.ts";
14
14
  import { harneryVersion, loadAdapterWiring } from "../../hooks/adapter/wiring.ts";
15
15
  import { readRemoteMachines } from "../../presence/index.ts";
16
16
  import { readForkParent } from "../state/names.ts";
17
+ import type { AgentActivity, TaskState } from "../state/session-state.ts";
17
18
 
18
19
  interface HeartbeatRow {
19
20
  instance_id?: string;
@@ -27,6 +28,9 @@ interface HeartbeatRow {
27
28
  files_touched?: string[];
28
29
  platform?: string;
29
30
  task?: string;
31
+ activity?: AgentActivity;
32
+ task_state?: TaskState;
33
+ task_state_reason?: string;
30
34
  turn_summary?: string;
31
35
  }
32
36
 
@@ -135,8 +139,12 @@ function formatRemoteMachines(coordRoot: string): string {
135
139
  const files = a.files_touched?.length
136
140
  ? `holds: ${a.files_touched.slice(0, 3).join(", ")}${a.files_touched.length > 3 ? `, +${a.files_touched.length - 3} more` : ""}`
137
141
  : "nothing held";
142
+ const reason =
143
+ a.task_state === "blocked" && a.task_state_reason
144
+ ? `: ${a.task_state_reason.slice(0, 80)}`
145
+ : "";
138
146
  lines.push(
139
- ` - agent-${a.name ?? a.instance_id.slice(0, 8)} @${m.machine}${task} (${files})`,
147
+ ` - agent-${a.name ?? a.instance_id.slice(0, 8)} @${m.machine}${task} (activity=${a.activity}, lifecycle=${a.task_state}${reason}, ${files})`,
140
148
  );
141
149
  }
142
150
  }
@@ -230,11 +238,15 @@ function renderSubtable(
230
238
 
231
239
  function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number): string {
232
240
  const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : "";
241
+ const activity = r.activity ?? "unknown";
242
+ const lifecycle = r.task_state ?? "active";
243
+ const reason =
244
+ lifecycle === "blocked" && r.task_state_reason ? `: ${r.task_state_reason.slice(0, 80)}` : "";
233
245
  const ageFrom = fmtAge(nowSec - parseIsoSec(r.started_at));
234
246
  const filesPart = fmtFiles(r.display_files);
235
247
  const lastActivity = fmtLastActivity(r, nowSec);
236
248
  const turnSummary = r.turn_summary ? `\n last turn: ${r.turn_summary.slice(0, 80)}` : "";
237
- return ` - agent-${r.name ?? "unknown"}${taskPart} (${ageFrom}, ${filesPart}${lastActivity})${turnSummary}`;
249
+ return ` - agent-${r.name ?? "unknown"}${taskPart} (activity=${activity}, lifecycle=${lifecycle}${reason}, ${ageFrom}, ${filesPart}${lastActivity})${turnSummary}`;
238
250
  }
239
251
 
240
252
  function fmtFiles(files: string[]): string {
@@ -308,14 +308,14 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
308
308
  // `session_name_present` (assistant-text-only transcript scan), mirroring
309
309
  // how rule 2/3 rides `status_box_present`.
310
310
  if (ackSignal === "status_box_present") {
311
- const namingTaskSet = inTurn.find(
311
+ const namingEvent = inTurn.find(
312
312
  (e) =>
313
- e.event_type === "state.task_set" &&
314
- e.data.first_of_session === true &&
313
+ ((e.event_type === "state.task_set" && e.data.first_of_session === true) ||
314
+ (e.event_type === "state.task_state" && e.data.name_reminted === true)) &&
315
315
  typeof e.data.suggested_session_name === "string" &&
316
316
  e.data.suggested_session_name.length > 0,
317
317
  );
318
- if (namingTaskSet) {
318
+ if (namingEvent) {
319
319
  // ANY in-window turn.stop with the flag satisfies the rule, not just the
320
320
  // latest: once seen, `session_name_seen_at` is stamped and later
321
321
  // turn.stops OMIT the field, so a later block on a different rule (whose
@@ -324,7 +324,7 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
324
324
  (e) => e.event_type === "turn.stop" && e.data.session_name_present === true,
325
325
  );
326
326
  if (!namePresent) {
327
- return sessionNameBlock(String(namingTaskSet.data.suggested_session_name));
327
+ return sessionNameBlock(String(namingEvent.data.suggested_session_name));
328
328
  }
329
329
  }
330
330
  }
@@ -12,6 +12,7 @@
12
12
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
14
  import type { CanonicalEvent } from "../events/consume.ts";
15
+ import { type AgentActivity, applySessionStateEvent, type TaskState } from "./session-state.ts";
15
16
 
16
17
  export interface V2Heartbeat {
17
18
  instance_id: string;
@@ -21,6 +22,10 @@ export interface V2Heartbeat {
21
22
  name?: string;
22
23
  kind?: "session" | "subagent" | "transient";
23
24
  model?: string;
25
+ instruction_bundle_id?: string;
26
+ instruction_source_id?: string;
27
+ instruction_profile_root?: string;
28
+ instruction_component_count?: number;
24
29
  platform?: string;
25
30
  subagent_call_id?: string;
26
31
  parent_session_id?: string;
@@ -34,6 +39,12 @@ export interface V2Heartbeat {
34
39
  last_tool_at?: string;
35
40
  task?: string;
36
41
  task_updated_at?: string;
42
+ activity?: AgentActivity;
43
+ activity_updated_at?: string;
44
+ activity_source?: string;
45
+ task_state?: TaskState;
46
+ task_state_updated_at?: string;
47
+ task_state_reason?: string;
37
48
  suggested_session_name?: string;
38
49
  session_name_seen_at?: string;
39
50
  session_name_seen_for?: string;
@@ -180,6 +191,14 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
180
191
  hb.v2_meta.last_projected = new Date().toISOString();
181
192
  if (ev.turn_id) hb.last_turn_id = ev.turn_id;
182
193
 
194
+ const sessionState = applySessionStateEvent(hb, ev);
195
+ hb.activity = sessionState.activity;
196
+ hb.activity_updated_at = sessionState.activity_updated_at;
197
+ hb.activity_source = sessionState.activity_source;
198
+ hb.task_state = sessionState.task_state;
199
+ hb.task_state_updated_at = sessionState.task_state_updated_at;
200
+ hb.task_state_reason = sessionState.task_state_reason;
201
+
183
202
  const d = ev.data;
184
203
  switch (ev.event_type) {
185
204
  case "session.start":
@@ -188,6 +207,16 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
188
207
  {
189
208
  const model = pickStr(d, "model");
190
209
  if (model) hb.model = model;
210
+ const instructionBundleId = pickStr(d, "instruction_bundle_id");
211
+ if (instructionBundleId) hb.instruction_bundle_id = instructionBundleId;
212
+ const instructionSourceId = pickStr(d, "instruction_source_id");
213
+ if (instructionSourceId) hb.instruction_source_id = instructionSourceId;
214
+ const instructionProfileRoot = pickStr(d, "instruction_profile_root");
215
+ if (instructionProfileRoot) hb.instruction_profile_root = instructionProfileRoot;
216
+ const instructionComponentCount = pickNum(d, "instruction_component_count");
217
+ if (instructionComponentCount !== undefined) {
218
+ hb.instruction_component_count = instructionComponentCount;
219
+ }
191
220
  const platform = pickStr(d, "platform") ?? adapterToPlatform(ev.adapter);
192
221
  hb.platform = platform;
193
222
  const name = pickStr(d, "name");
@@ -324,6 +353,21 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
324
353
  break;
325
354
  }
326
355
 
356
+ case "state.task_state": {
357
+ const suggested = pickStr(d, "suggested_session_name");
358
+ if (suggested && suggested !== hb.suggested_session_name) {
359
+ // Older heartbeats predate session_name_seen_for. A lifecycle re-mint
360
+ // still needs a mismatch the prompt and Stop paths can enforce, so
361
+ // attribute the prior title before replacing it. Ordinary legacy
362
+ // heartbeats remain quiet until a real lifecycle event changes them.
363
+ if (!hb.session_name_seen_for && hb.suggested_session_name) {
364
+ hb.session_name_seen_for = hb.suggested_session_name;
365
+ }
366
+ hb.suggested_session_name = suggested;
367
+ }
368
+ break;
369
+ }
370
+
327
371
  case "state.status_checked":
328
372
  hb.last_status_at = ev.ts;
329
373
  break;
@@ -513,6 +557,10 @@ function writeHeartbeat(coordRoot: string, instanceId: string, hb: V2Heartbeat):
513
557
  setIfDefined(merged, "workflow_run_id", hb.workflow_run_id);
514
558
  setIfDefined(merged, "workflow_agent_id", hb.workflow_agent_id);
515
559
  setIfDefined(merged, "model", hb.model);
560
+ setIfDefined(merged, "instruction_bundle_id", hb.instruction_bundle_id);
561
+ setIfDefined(merged, "instruction_source_id", hb.instruction_source_id);
562
+ setIfDefined(merged, "instruction_profile_root", hb.instruction_profile_root);
563
+ setIfDefined(merged, "instruction_component_count", hb.instruction_component_count);
516
564
  setIfDefined(merged, "platform", hb.platform);
517
565
  setIfDefined(merged, "started_at", hb.started_at);
518
566
  // files_touched is a required-array invariant for every reader
@@ -526,6 +574,16 @@ function writeHeartbeat(coordRoot: string, instanceId: string, hb: V2Heartbeat):
526
574
  setIfDefined(merged, "last_tool_at", hb.last_tool_at);
527
575
  setIfDefined(merged, "task", hb.task);
528
576
  setIfDefined(merged, "task_updated_at", hb.task_updated_at);
577
+ setIfDefined(merged, "activity", hb.activity);
578
+ setIfDefined(merged, "activity_updated_at", hb.activity_updated_at);
579
+ setIfDefined(merged, "activity_source", hb.activity_source);
580
+ setIfDefined(merged, "task_state", hb.task_state);
581
+ setIfDefined(merged, "task_state_updated_at", hb.task_state_updated_at);
582
+ if (hb.task_state_reason) {
583
+ merged.task_state_reason = hb.task_state_reason;
584
+ } else if (hb.task_state) {
585
+ delete merged.task_state_reason;
586
+ }
529
587
  setIfDefined(merged, "suggested_session_name", hb.suggested_session_name);
530
588
  setIfDefined(merged, "session_name_seen_at", hb.session_name_seen_at);
531
589
  if (hb.session_name_seen_for) {
@@ -580,3 +638,8 @@ function pickBool(o: Record<string, unknown>, k: string): boolean | undefined {
580
638
  const v = o[k];
581
639
  return typeof v === "boolean" ? v : undefined;
582
640
  }
641
+
642
+ function pickNum(o: Record<string, unknown>, k: string): number | undefined {
643
+ const v = o[k];
644
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
645
+ }
@@ -24,6 +24,12 @@ import {
24
24
  } from "node:fs";
25
25
  import { dirname, join } from "node:path";
26
26
  import { emit } from "../events/emit.ts";
27
+ import {
28
+ type AgentActivity,
29
+ applySessionStateEvent,
30
+ type SessionStateEvidenceEvent,
31
+ type TaskState,
32
+ } from "./session-state.ts";
27
33
 
28
34
  /** Inline adapter normalizer (mirrors canonical-emit.normalizeAdapter), kept
29
35
  * local so the writer's only cross-module dep is the in-process emit(). */
@@ -86,6 +92,12 @@ export interface Heartbeat {
86
92
  files_touched: string[];
87
93
  task?: string;
88
94
  task_updated_at?: string | null;
95
+ activity?: AgentActivity;
96
+ activity_updated_at?: string;
97
+ activity_source?: string;
98
+ task_state?: TaskState;
99
+ task_state_updated_at?: string;
100
+ task_state_reason?: string;
89
101
  /** Session name built on the first non-empty set-task (never rebuilt). Its
90
102
  * presence is the "this session has been named" signal the prompt-context
91
103
  * nudge and the Stop-hook naming rule key on. */
@@ -134,6 +146,32 @@ export function readHeartbeat(coordRoot: string, instanceId: string): Heartbeat
134
146
  }
135
147
  }
136
148
 
149
+ /** Apply one evidence event to the live state fields without draining the
150
+ * global event stream. This keeps permission prompts current while avoiding
151
+ * unrelated projector side effects before a tool authorization verdict. */
152
+ export function stampSessionStateEvent(
153
+ coordRoot: string,
154
+ instanceId: string,
155
+ event: Omit<SessionStateEvidenceEvent, "data"> & { data: unknown },
156
+ ): Heartbeat | null {
157
+ return mutate(coordRoot, instanceId, (heartbeat) => {
158
+ const data =
159
+ typeof event.data === "object" && event.data !== null && !Array.isArray(event.data)
160
+ ? (event.data as Record<string, unknown>)
161
+ : {};
162
+ const state = applySessionStateEvent(heartbeat, { ...event, data });
163
+ return {
164
+ ...heartbeat,
165
+ activity: state.activity,
166
+ activity_updated_at: state.activity_updated_at,
167
+ activity_source: state.activity_source,
168
+ task_state: state.task_state,
169
+ task_state_updated_at: state.task_state_updated_at,
170
+ task_state_reason: state.task_state_reason,
171
+ };
172
+ });
173
+ }
174
+
137
175
  function mutate(
138
176
  coordRoot: string,
139
177
  instanceId: string,
@@ -183,6 +221,19 @@ export function buildSuggestedName(
183
221
  return { suggestedName: `Agent ${name} - ${description}`, description };
184
222
  }
185
223
 
224
+ /** Build the operator-facing title projected from an explicit task lifecycle. */
225
+ export function buildLifecycleSuggestedName(
226
+ agentName: string,
227
+ task: string | undefined,
228
+ state: TaskState,
229
+ ): string | null {
230
+ const built = buildSuggestedName(agentName, task ? [task] : []);
231
+ if (!built) return null;
232
+ if (state === "blocked") return `[BLOCKED] - ${built.suggestedName}`;
233
+ if (state === "done") return `[DONE] - ${built.suggestedName}`;
234
+ return built.suggestedName;
235
+ }
236
+
186
237
  /** Stamp the sighting once the suggested name has been observed in assistant
187
238
  * reply text; later turns skip the transcript scan. Records WHICH name was seen,
188
239
  * because the skip is only valid for that one: if a later set-task mints a
@@ -0,0 +1,131 @@
1
+ /** What the agent is doing now. `unknown` is evidence-safe, not an error. */
2
+ export type AgentActivity = "unknown" | "working" | "needs_input" | "idle";
3
+
4
+ /** Whether the declared task remains open. Activity never changes this axis. */
5
+ export type TaskState = "active" | "blocked" | "done";
6
+
7
+ /** Raw fields stored in a live heartbeat and rebuilt from canonical events. */
8
+ export interface SessionStateFields {
9
+ activity?: AgentActivity;
10
+ activity_updated_at?: string;
11
+ activity_source?: string;
12
+ task_state?: TaskState;
13
+ task_state_updated_at?: string;
14
+ task_state_reason?: string;
15
+ }
16
+
17
+ /** Reader-facing state with compatibility defaults applied. */
18
+ export interface SessionState extends SessionStateFields {
19
+ activity: AgentActivity;
20
+ task_state: TaskState;
21
+ }
22
+
23
+ export interface SessionStateSelector {
24
+ instance_id?: string;
25
+ session_id?: string;
26
+ }
27
+
28
+ export interface SessionStateEvidenceEvent {
29
+ event_type: string;
30
+ ts: string;
31
+ data?: Record<string, unknown>;
32
+ instance_id?: string;
33
+ session_id?: string;
34
+ }
35
+
36
+ const TERMINAL_ACTIVITY_EVENTS = new Set(["session.end", "subagent.stop", "turn.stop"]);
37
+
38
+ /**
39
+ * Apply one canonical event to the two independent state axes.
40
+ *
41
+ * The table is deliberately evidence-only. Output chunks, post-tool events,
42
+ * narration, coordination commands, and elapsed time cannot clear an observed
43
+ * input wait. A command start only counts as progress when the session was
44
+ * already inside an open turn (`working` or `needs_input`).
45
+ */
46
+ export function applySessionStateEvent(
47
+ current: Readonly<SessionStateFields>,
48
+ event: SessionStateEvidenceEvent,
49
+ ): SessionStateFields {
50
+ const next: SessionStateFields = { ...current };
51
+
52
+ if (event.event_type === "session.start" || event.event_type === "subagent.start") {
53
+ setActivity(next, "idle", event);
54
+ } else if (event.event_type === "user_prompt.submit" || event.event_type === "tool.pre_use") {
55
+ setActivity(next, "working", event);
56
+ } else if (event.event_type === "interaction.input_requested") {
57
+ setActivity(next, "needs_input", event);
58
+ } else if (
59
+ event.event_type === "command.start" &&
60
+ (current.activity === "working" || current.activity === "needs_input")
61
+ ) {
62
+ setActivity(next, "working", event);
63
+ } else if (TERMINAL_ACTIVITY_EVENTS.has(event.event_type)) {
64
+ setActivity(next, "idle", event);
65
+ }
66
+
67
+ if (event.event_type === "state.task_state") {
68
+ const data = event.data ?? {};
69
+ const state = field(data, "state");
70
+ if (state === "active" || state === "blocked" || state === "done") {
71
+ next.task_state = state;
72
+ next.task_state_updated_at = event.ts;
73
+ const reason = field(data, "reason");
74
+ if (typeof reason === "string" && reason.length > 0) {
75
+ next.task_state_reason = reason;
76
+ } else {
77
+ delete next.task_state_reason;
78
+ }
79
+ }
80
+ }
81
+
82
+ return next;
83
+ }
84
+
85
+ /**
86
+ * Rebuild both axes from canonical events after a heartbeat has disappeared.
87
+ * Events may be the whole ledger or an already-filtered session slice.
88
+ */
89
+ export function foldSessionState(
90
+ events: readonly SessionStateEvidenceEvent[],
91
+ selector: SessionStateSelector = {},
92
+ ): SessionState {
93
+ const ordered = events
94
+ .map((event, index) => ({ event, index }))
95
+ .filter(({ event }) => matches(event, selector))
96
+ .sort((a, b) => timestamp(a.event) - timestamp(b.event) || a.index - b.index);
97
+
98
+ let fields: SessionStateFields = {};
99
+ for (const { event } of ordered) fields = applySessionStateEvent(fields, event);
100
+
101
+ return {
102
+ ...fields,
103
+ activity: fields.activity ?? "unknown",
104
+ task_state: fields.task_state ?? "active",
105
+ };
106
+ }
107
+
108
+ function setActivity(
109
+ target: SessionStateFields,
110
+ activity: AgentActivity,
111
+ event: SessionStateEvidenceEvent,
112
+ ): void {
113
+ target.activity = activity;
114
+ target.activity_updated_at = event.ts;
115
+ target.activity_source = event.event_type;
116
+ }
117
+
118
+ function matches(event: SessionStateEvidenceEvent, selector: SessionStateSelector): boolean {
119
+ if (selector.instance_id && event.instance_id !== selector.instance_id) return false;
120
+ if (selector.session_id && event.session_id !== selector.session_id) return false;
121
+ return true;
122
+ }
123
+
124
+ function timestamp(event: SessionStateEvidenceEvent): number {
125
+ const parsed = Date.parse(event.ts);
126
+ return Number.isFinite(parsed) ? parsed : 0;
127
+ }
128
+
129
+ function field(data: Record<string, unknown>, key: string): unknown {
130
+ return data[key];
131
+ }
@@ -87,6 +87,7 @@ export const CURSOR_EVENTS: HookEvent[] = [
87
87
  export const CODEX_EVENTS: HookEvent[] = [
88
88
  { settingsKey: "SessionStart", subcommand: "session-start" },
89
89
  { settingsKey: "PreToolUse", subcommand: "pre-tool-use" },
90
+ { settingsKey: "PermissionRequest", subcommand: "permission-request" },
90
91
  { settingsKey: "PostToolUse", subcommand: "post-tool-use" },
91
92
  { settingsKey: "UserPromptSubmit", subcommand: "user-prompt-submit" },
92
93
  { settingsKey: "SubagentStart", subcommand: "sub-agent-start" },
@@ -178,6 +178,8 @@ export function normalizeEventName(
178
178
  case "pre-tool-use":
179
179
  case "before-shell-execution":
180
180
  return { event_type: "tool.pre_use", intra_turn: true };
181
+ case "permission-request":
182
+ return { event_type: "interaction.input_requested", intra_turn: true };
181
183
  case "post-tool-use":
182
184
  return { event_type: "tool.post_use", intra_turn: true };
183
185
  case "post-tool-use-failure":
@@ -199,6 +201,7 @@ export type NormalizedEventType =
199
201
  | "subagent.start"
200
202
  | "subagent.stop"
201
203
  | "tool.pre_use"
204
+ | "interaction.input_requested"
202
205
  | "tool.post_use"
203
206
  | "tool.post_use_failure"
204
207
  | "context.compaction.started"
@@ -21,6 +21,7 @@ import { spawnSync } from "node:child_process";
21
21
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
22
22
  import { dirname, join } from "node:path";
23
23
  import { coordEnv } from "../../lib/env.ts";
24
+ import { buildInstructionBundle } from "../../lib/instructions/bundle.ts";
24
25
  import { replayCodexJsonl } from "../agents/codex-replay.ts";
25
26
  import { coordBinPath } from "../agents/coord-bin.ts";
26
27
  import { consumeSince, writeCursor } from "../agents/events/consume.ts";
@@ -32,6 +33,7 @@ import {
32
33
  } from "../agents/finalization.ts";
33
34
  import { evaluateStopHook } from "../agents/rules/stop-hook.ts";
34
35
  import { projectHeartbeats } from "../agents/state/heartbeat-projector.ts";
36
+ import { stampSessionStateEvent } from "../agents/state/heartbeat-writer.ts";
35
37
  import { writePidmapRow } from "../agents/state/pidmap.ts";
36
38
  import { shellMutationPaths } from "../agents/state/shell-mutation.ts";
37
39
  import { agentsRequireGitFinalization, resolveBinName } from "../config.ts";
@@ -290,6 +292,7 @@ function buildEventData(
290
292
  if (adapterPid) {
291
293
  writePidmapViaAgentCoord(ctx.coordRoot, adapterPid, ctx.instanceId, adapterPlatform);
292
294
  }
295
+ const bundle = safeInstructionBundle(ctx.coordRoot, p?.cwd ?? process.cwd(), ctx.adapter);
293
296
  return {
294
297
  started_at: new Date().toISOString(),
295
298
  cwd: p?.cwd ?? process.cwd(),
@@ -303,6 +306,14 @@ function buildEventData(
303
306
  name: assigned?.name,
304
307
  kind: "session",
305
308
  agent_id: ctx.instanceId,
309
+ ...(bundle
310
+ ? {
311
+ instruction_bundle_id: bundle.instruction_bundle_id,
312
+ instruction_source_id: bundle.canonical_source_id,
313
+ instruction_profile_root: bundle.profile_root,
314
+ instruction_component_count: bundle.components.length,
315
+ }
316
+ : {}),
306
317
  // Recorded fork lineage rides the canonical event too, so full replay
307
318
  // and derived readers converge on the same parent linkage that
308
319
  // .name-history carries (the ADR 0017 dual-write pattern).
@@ -435,6 +446,16 @@ function buildEventData(
435
446
  };
436
447
  }
437
448
 
449
+ case "interaction.input_requested": {
450
+ const description = extractToolDescription(p?.tool_input);
451
+ const reason = description ? clampString(description, 500).value : undefined;
452
+ return {
453
+ request_kind: "permission",
454
+ tool_name: p?.tool_name,
455
+ ...(reason ? { reason } : {}),
456
+ };
457
+ }
458
+
438
459
  case "tool.post_use": {
439
460
  const toolName = p?.tool_name ?? "unknown";
440
461
  const summary = summarizeOutput(p?.tool_response);
@@ -488,6 +509,15 @@ function buildEventData(
488
509
  }
489
510
  }
490
511
 
512
+ function safeInstructionBundle(coordRoot: string, cwd: string, adapter: Adapter) {
513
+ try {
514
+ return buildInstructionBundle({ coordRoot, cwd, adapter });
515
+ } catch (err) {
516
+ logError(coordRoot, err, { phase: "instruction-bundle-identity" });
517
+ return null;
518
+ }
519
+ }
520
+
491
521
  function objectRecord(value: unknown): Record<string, unknown> | undefined {
492
522
  return typeof value === "object" && value !== null && !Array.isArray(value)
493
523
  ? (value as Record<string, unknown>)
@@ -602,6 +632,21 @@ async function main(): Promise<number> {
602
632
  event_id: envelope.event_id,
603
633
  });
604
634
 
635
+ // Activity is only useful while it is current. Stamp prompt and permission
636
+ // evidence directly from this envelope rather than draining the global
637
+ // projector: a drain here could apply a tool event before its authorization
638
+ // verdict. Tool progress is stamped later, after the guard allows it.
639
+ if (
640
+ norm.event_type === "user_prompt.submit" ||
641
+ norm.event_type === "interaction.input_requested"
642
+ ) {
643
+ try {
644
+ stampSessionStateEvent(coordRoot, owner.instance_id, envelope);
645
+ } catch (err) {
646
+ logError(coordRoot, err, { phase: "activity-projection" });
647
+ }
648
+ }
649
+
605
650
  // Context telemetry is opportunistic and truthful: only persist a sample
606
651
  // when the adapter payload actually exposes usage/window data. Identical
607
652
  // measurements are de-duplicated before they reach the canonical stream.
@@ -955,6 +1000,23 @@ async function main(): Promise<number> {
955
1000
  bypass: coordEnv("AGENT_COORD_BYPASS_STOP") === "1",
956
1001
  workflow_child: coordEnv("WORKFLOW_CHILD") === "1",
957
1002
  });
1003
+ const enforcementMode = stopEnforcementMode(verdict.rule);
1004
+ emit(coordRoot, {
1005
+ event_type: "stop.verdict",
1006
+ instance_id: owner.instance_id,
1007
+ session_id: sessionId,
1008
+ turn_id: payload?.turn_id,
1009
+ parent_turn_id: payload?.parent_turn_id,
1010
+ adapter,
1011
+ data: {
1012
+ allow: verdict.allow,
1013
+ rule: verdict.rule,
1014
+ ...(verdict.reason ? { reason: verdict.reason } : {}),
1015
+ enforcement_mode: enforcementMode,
1016
+ eligible: enforcementMode === "enforced",
1017
+ nag_delivered: enforcementMode === "enforced" && !verdict.allow,
1018
+ },
1019
+ });
958
1020
  if (!verdict.allow) {
959
1021
  // Adapter-aware enforcement channel: Claude Code / Codex honor exit-2 +
960
1022
  // stderr as a turn block; Cursor ignores exit codes (fail-open) and
@@ -1042,12 +1104,27 @@ async function main(): Promise<number> {
1042
1104
  // apply_patch (codex) parses paths from the patch body and runs verdict
1043
1105
  // per-path; Edit/Write/NotebookEdit resolve a single target. Non-write tools
1044
1106
  // (incl. Agent) yield no targets and pass through with no deny.
1107
+ let guardAllowed = true;
1045
1108
  try {
1046
- await runPreToolUseGuard(coordRoot, owner.instance_id, sessionId, data, adapter);
1109
+ guardAllowed = await runPreToolUseGuard(
1110
+ coordRoot,
1111
+ owner.instance_id,
1112
+ sessionId,
1113
+ data,
1114
+ adapter,
1115
+ );
1047
1116
  } catch (err) {
1048
1117
  logError(coordRoot, err, { phase: "pre-tool-use-guard" });
1049
1118
  }
1050
1119
 
1120
+ if (guardAllowed) {
1121
+ try {
1122
+ stampSessionStateEvent(coordRoot, owner.instance_id, envelope);
1123
+ } catch (err) {
1124
+ logError(coordRoot, err, { phase: "activity-projection" });
1125
+ }
1126
+ }
1127
+
1051
1128
  // Shell-mutation warn (warn-only, never blocks). Was the cursor
1052
1129
  // beforeShellExecution + codex preToolUse-Bash shell-mutation-claim-log in
1053
1130
  // the per-adapter shell adapters. Cursor sends the command at payload.command;
@@ -1126,16 +1203,23 @@ async function main(): Promise<number> {
1126
1203
  return 0;
1127
1204
  }
1128
1205
 
1206
+ function stopEnforcementMode(rule: string): "enforced" | "observe_only" | "exempt" | "fail_open" {
1207
+ if (rule === "stop-hook.codex_observe_only") return "observe_only";
1208
+ if (rule === "stop-hook.fail_open" || rule === "stop-hook.no_history") return "fail_open";
1209
+ if (rule === "stop-hook.bypass" || rule === "stop-hook.workflow_child") return "exempt";
1210
+ return "enforced";
1211
+ }
1212
+
1129
1213
  async function runPreToolUseGuard(
1130
1214
  coordRoot: string,
1131
1215
  instanceId: string,
1132
1216
  sessionId: string,
1133
1217
  data: Record<string, unknown>,
1134
1218
  adapter: Adapter,
1135
- ): Promise<void> {
1219
+ ): Promise<boolean> {
1136
1220
  const toolName = (data.tool_name as string | undefined) ?? "";
1137
1221
  const rawTargets = collectGuardTargets(toolName, data);
1138
- if (rawTargets.length === 0) return;
1222
+ if (rawTargets.length === 0) return true;
1139
1223
 
1140
1224
  let targets: Array<{ path: string; finalization?: ClaimFinalizationDescriptor }>;
1141
1225
  if (agentsRequireGitFinalization(coordRoot)) {
@@ -1144,7 +1228,7 @@ async function runPreToolUseGuard(
1144
1228
  if (denied && !denied.allow) {
1145
1229
  const { emitDeny } = await import("./adapter/output.ts");
1146
1230
  emitDeny(adapter, formatWriteClaimFinalizationDenial(denied, resolveBinName(coordRoot)));
1147
- return;
1231
+ return false;
1148
1232
  }
1149
1233
  const allowed = decisions.filter(
1150
1234
  (decision): decision is Extract<ClaimFinalizationDecision, { allow: true }> => decision.allow,
@@ -1159,7 +1243,7 @@ async function runPreToolUseGuard(
1159
1243
  .filter((path): path is string => path !== null)
1160
1244
  .map((path) => ({ path }));
1161
1245
  }
1162
- if (targets.length === 0) return;
1246
+ if (targets.length === 0) return true;
1163
1247
 
1164
1248
  const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1165
1249
  if (!existsSync(agentCoordBin)) {
@@ -1169,8 +1253,9 @@ async function runPreToolUseGuard(
1169
1253
  adapter,
1170
1254
  "Harnery denied the write before mutation because the claim coordinator is unavailable; repair the project's Harnery installation and retry.",
1171
1255
  );
1256
+ return false;
1172
1257
  }
1173
- return;
1258
+ return true;
1174
1259
  }
1175
1260
 
1176
1261
  // For apply_patch (multi-file), collect siblings so the deny reason names
@@ -1210,7 +1295,7 @@ async function runPreToolUseGuard(
1210
1295
  }
1211
1296
  const { emitDeny } = await import("./adapter/output.ts");
1212
1297
  emitDeny(adapter, reason);
1213
- return;
1298
+ return false;
1214
1299
  }
1215
1300
 
1216
1301
  // The verdict mutates the live heartbeat immediately, but that file is a
@@ -1231,6 +1316,7 @@ async function runPreToolUseGuard(
1231
1316
  },
1232
1317
  });
1233
1318
  }
1319
+ return true;
1234
1320
  }
1235
1321
 
1236
1322
  /** Canonicalize a path to monorepo-relative form. Absolute paths under
@@ -1567,9 +1653,14 @@ async function emitUserPromptSubmitSystemMessage(
1567
1653
  // staleness nudges; Claude Code enforces those via its Stop transcript.
1568
1654
  // Codex gets a fresh status-footer reminder on every prompt. Its Stop hook
1569
1655
  // stays observe-only, so missing the footer never replaces the answer.
1656
+ // Claude Code + Cursor (the Stop-ENFORCING adapters) get a fresh per-turn
1657
+ // ritual reminder: satisfying the contract up front is far cheaper than
1658
+ // the bounce-and-retry the Stop hook otherwise forces.
1570
1659
  args.push("--session-name-nudge");
1571
1660
  if (adapter === "cursor" || adapter === "codex") args.push("--task-nudge");
1572
1661
  if (adapter === "codex") args.push("--status-footer-nudge");
1662
+ if (adapter === "claude-code" || adapter === "cursor")
1663
+ args.push("--turn-ritual-nudge", adapter);
1573
1664
  const result = spawnSync(agentCoordBin, args, { encoding: "utf8", timeout: 3000 });
1574
1665
  if (result.status === 0 && result.stdout) additionalContext = result.stdout.trim();
1575
1666
  }