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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
|
|
4
|
+
export type CodexHookTrustStatus = "managed" | "trusted" | "untrusted" | "modified";
|
|
5
|
+
|
|
6
|
+
export type CodexHookAuthorizationStatus =
|
|
7
|
+
| "runnable"
|
|
8
|
+
| "review_required"
|
|
9
|
+
| "disabled"
|
|
10
|
+
| "not_wired"
|
|
11
|
+
| "error";
|
|
12
|
+
|
|
13
|
+
export interface CodexHookAuthorizationResult {
|
|
14
|
+
status: CodexHookAuthorizationStatus;
|
|
15
|
+
detail: string;
|
|
16
|
+
hookCount: number;
|
|
17
|
+
disabledCount: number;
|
|
18
|
+
trustCounts: Partial<Record<CodexHookTrustStatus, number>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CodexHookAuthorizationProbeOptions {
|
|
22
|
+
cwd: string;
|
|
23
|
+
codexBin?: string;
|
|
24
|
+
appServerArgs?: string[];
|
|
25
|
+
env?: NodeJS.ProcessEnv;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface JsonObject {
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isObject(value: unknown): value is JsonObject {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stringArray(value: unknown): string[] {
|
|
38
|
+
return Array.isArray(value)
|
|
39
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
40
|
+
: [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function trustStatus(value: unknown): CodexHookTrustStatus | null {
|
|
44
|
+
return value === "managed" || value === "trusted" || value === "untrusted" || value === "modified"
|
|
45
|
+
? value
|
|
46
|
+
: null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizedPath(value: string): string {
|
|
50
|
+
return value.replaceAll("\\", "/").replace(/\/$/, "").toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Convert Codex app-server's `hooks/list` response into the one authorization
|
|
55
|
+
* state doctor needs. This is intentionally read-only: hashes are observed but
|
|
56
|
+
* never persisted, and only commands containing Harnery's `agent-hook` binary
|
|
57
|
+
* are included in the result.
|
|
58
|
+
*/
|
|
59
|
+
export function assessCodexHookList(response: unknown, cwd: string): CodexHookAuthorizationResult {
|
|
60
|
+
if (!isObject(response) || !isObject(response.result) || !Array.isArray(response.result.data)) {
|
|
61
|
+
return {
|
|
62
|
+
status: "error",
|
|
63
|
+
detail: "Codex hooks/list returned an invalid response",
|
|
64
|
+
hookCount: 0,
|
|
65
|
+
disabledCount: 0,
|
|
66
|
+
trustCounts: {},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const entries = response.result.data.filter(isObject);
|
|
71
|
+
const expected = normalizedPath(cwd);
|
|
72
|
+
const entry =
|
|
73
|
+
entries.find((candidate) => {
|
|
74
|
+
const candidateCwd = candidate.cwd;
|
|
75
|
+
return typeof candidateCwd === "string" && normalizedPath(candidateCwd) === expected;
|
|
76
|
+
}) ?? (entries.length === 1 ? entries[0] : undefined);
|
|
77
|
+
if (!entry) {
|
|
78
|
+
return {
|
|
79
|
+
status: "error",
|
|
80
|
+
detail: "Codex hooks/list omitted the requested workspace",
|
|
81
|
+
hookCount: 0,
|
|
82
|
+
disabledCount: 0,
|
|
83
|
+
trustCounts: {},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const errors = stringArray(entry.errors);
|
|
88
|
+
if (errors.length > 0) {
|
|
89
|
+
return {
|
|
90
|
+
status: "error",
|
|
91
|
+
detail: `Codex could not load hooks: ${errors.join("; ")}`,
|
|
92
|
+
hookCount: 0,
|
|
93
|
+
disabledCount: 0,
|
|
94
|
+
trustCounts: {},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks.filter(isObject) : [];
|
|
99
|
+
const harneryHooks = hooks.filter(
|
|
100
|
+
(hook) =>
|
|
101
|
+
typeof hook.command === "string" && /(?:^|[\\/\s])agent-hook(?:\s|$)/.test(hook.command),
|
|
102
|
+
);
|
|
103
|
+
if (harneryHooks.length === 0) {
|
|
104
|
+
return {
|
|
105
|
+
status: "not_wired",
|
|
106
|
+
detail: "Codex discovered no Harnery hooks",
|
|
107
|
+
hookCount: 0,
|
|
108
|
+
disabledCount: 0,
|
|
109
|
+
trustCounts: {},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const counts: Partial<Record<CodexHookTrustStatus, number>> = {};
|
|
114
|
+
let disabledCount = 0;
|
|
115
|
+
for (const hook of harneryHooks) {
|
|
116
|
+
const status = trustStatus(hook.trustStatus);
|
|
117
|
+
if (!status) {
|
|
118
|
+
return {
|
|
119
|
+
status: "error",
|
|
120
|
+
detail: "Codex returned an unknown hook trust state",
|
|
121
|
+
hookCount: harneryHooks.length,
|
|
122
|
+
disabledCount,
|
|
123
|
+
trustCounts: counts,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
counts[status] = (counts[status] ?? 0) + 1;
|
|
127
|
+
if (hook.enabled !== true) disabledCount += 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const reviewCount = (counts.untrusted ?? 0) + (counts.modified ?? 0);
|
|
131
|
+
const warnings = stringArray(entry.warnings);
|
|
132
|
+
const warningSuffix = warnings.length > 0 ? `; ${warnings.length} Codex warning(s)` : "";
|
|
133
|
+
if (reviewCount > 0) {
|
|
134
|
+
return {
|
|
135
|
+
status: "review_required",
|
|
136
|
+
detail:
|
|
137
|
+
`${reviewCount} of ${harneryHooks.length} Harnery hooks require review` +
|
|
138
|
+
` (${counts.untrusted ?? 0} new, ${counts.modified ?? 0} modified)${warningSuffix}`,
|
|
139
|
+
hookCount: harneryHooks.length,
|
|
140
|
+
disabledCount,
|
|
141
|
+
trustCounts: counts,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (disabledCount > 0) {
|
|
145
|
+
return {
|
|
146
|
+
status: "disabled",
|
|
147
|
+
detail: `${disabledCount} of ${harneryHooks.length} trusted Harnery hooks are disabled${warningSuffix}`,
|
|
148
|
+
hookCount: harneryHooks.length,
|
|
149
|
+
disabledCount,
|
|
150
|
+
trustCounts: counts,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
status: "runnable",
|
|
155
|
+
detail: `${harneryHooks.length} Harnery hooks trusted and enabled${warningSuffix}`,
|
|
156
|
+
hookCount: harneryHooks.length,
|
|
157
|
+
disabledCount: 0,
|
|
158
|
+
trustCounts: counts,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Ask the active Codex CLI for hook authorization through its app-server
|
|
164
|
+
* protocol. The child is closed as soon as `hooks/list` responds. Harnery never
|
|
165
|
+
* calls the trust-write method and never reads Codex's private config format.
|
|
166
|
+
*/
|
|
167
|
+
export function probeCodexHookAuthorization(
|
|
168
|
+
options: CodexHookAuthorizationProbeOptions,
|
|
169
|
+
): Promise<CodexHookAuthorizationResult> {
|
|
170
|
+
const codexBin = options.codexBin ?? "codex";
|
|
171
|
+
const args = options.appServerArgs ?? ["app-server"];
|
|
172
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
173
|
+
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
const child = spawn(codexBin, args, {
|
|
176
|
+
cwd: options.cwd,
|
|
177
|
+
env: options.env ?? process.env,
|
|
178
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
179
|
+
windowsHide: true,
|
|
180
|
+
});
|
|
181
|
+
const lines = createInterface({ input: child.stdout });
|
|
182
|
+
let settled = false;
|
|
183
|
+
let stderr = "";
|
|
184
|
+
|
|
185
|
+
const finish = (result: CodexHookAuthorizationResult): void => {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
lines.close();
|
|
190
|
+
child.stdin.end();
|
|
191
|
+
child.kill();
|
|
192
|
+
resolve(result);
|
|
193
|
+
};
|
|
194
|
+
const fail = (detail: string): void =>
|
|
195
|
+
finish({ status: "error", detail, hookCount: 0, disabledCount: 0, trustCounts: {} });
|
|
196
|
+
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
fail(`Codex hooks/list timed out after ${timeoutMs}ms`);
|
|
199
|
+
}, timeoutMs);
|
|
200
|
+
|
|
201
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
202
|
+
if (stderr.length < 4_096) stderr += chunk.toString();
|
|
203
|
+
});
|
|
204
|
+
child.stdin.on("error", (error) => fail(`Codex app-server input failed: ${error.message}`));
|
|
205
|
+
child.on("error", (error) => fail(`could not start Codex app-server: ${error.message}`));
|
|
206
|
+
child.on("close", (code) => {
|
|
207
|
+
if (!settled) {
|
|
208
|
+
const suffix = stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : "";
|
|
209
|
+
fail(
|
|
210
|
+
`Codex app-server exited before hooks/list completed (exit ${code ?? "unknown"})${suffix}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
lines.on("line", (line) => {
|
|
215
|
+
let message: JsonObject;
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(line) as unknown;
|
|
218
|
+
if (!isObject(parsed)) return;
|
|
219
|
+
message = parsed;
|
|
220
|
+
} catch {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (message.id === 0) {
|
|
224
|
+
if (message.error) {
|
|
225
|
+
fail("Codex app-server rejected initialization");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
child.stdin.write(`${JSON.stringify({ method: "initialized" })}\n`);
|
|
229
|
+
child.stdin.write(
|
|
230
|
+
`${JSON.stringify({ method: "hooks/list", id: 1, params: { cwds: [options.cwd] } })}\n`,
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (message.id === 1) {
|
|
235
|
+
if (message.error) {
|
|
236
|
+
fail("Codex app-server rejected hooks/list");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
finish(assessCodexHookList(message, options.cwd));
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
child.stdin.write(
|
|
244
|
+
`${JSON.stringify({
|
|
245
|
+
method: "initialize",
|
|
246
|
+
id: 0,
|
|
247
|
+
params: {
|
|
248
|
+
clientInfo: { name: "harnery_doctor", title: "Harnery Doctor", version: "0.0.0" },
|
|
249
|
+
},
|
|
250
|
+
})}\n`,
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
@@ -51,6 +51,14 @@ export type SessionStart = EventEnvelope<
|
|
|
51
51
|
cwd: string;
|
|
52
52
|
model?: string;
|
|
53
53
|
pid: number;
|
|
54
|
+
/** Content hash of every repo-authored instruction/config file this
|
|
55
|
+
* adapter can load for the session's working-path profile. */
|
|
56
|
+
instruction_bundle_id?: string;
|
|
57
|
+
/** Parallel hash of canonical AGENTS.md + .agents sources. This diagnoses
|
|
58
|
+
* sync drift without splitting behavior cohorts when rendered bytes match. */
|
|
59
|
+
instruction_source_id?: string;
|
|
60
|
+
instruction_profile_root?: string;
|
|
61
|
+
instruction_component_count?: number;
|
|
54
62
|
/** Present iff this session is a `workflow run` child: the run id whose
|
|
55
63
|
* transcript owns it (child env HARNERY_WORKFLOW_RUN_ID). Optional-field
|
|
56
64
|
* addition per the schema evolution rules (minor bump). */
|
|
@@ -93,6 +101,16 @@ export type UserPromptSubmit = EventEnvelope<
|
|
|
93
101
|
}
|
|
94
102
|
>;
|
|
95
103
|
|
|
104
|
+
/** Direct adapter evidence that the session is waiting for operator input. */
|
|
105
|
+
export type InteractionInputRequested = EventEnvelope<
|
|
106
|
+
"interaction.input_requested",
|
|
107
|
+
{
|
|
108
|
+
request_kind: "permission";
|
|
109
|
+
tool_name?: string;
|
|
110
|
+
reason?: string;
|
|
111
|
+
}
|
|
112
|
+
>;
|
|
113
|
+
|
|
96
114
|
export type TurnStop = EventEnvelope<
|
|
97
115
|
"turn.stop",
|
|
98
116
|
{
|
|
@@ -120,6 +138,21 @@ export type TurnStop = EventEnvelope<
|
|
|
120
138
|
}
|
|
121
139
|
>;
|
|
122
140
|
|
|
141
|
+
/** The evaluated end-turn policy outcome. One row per Stop evaluation makes
|
|
142
|
+
* the future bounce denominator explicit and keeps repeated remediation nags
|
|
143
|
+
* from being mistaken for additional human turns. */
|
|
144
|
+
export type StopVerdict = EventEnvelope<
|
|
145
|
+
"stop.verdict",
|
|
146
|
+
{
|
|
147
|
+
allow: boolean;
|
|
148
|
+
rule: string;
|
|
149
|
+
reason?: string;
|
|
150
|
+
enforcement_mode: "enforced" | "observe_only" | "exempt" | "fail_open";
|
|
151
|
+
eligible: boolean;
|
|
152
|
+
nag_delivered: boolean;
|
|
153
|
+
}
|
|
154
|
+
>;
|
|
155
|
+
|
|
123
156
|
// Session-telemetry (merge path)
|
|
124
157
|
export type CommandStart = EventEnvelope<
|
|
125
158
|
"command.start",
|
|
@@ -314,6 +347,14 @@ export type StateTaskSet = EventEnvelope<
|
|
|
314
347
|
}
|
|
315
348
|
>;
|
|
316
349
|
|
|
350
|
+
export type StateTaskState = EventEnvelope<
|
|
351
|
+
"state.task_state",
|
|
352
|
+
{
|
|
353
|
+
state: "active" | "blocked" | "done";
|
|
354
|
+
reason?: string;
|
|
355
|
+
}
|
|
356
|
+
>;
|
|
357
|
+
|
|
317
358
|
export type StateStatusChecked = EventEnvelope<
|
|
318
359
|
"state.status_checked",
|
|
319
360
|
{
|
|
@@ -486,7 +527,9 @@ export type Event =
|
|
|
486
527
|
| SubagentStart
|
|
487
528
|
| SubagentStop
|
|
488
529
|
| UserPromptSubmit
|
|
530
|
+
| InteractionInputRequested
|
|
489
531
|
| TurnStop
|
|
532
|
+
| StopVerdict
|
|
490
533
|
| CommandStart
|
|
491
534
|
| CommandOutput
|
|
492
535
|
| CommandEnd
|
|
@@ -505,6 +548,7 @@ export type Event =
|
|
|
505
548
|
| ClaimRelease
|
|
506
549
|
| ClaimConflict
|
|
507
550
|
| StateTaskSet
|
|
551
|
+
| StateTaskState
|
|
508
552
|
| StateStatusChecked
|
|
509
553
|
| StateJournalAppend
|
|
510
554
|
| StatePresenceChange
|
|
@@ -9,6 +9,7 @@ import { createHash } from "node:crypto";
|
|
|
9
9
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { resolveMachineLabel } from "../../lib/machine.ts";
|
|
12
|
+
import type { AgentActivity, TaskState } from "../agents/state/session-state.ts";
|
|
12
13
|
|
|
13
14
|
/** Mirror of the local heartbeat freshness window (commands/agents.ts). */
|
|
14
15
|
const FRESHNESS_SECS = 600;
|
|
@@ -24,6 +25,9 @@ export interface PresenceAgent {
|
|
|
24
25
|
session_id?: string;
|
|
25
26
|
platform?: string;
|
|
26
27
|
task?: string;
|
|
28
|
+
activity: AgentActivity;
|
|
29
|
+
task_state: TaskState;
|
|
30
|
+
task_state_reason?: string;
|
|
27
31
|
turn_summary?: string;
|
|
28
32
|
files_touched?: string[];
|
|
29
33
|
last_tool?: string;
|
|
@@ -78,6 +82,9 @@ export function buildPresenceBlob(coordRoot: string, now: Date = new Date()): Bu
|
|
|
78
82
|
session_id: strOr(hb.session_id),
|
|
79
83
|
platform: strOr(hb.platform),
|
|
80
84
|
task: strOr(hb.task),
|
|
85
|
+
activity: activityOrUnknown(hb.activity),
|
|
86
|
+
task_state: taskStateOrActive(hb.task_state),
|
|
87
|
+
task_state_reason: clamp(strOr(hb.task_state_reason), 160),
|
|
81
88
|
turn_summary: clamp(strOr(hb.turn_summary), 160),
|
|
82
89
|
files_touched: Array.isArray(hb.files_touched)
|
|
83
90
|
? (hb.files_touched as string[]).slice(0, MAX_FILES_PER_AGENT)
|
|
@@ -107,6 +114,9 @@ export function buildPresenceBlob(coordRoot: string, now: Date = new Date()): Bu
|
|
|
107
114
|
n: a.name ?? null,
|
|
108
115
|
k: a.kind ?? null,
|
|
109
116
|
t: a.task ?? null,
|
|
117
|
+
a: a.activity,
|
|
118
|
+
l: a.task_state,
|
|
119
|
+
r: a.task_state_reason ?? null,
|
|
110
120
|
s: a.turn_summary ?? null,
|
|
111
121
|
f: [...(a.files_touched ?? [])].sort(),
|
|
112
122
|
}));
|
|
@@ -119,6 +129,14 @@ function strOr(v: unknown): string | undefined {
|
|
|
119
129
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
function activityOrUnknown(v: unknown): AgentActivity {
|
|
133
|
+
return v === "working" || v === "needs_input" || v === "idle" ? v : "unknown";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function taskStateOrActive(v: unknown): TaskState {
|
|
137
|
+
return v === "blocked" || v === "done" ? v : "active";
|
|
138
|
+
}
|
|
139
|
+
|
|
122
140
|
function clamp(v: string | undefined, max: number): string | undefined {
|
|
123
141
|
if (!v) return undefined;
|
|
124
142
|
return v.length > max ? v.slice(0, max) : v;
|
|
@@ -263,7 +263,7 @@ export function readRemoteMachines(
|
|
|
263
263
|
machine,
|
|
264
264
|
published_at: blob.published_at,
|
|
265
265
|
age_secs: ageSecs,
|
|
266
|
-
agents: blob.agents,
|
|
266
|
+
agents: blob.agents.map(normalizePresenceAgent),
|
|
267
267
|
});
|
|
268
268
|
}
|
|
269
269
|
out.sort((a, b) => a.machine.localeCompare(b.machine));
|
|
@@ -272,3 +272,20 @@ export function readRemoteMachines(
|
|
|
272
272
|
return [];
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
|
+
|
|
276
|
+
function normalizePresenceAgent(agent: PresenceAgent): PresenceAgent {
|
|
277
|
+
const legacy = agent as Partial<PresenceAgent>;
|
|
278
|
+
return {
|
|
279
|
+
...agent,
|
|
280
|
+
activity:
|
|
281
|
+
legacy.activity === "working" ||
|
|
282
|
+
legacy.activity === "needs_input" ||
|
|
283
|
+
legacy.activity === "idle"
|
|
284
|
+
? legacy.activity
|
|
285
|
+
: "unknown",
|
|
286
|
+
task_state:
|
|
287
|
+
legacy.task_state === "blocked" || legacy.task_state === "done"
|
|
288
|
+
? legacy.task_state
|
|
289
|
+
: "active",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
createReadStream,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
2
14
|
import { homedir } from "node:os";
|
|
3
15
|
import { basename, dirname, join } from "node:path";
|
|
16
|
+
import { createInterface } from "node:readline";
|
|
4
17
|
|
|
5
18
|
/**
|
|
6
19
|
* Claude desktop app session-index access.
|
|
@@ -67,6 +80,49 @@ export interface MirrorPlan {
|
|
|
67
80
|
skippedArchived: number;
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
export type DesktopLifecycleState = "active" | "blocked" | "done";
|
|
84
|
+
|
|
85
|
+
export interface DesktopLifecycleRecord {
|
|
86
|
+
state: DesktopLifecycleState;
|
|
87
|
+
updatedAt: string;
|
|
88
|
+
instanceId: string | null;
|
|
89
|
+
sessionId: string | null;
|
|
90
|
+
reason: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type DesktopTidySource = "lifecycle" | "legacy-prefix";
|
|
94
|
+
|
|
95
|
+
export interface DesktopTidyAction {
|
|
96
|
+
entry: DesktopSessionEntry;
|
|
97
|
+
source: DesktopTidySource;
|
|
98
|
+
lifecycle: DesktopLifecycleRecord | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type DesktopTidySkipReason =
|
|
102
|
+
| "already_archived"
|
|
103
|
+
| "blocked_title"
|
|
104
|
+
| "lifecycle_active"
|
|
105
|
+
| "lifecycle_blocked"
|
|
106
|
+
| "unmatched";
|
|
107
|
+
|
|
108
|
+
export interface DesktopTidySkip {
|
|
109
|
+
entry: DesktopSessionEntry;
|
|
110
|
+
reason: DesktopTidySkipReason;
|
|
111
|
+
lifecycle: DesktopLifecycleRecord | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface DesktopTidyPlan {
|
|
115
|
+
actions: DesktopTidyAction[];
|
|
116
|
+
skipped: DesktopTidySkip[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface DesktopTidyOptions {
|
|
120
|
+
/** Restrict entries to accounts whose UUID starts with one of these prefixes. */
|
|
121
|
+
accounts?: string[];
|
|
122
|
+
/** Accept an unmatched historical `[DONE]` title as a compatibility fallback. */
|
|
123
|
+
includeLegacyPrefix?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
70
126
|
const SESSIONS_DIRNAME = "claude-code-sessions";
|
|
71
127
|
|
|
72
128
|
function isWsl(): boolean {
|
|
@@ -294,3 +350,178 @@ export function applyMirror(plan: MirrorPlan): { copied: number } {
|
|
|
294
350
|
}
|
|
295
351
|
return { copied };
|
|
296
352
|
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Read the latest durable lifecycle declaration for every Harnery session.
|
|
356
|
+
*
|
|
357
|
+
* The canonical ledger rotates into `events-*.ndjson` archives. Stream each
|
|
358
|
+
* file rather than loading the whole history into one string, then compare
|
|
359
|
+
* event timestamps because adapter replay may append an older event later.
|
|
360
|
+
* Records are indexed by both session_id and instance_id so a desktop
|
|
361
|
+
* cliSessionId can match either identity shape used by older adapters.
|
|
362
|
+
*/
|
|
363
|
+
export async function readDesktopLifecycleIndex(
|
|
364
|
+
coordRoot: string,
|
|
365
|
+
): Promise<Map<string, DesktopLifecycleRecord>> {
|
|
366
|
+
const coordDir = join(coordRoot, ".harnery");
|
|
367
|
+
const archives = safeReaddir(coordDir)
|
|
368
|
+
.filter((name) => name.startsWith("events-") && name.endsWith(".ndjson"))
|
|
369
|
+
.sort();
|
|
370
|
+
const files = [...archives, "events.ndjson"]
|
|
371
|
+
.map((name) => join(coordDir, name))
|
|
372
|
+
.filter((path) => existsSync(path));
|
|
373
|
+
|
|
374
|
+
const records = new Map<string, DesktopLifecycleRecord>();
|
|
375
|
+
const timestamps = new Map<string, number>();
|
|
376
|
+
|
|
377
|
+
for (const file of files) {
|
|
378
|
+
let lines: ReturnType<typeof createInterface> | null = null;
|
|
379
|
+
try {
|
|
380
|
+
lines = createInterface({
|
|
381
|
+
input: createReadStream(file, { encoding: "utf8" }),
|
|
382
|
+
crlfDelay: Infinity,
|
|
383
|
+
});
|
|
384
|
+
for await (const line of lines) {
|
|
385
|
+
if (!line.trim()) continue;
|
|
386
|
+
let raw: unknown;
|
|
387
|
+
try {
|
|
388
|
+
raw = JSON.parse(line);
|
|
389
|
+
} catch {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (!raw || typeof raw !== "object") continue;
|
|
393
|
+
const event = raw as Record<string, unknown>;
|
|
394
|
+
if (event.event_type !== "state.task_state" || typeof event.ts !== "string") continue;
|
|
395
|
+
const data = event.data;
|
|
396
|
+
if (!data || typeof data !== "object") continue;
|
|
397
|
+
const state = (data as Record<string, unknown>).state;
|
|
398
|
+
if (state !== "active" && state !== "blocked" && state !== "done") continue;
|
|
399
|
+
|
|
400
|
+
const instanceId = typeof event.instance_id === "string" ? event.instance_id : null;
|
|
401
|
+
const sessionId = typeof event.session_id === "string" ? event.session_id : null;
|
|
402
|
+
const reasonValue = (data as Record<string, unknown>).reason;
|
|
403
|
+
const record: DesktopLifecycleRecord = {
|
|
404
|
+
state,
|
|
405
|
+
updatedAt: event.ts,
|
|
406
|
+
instanceId,
|
|
407
|
+
sessionId,
|
|
408
|
+
reason: typeof reasonValue === "string" && reasonValue.length > 0 ? reasonValue : null,
|
|
409
|
+
};
|
|
410
|
+
const timestamp = Number.isFinite(Date.parse(event.ts)) ? Date.parse(event.ts) : 0;
|
|
411
|
+
for (const key of new Set(
|
|
412
|
+
[sessionId, instanceId].filter((value): value is string => !!value),
|
|
413
|
+
)) {
|
|
414
|
+
const priorTimestamp = timestamps.get(key);
|
|
415
|
+
if (priorTimestamp !== undefined && priorTimestamp > timestamp) continue;
|
|
416
|
+
timestamps.set(key, timestamp);
|
|
417
|
+
records.set(key, record);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// A missing or concurrently rotated event file cannot make tidy unsafe:
|
|
422
|
+
// absent lifecycle evidence leaves entries unmatched and therefore skipped.
|
|
423
|
+
} finally {
|
|
424
|
+
lines?.close();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return records;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Select desktop entries whose durable lifecycle is done. */
|
|
432
|
+
export function planTidy(
|
|
433
|
+
accounts: DesktopAccount[],
|
|
434
|
+
lifecycleBySession: ReadonlyMap<string, DesktopLifecycleRecord>,
|
|
435
|
+
opts: DesktopTidyOptions = {},
|
|
436
|
+
): DesktopTidyPlan {
|
|
437
|
+
const selectedAccounts = accounts.filter(
|
|
438
|
+
(account) =>
|
|
439
|
+
!opts.accounts?.length ||
|
|
440
|
+
opts.accounts.some((prefix) => account.accountUuid.startsWith(prefix)),
|
|
441
|
+
);
|
|
442
|
+
const actions: DesktopTidyAction[] = [];
|
|
443
|
+
const skipped: DesktopTidySkip[] = [];
|
|
444
|
+
|
|
445
|
+
for (const account of selectedAccounts) {
|
|
446
|
+
for (const entry of account.entries) {
|
|
447
|
+
const lifecycle = lifecycleForEntry(entry, lifecycleBySession);
|
|
448
|
+
if (entry.isArchived) {
|
|
449
|
+
skipped.push({ entry, reason: "already_archived", lifecycle });
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (hasLifecyclePrefix(entry.title, "BLOCKED")) {
|
|
453
|
+
skipped.push({ entry, reason: "blocked_title", lifecycle });
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (lifecycle) {
|
|
457
|
+
if (lifecycle.state === "done") {
|
|
458
|
+
actions.push({ entry, source: "lifecycle", lifecycle });
|
|
459
|
+
} else {
|
|
460
|
+
skipped.push({ entry, reason: `lifecycle_${lifecycle.state}`, lifecycle });
|
|
461
|
+
}
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (opts.includeLegacyPrefix && hasLifecyclePrefix(entry.title, "DONE")) {
|
|
465
|
+
actions.push({ entry, source: "legacy-prefix", lifecycle: null });
|
|
466
|
+
} else {
|
|
467
|
+
skipped.push({ entry, reason: "unmatched", lifecycle: null });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return { actions, skipped };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Apply a tidy plan with same-directory temp files and atomic renames. */
|
|
476
|
+
export function applyTidy(plan: DesktopTidyPlan): { archived: number; alreadyArchived: number } {
|
|
477
|
+
let archived = 0;
|
|
478
|
+
let alreadyArchived = 0;
|
|
479
|
+
for (const action of plan.actions) {
|
|
480
|
+
if (archiveEntry(action.entry.file)) archived++;
|
|
481
|
+
else alreadyArchived++;
|
|
482
|
+
}
|
|
483
|
+
return { archived, alreadyArchived };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function lifecycleForEntry(
|
|
487
|
+
entry: DesktopSessionEntry,
|
|
488
|
+
lifecycleBySession: ReadonlyMap<string, DesktopLifecycleRecord>,
|
|
489
|
+
): DesktopLifecycleRecord | null {
|
|
490
|
+
if (entry.cliSessionId) {
|
|
491
|
+
const lifecycle = lifecycleBySession.get(entry.cliSessionId);
|
|
492
|
+
if (lifecycle) return lifecycle;
|
|
493
|
+
}
|
|
494
|
+
return entry.sessionId ? (lifecycleBySession.get(entry.sessionId) ?? null) : null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function hasLifecyclePrefix(title: string | null, state: "BLOCKED" | "DONE"): boolean {
|
|
498
|
+
if (!title) return false;
|
|
499
|
+
return new RegExp(`^\\s*\\[${state}\\](?:\\s*-|\\s|$)`, "i").test(title);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function archiveEntry(file: string): boolean {
|
|
503
|
+
const text = readFileSync(file, "utf8");
|
|
504
|
+
const raw = JSON.parse(text) as unknown;
|
|
505
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
506
|
+
throw new Error(`desktop entry is not a JSON object: ${file}`);
|
|
507
|
+
}
|
|
508
|
+
const entry = raw as Record<string, unknown>;
|
|
509
|
+
if (entry.isArchived === true) return false;
|
|
510
|
+
entry.isArchived = true;
|
|
511
|
+
|
|
512
|
+
const pretty = text.includes("\n");
|
|
513
|
+
const trailingNewline = text.endsWith("\n") ? "\n" : "";
|
|
514
|
+
const next = `${JSON.stringify(entry, null, pretty ? 2 : undefined)}${trailingNewline}`;
|
|
515
|
+
const temp = join(dirname(file), `.${basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
516
|
+
try {
|
|
517
|
+
writeFileSync(temp, next, { encoding: "utf8", mode: statSync(file).mode });
|
|
518
|
+
renameSync(temp, file);
|
|
519
|
+
} finally {
|
|
520
|
+
try {
|
|
521
|
+
unlinkSync(temp);
|
|
522
|
+
} catch {
|
|
523
|
+
// rename already consumed the temp file, or creation failed before it existed
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return true;
|
|
527
|
+
}
|