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.
- package/README.md +2 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +3 -1
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +212 -1
- package/dist/commands/claude-desktop.d.ts +2 -2
- package/dist/commands/claude-desktop.d.ts.map +1 -1
- package/dist/commands/claude-desktop.js +74 -3
- package/dist/commands/doctor.d.ts +4 -2
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +45 -6
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +10 -0
- package/dist/commands/instructions.d.ts +4 -0
- package/dist/commands/instructions.d.ts.map +1 -0
- package/dist/commands/instructions.js +100 -0
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +4 -1
- package/dist/core/agents/canonical-emit.d.ts +1 -1
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +5 -3
- package/dist/core/agents/cli.js +5 -1
- package/dist/core/agents/coord-client.d.ts +11 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/index.d.ts +1 -0
- package/dist/core/agents/index.d.ts.map +1 -1
- package/dist/core/agents/index.js +1 -0
- package/dist/core/agents/render/prompt-context.d.ts +6 -0
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +46 -5
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +8 -2
- package/dist/core/agents/rules/stop-hook.js +4 -4
- package/dist/core/agents/state/heartbeat-projector.d.ts +11 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +54 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts +15 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +32 -0
- package/dist/core/agents/state/session-state.d.ts +44 -0
- package/dist/core/agents/state/session-state.d.ts.map +1 -0
- package/dist/core/agents/state/session-state.js +81 -0
- package/dist/core/hooks/adapter/events.d.ts.map +1 -1
- package/dist/core/hooks/adapter/events.js +1 -0
- package/dist/core/hooks/adapter/parse.d.ts +1 -1
- package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
- package/dist/core/hooks/adapter/parse.js +2 -0
- package/dist/core/hooks/cli.js +90 -6
- package/dist/core/hooks/codex-authorization.d.ts +30 -0
- package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
- package/dist/core/hooks/codex-authorization.js +199 -0
- package/dist/core/hooks/events/schema.d.ts +30 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/presence/blob.d.ts +4 -0
- package/dist/core/presence/blob.d.ts.map +1 -1
- package/dist/core/presence/blob.js +12 -0
- package/dist/core/presence/index.js +15 -1
- package/dist/lib/claude-desktop.d.ts +47 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -1
- package/dist/lib/claude-desktop.js +169 -1
- package/dist/lib/instructions/bundle.d.ts +33 -0
- package/dist/lib/instructions/bundle.d.ts.map +1 -0
- package/dist/lib/instructions/bundle.js +149 -0
- package/package.json +2 -2
- package/src/commander.ts +3 -1
- package/src/commands/agents.ts +248 -1
- package/src/commands/claude-desktop.ts +106 -5
- package/src/commands/doctor.ts +59 -7
- package/src/commands/init.ts +11 -0
- package/src/commands/instructions.ts +129 -0
- package/src/commands/presence.ts +7 -1
- package/src/core/agents/canonical-emit.ts +6 -4
- package/src/core/agents/cli.ts +6 -1
- package/src/core/agents/coord-client.ts +11 -0
- package/src/core/agents/index.ts +1 -0
- package/src/core/agents/render/prompt-context.ts +71 -6
- package/src/core/agents/render/session-context.ts +14 -2
- package/src/core/agents/rules/stop-hook.ts +5 -5
- package/src/core/agents/state/heartbeat-projector.ts +63 -0
- package/src/core/agents/state/heartbeat-writer.ts +51 -0
- package/src/core/agents/state/session-state.ts +131 -0
- package/src/core/hooks/adapter/events.ts +1 -0
- package/src/core/hooks/adapter/parse.ts +3 -0
- package/src/core/hooks/cli.ts +98 -7
- package/src/core/hooks/codex-authorization.ts +253 -0
- package/src/core/hooks/events/schema.ts +44 -0
- package/src/core/presence/blob.ts +18 -0
- package/src/core/presence/index.ts +18 -1
- package/src/lib/claude-desktop.ts +232 -1
- 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
|
|
311
|
+
const namingEvent = inTurn.find(
|
|
312
312
|
(e) =>
|
|
313
|
-
e.event_type === "state.task_set" &&
|
|
314
|
-
|
|
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 (
|
|
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(
|
|
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"
|
package/src/core/hooks/cli.ts
CHANGED
|
@@ -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(
|
|
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<
|
|
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
|
}
|