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
@@ -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 { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
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
+ }