harnery 0.31.6 → 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 (264) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +5 -1
  4. package/dist/commands/agents.d.ts +0 -9
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +280 -94
  7. package/dist/commands/browse-session.d.ts +21 -0
  8. package/dist/commands/browse-session.d.ts.map +1 -0
  9. package/dist/commands/browse-session.js +157 -0
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +205 -37
  12. package/dist/commands/checkpoint.js +1 -1
  13. package/dist/commands/claude-desktop.d.ts +2 -2
  14. package/dist/commands/claude-desktop.d.ts.map +1 -1
  15. package/dist/commands/claude-desktop.js +74 -3
  16. package/dist/commands/deinit.d.ts.map +1 -1
  17. package/dist/commands/deinit.js +4 -0
  18. package/dist/commands/docs.d.ts.map +1 -1
  19. package/dist/commands/docs.js +40 -0
  20. package/dist/commands/doctor.d.ts +4 -2
  21. package/dist/commands/doctor.d.ts.map +1 -1
  22. package/dist/commands/doctor.js +141 -19
  23. package/dist/commands/init.d.ts +2 -2
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +26 -5
  26. package/dist/commands/instructions.d.ts +4 -0
  27. package/dist/commands/instructions.d.ts.map +1 -0
  28. package/dist/commands/instructions.js +100 -0
  29. package/dist/commands/presence.d.ts.map +1 -1
  30. package/dist/commands/presence.js +4 -1
  31. package/dist/commands/tunnel.d.ts.map +1 -1
  32. package/dist/commands/tunnel.js +2 -3
  33. package/dist/core/agents/canonical-emit.d.ts +2 -3
  34. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  35. package/dist/core/agents/canonical-emit.js +6 -5
  36. package/dist/core/agents/cli.js +128 -89
  37. package/dist/core/agents/coord-client.d.ts +18 -3
  38. package/dist/core/agents/coord-client.d.ts.map +1 -1
  39. package/dist/core/agents/coord-client.js +5 -4
  40. package/dist/core/agents/finalization.d.ts +68 -0
  41. package/dist/core/agents/finalization.d.ts.map +1 -0
  42. package/dist/core/agents/finalization.js +443 -0
  43. package/dist/core/agents/git-hook.d.ts +51 -0
  44. package/dist/core/agents/git-hook.d.ts.map +1 -0
  45. package/dist/core/agents/git-hook.js +118 -0
  46. package/dist/core/agents/index.d.ts +1 -0
  47. package/dist/core/agents/index.d.ts.map +1 -1
  48. package/dist/core/agents/index.js +1 -0
  49. package/dist/core/agents/render/prompt-context.d.ts +12 -5
  50. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  51. package/dist/core/agents/render/prompt-context.js +70 -16
  52. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  53. package/dist/core/agents/render/session-context.js +23 -4
  54. package/dist/core/agents/rules/claim-conflict.js +3 -3
  55. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  56. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  57. package/dist/core/agents/rules/commit-conflict.js +21 -5
  58. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  59. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  60. package/dist/core/agents/rules/stop-hook.js +59 -23
  61. package/dist/core/agents/session-events.d.ts +8 -16
  62. package/dist/core/agents/session-events.d.ts.map +1 -1
  63. package/dist/core/agents/session-events.js +12 -26
  64. package/dist/core/agents/state/heartbeat-projector.d.ts +14 -0
  65. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  66. package/dist/core/agents/state/heartbeat-projector.js +98 -1
  67. package/dist/core/agents/state/heartbeat-writer.d.ts +47 -5
  68. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  69. package/dist/core/agents/state/heartbeat-writer.js +86 -17
  70. package/dist/core/agents/state/names.d.ts +22 -1
  71. package/dist/core/agents/state/names.d.ts.map +1 -1
  72. package/dist/core/agents/state/names.js +40 -2
  73. package/dist/core/agents/state/session-state.d.ts +44 -0
  74. package/dist/core/agents/state/session-state.d.ts.map +1 -0
  75. package/dist/core/agents/state/session-state.js +81 -0
  76. package/dist/core/config.d.ts +30 -2
  77. package/dist/core/config.d.ts.map +1 -1
  78. package/dist/core/config.js +74 -11
  79. package/dist/core/governor/planning.d.ts.map +1 -1
  80. package/dist/core/governor/planning.js +12 -13
  81. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  82. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  83. package/dist/core/hooks/adapter/detect.js +4 -14
  84. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  85. package/dist/core/hooks/adapter/events.js +1 -0
  86. package/dist/core/hooks/adapter/output.d.ts +1 -1
  87. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  88. package/dist/core/hooks/adapter/output.js +5 -4
  89. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  90. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  91. package/dist/core/hooks/adapter/parse.js +2 -0
  92. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  93. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  94. package/dist/core/hooks/adapter/wiring.js +32 -0
  95. package/dist/core/hooks/cli.d.ts +1 -2
  96. package/dist/core/hooks/cli.d.ts.map +1 -1
  97. package/dist/core/hooks/cli.js +258 -32
  98. package/dist/core/hooks/codex-authorization.d.ts +30 -0
  99. package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
  100. package/dist/core/hooks/codex-authorization.js +199 -0
  101. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  102. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  103. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  104. package/dist/core/hooks/effects/index.d.ts +1 -1
  105. package/dist/core/hooks/effects/index.js +3 -3
  106. package/dist/core/hooks/events/schema.d.ts +57 -1
  107. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  108. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  109. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  110. package/dist/core/hooks/resolve/owner.js +15 -0
  111. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  112. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  113. package/dist/core/hooks/resolve/transcript.js +176 -1
  114. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  115. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  116. package/dist/core/hooks/session-name-presence.js +43 -0
  117. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  118. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  119. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  120. package/dist/core/presence/blob.d.ts +4 -0
  121. package/dist/core/presence/blob.d.ts.map +1 -1
  122. package/dist/core/presence/blob.js +12 -0
  123. package/dist/core/presence/index.js +15 -1
  124. package/dist/core/work/state.d.ts +4 -5
  125. package/dist/core/work/state.d.ts.map +1 -1
  126. package/dist/core/work/state.js +5 -8
  127. package/dist/core/workflow/index.d.ts +1 -1
  128. package/dist/core/workflow/index.d.ts.map +1 -1
  129. package/dist/core/workflow/proof.d.ts +2 -2
  130. package/dist/core/workflow/proof.d.ts.map +1 -1
  131. package/dist/core/workflow/proof.js +2 -2
  132. package/dist/core/workflow/run-state.d.ts +2 -2
  133. package/dist/core/workflow/run-state.d.ts.map +1 -1
  134. package/dist/core/workflow/run-state.js +2 -2
  135. package/dist/core/workflow/types.d.ts +3 -3
  136. package/dist/core/workflow/types.d.ts.map +1 -1
  137. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  138. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  139. package/dist/core/workflow/workspaces/execution.js +5 -0
  140. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  141. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  142. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  143. package/dist/core/workflow/workspaces/local-git.js +1 -2
  144. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  145. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  146. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  147. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  148. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  149. package/dist/core/workflow/workspaces/validate.js +0 -2
  150. package/dist/lib/browser/client.d.ts +118 -1
  151. package/dist/lib/browser/client.d.ts.map +1 -1
  152. package/dist/lib/browser/client.js +435 -6
  153. package/dist/lib/browser/geometry.d.ts.map +1 -1
  154. package/dist/lib/browser/geometry.js +180 -37
  155. package/dist/lib/browser/index.d.ts +5 -1
  156. package/dist/lib/browser/index.d.ts.map +1 -1
  157. package/dist/lib/browser/index.js +5 -1
  158. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  159. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  160. package/dist/lib/browser/netscape-cookies.js +31 -0
  161. package/dist/lib/browser/proxy.d.ts +24 -0
  162. package/dist/lib/browser/proxy.d.ts.map +1 -0
  163. package/dist/lib/browser/proxy.js +84 -0
  164. package/dist/lib/browser/runts.d.ts +6 -0
  165. package/dist/lib/browser/runts.d.ts.map +1 -1
  166. package/dist/lib/browser/runts.js +21 -4
  167. package/dist/lib/browser/session-control.d.ts +112 -0
  168. package/dist/lib/browser/session-control.d.ts.map +1 -0
  169. package/dist/lib/browser/session-control.js +670 -0
  170. package/dist/lib/claude-desktop.d.ts +47 -0
  171. package/dist/lib/claude-desktop.d.ts.map +1 -1
  172. package/dist/lib/claude-desktop.js +169 -1
  173. package/dist/lib/docs-links.d.ts +108 -0
  174. package/dist/lib/docs-links.d.ts.map +1 -0
  175. package/dist/lib/docs-links.js +555 -0
  176. package/dist/lib/exec.d.ts +1 -1
  177. package/dist/lib/exec.js +1 -1
  178. package/dist/lib/identities/assume.d.ts +4 -2
  179. package/dist/lib/identities/assume.d.ts.map +1 -1
  180. package/dist/lib/identities/assume.js +15 -2
  181. package/dist/lib/instructions/bundle.d.ts +33 -0
  182. package/dist/lib/instructions/bundle.d.ts.map +1 -0
  183. package/dist/lib/instructions/bundle.js +149 -0
  184. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  185. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  186. package/dist/lib/instructions/git-hooks.js +238 -0
  187. package/dist/lib/instructions/splice.d.ts +10 -4
  188. package/dist/lib/instructions/splice.d.ts.map +1 -1
  189. package/dist/lib/instructions/splice.js +19 -11
  190. package/package.json +2 -2
  191. package/schemas/config.schema.json +32 -0
  192. package/src/commander.ts +5 -1
  193. package/src/commands/agents.ts +341 -113
  194. package/src/commands/browse-session.ts +245 -0
  195. package/src/commands/browse.ts +287 -44
  196. package/src/commands/checkpoint.ts +1 -1
  197. package/src/commands/claude-desktop.ts +106 -5
  198. package/src/commands/deinit.ts +7 -1
  199. package/src/commands/docs.ts +56 -0
  200. package/src/commands/doctor.ts +155 -21
  201. package/src/commands/init.ts +28 -9
  202. package/src/commands/instructions.ts +129 -0
  203. package/src/commands/presence.ts +7 -1
  204. package/src/commands/tunnel.ts +2 -5
  205. package/src/core/agents/canonical-emit.ts +7 -6
  206. package/src/core/agents/cli.ts +143 -89
  207. package/src/core/agents/coord-client.ts +21 -5
  208. package/src/core/agents/finalization.ts +595 -0
  209. package/src/core/agents/git-hook.ts +126 -0
  210. package/src/core/agents/index.ts +1 -0
  211. package/src/core/agents/render/prompt-context.ts +96 -16
  212. package/src/core/agents/render/session-context.ts +29 -4
  213. package/src/core/agents/rules/claim-conflict.ts +3 -3
  214. package/src/core/agents/rules/commit-conflict.ts +35 -8
  215. package/src/core/agents/rules/stop-hook.ts +71 -23
  216. package/src/core/agents/session-events.ts +14 -41
  217. package/src/core/agents/state/heartbeat-projector.ts +108 -1
  218. package/src/core/agents/state/heartbeat-writer.ts +129 -16
  219. package/src/core/agents/state/names.ts +54 -2
  220. package/src/core/agents/state/session-state.ts +131 -0
  221. package/src/core/config.ts +92 -12
  222. package/src/core/governor/planning.ts +17 -12
  223. package/src/core/hooks/adapter/detect.ts +4 -13
  224. package/src/core/hooks/adapter/events.ts +1 -0
  225. package/src/core/hooks/adapter/output.ts +9 -4
  226. package/src/core/hooks/adapter/parse.ts +3 -0
  227. package/src/core/hooks/adapter/wiring.ts +43 -0
  228. package/src/core/hooks/cli.ts +296 -27
  229. package/src/core/hooks/codex-authorization.ts +253 -0
  230. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  231. package/src/core/hooks/effects/index.ts +5 -5
  232. package/src/core/hooks/events/schema.ts +71 -0
  233. package/src/core/hooks/resolve/owner.ts +22 -1
  234. package/src/core/hooks/resolve/transcript.ts +164 -1
  235. package/src/core/hooks/session-name-presence.ts +52 -0
  236. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  237. package/src/core/presence/blob.ts +18 -0
  238. package/src/core/presence/index.ts +18 -1
  239. package/src/core/work/state.ts +8 -12
  240. package/src/core/workflow/engine.ts +2 -2
  241. package/src/core/workflow/index.ts +0 -1
  242. package/src/core/workflow/proof.ts +4 -4
  243. package/src/core/workflow/run-state.ts +5 -5
  244. package/src/core/workflow/types.ts +3 -3
  245. package/src/core/workflow/workspaces/execution.ts +11 -4
  246. package/src/core/workflow/workspaces/index.ts +0 -3
  247. package/src/core/workflow/workspaces/local-git.ts +1 -2
  248. package/src/core/workflow/workspaces/paths.ts +2 -2
  249. package/src/core/workflow/workspaces/types.ts +0 -9
  250. package/src/core/workflow/workspaces/validate.ts +1 -5
  251. package/src/lib/browser/client.ts +528 -6
  252. package/src/lib/browser/geometry.ts +197 -37
  253. package/src/lib/browser/index.ts +39 -0
  254. package/src/lib/browser/netscape-cookies.ts +39 -0
  255. package/src/lib/browser/proxy.ts +105 -0
  256. package/src/lib/browser/runts.ts +27 -3
  257. package/src/lib/browser/session-control.ts +892 -0
  258. package/src/lib/claude-desktop.ts +232 -1
  259. package/src/lib/docs-links.ts +674 -0
  260. package/src/lib/exec.ts +1 -1
  261. package/src/lib/identities/assume.ts +22 -1
  262. package/src/lib/instructions/bundle.ts +199 -0
  263. package/src/lib/instructions/git-hooks.ts +259 -0
  264. package/src/lib/instructions/splice.ts +41 -11
@@ -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
+ }