harnery 0.41.0 → 0.42.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 (212) hide show
  1. package/dist/commander.js +3 -0
  2. package/dist/commands/agents.d.ts.map +1 -1
  3. package/dist/commands/agents.js +9 -1
  4. package/dist/commands/artifacts.d.ts.map +1 -1
  5. package/dist/commands/artifacts.js +113 -12
  6. package/dist/commands/browse.d.ts +164 -0
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +263 -51
  9. package/dist/commands/claude-desktop.d.ts.map +1 -1
  10. package/dist/commands/claude-desktop.js +52 -0
  11. package/dist/commands/disk.d.ts +6 -0
  12. package/dist/commands/disk.d.ts.map +1 -0
  13. package/dist/commands/disk.js +80 -0
  14. package/dist/commands/doctor.d.ts +8 -0
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +48 -1
  17. package/dist/commands/files.d.ts +16 -0
  18. package/dist/commands/files.d.ts.map +1 -1
  19. package/dist/commands/files.js +54 -2
  20. package/dist/commands/init.d.ts +2 -2
  21. package/dist/commands/init.d.ts.map +1 -1
  22. package/dist/commands/init.js +43 -9
  23. package/dist/commands/rm.d.ts +4 -0
  24. package/dist/commands/rm.d.ts.map +1 -0
  25. package/dist/commands/rm.js +87 -0
  26. package/dist/commands/servers.d.ts +7 -0
  27. package/dist/commands/servers.d.ts.map +1 -0
  28. package/dist/commands/servers.js +394 -0
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +82 -2
  31. package/dist/core/adapters/profiles.d.ts +9 -0
  32. package/dist/core/adapters/profiles.d.ts.map +1 -1
  33. package/dist/core/adapters/profiles.js +22 -1
  34. package/dist/core/adapters/types.d.ts +9 -0
  35. package/dist/core/adapters/types.d.ts.map +1 -1
  36. package/dist/core/agents/cli.js +13 -6
  37. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/session-context.js +2 -0
  39. package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
  40. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  41. package/dist/core/agents/state/live-coordination-view.js +60 -0
  42. package/dist/core/artifacts/delivery-card.js +9 -2
  43. package/dist/core/artifacts/index.d.ts +140 -4
  44. package/dist/core/artifacts/index.d.ts.map +1 -1
  45. package/dist/core/artifacts/index.js +522 -49
  46. package/dist/core/config.d.ts +68 -11
  47. package/dist/core/config.d.ts.map +1 -1
  48. package/dist/core/config.js +128 -3
  49. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  50. package/dist/core/diagnostics/bundle.js +2 -2
  51. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  52. package/dist/core/events/v3/producers/recorder.js +16 -0
  53. package/dist/core/events/v3/projection.js +9 -1
  54. package/dist/core/hooks/adapter/events.d.ts +17 -0
  55. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  56. package/dist/core/hooks/adapter/events.js +5 -0
  57. package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
  58. package/dist/core/hooks/adapter/wiring.d.ts +37 -7
  59. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  60. package/dist/core/hooks/adapter/wiring.js +62 -4
  61. package/dist/core/hooks/cli.js +45 -6
  62. package/dist/core/hooks/run-markers.d.ts +37 -0
  63. package/dist/core/hooks/run-markers.d.ts.map +1 -0
  64. package/dist/core/hooks/run-markers.js +109 -0
  65. package/dist/core/servers/index.d.ts +211 -0
  66. package/dist/core/servers/index.d.ts.map +1 -0
  67. package/dist/core/servers/index.js +560 -0
  68. package/dist/core/servers/net.d.ts +58 -0
  69. package/dist/core/servers/net.d.ts.map +1 -0
  70. package/dist/core/servers/net.js +270 -0
  71. package/dist/core/servers/tunnels.d.ts +14 -0
  72. package/dist/core/servers/tunnels.d.ts.map +1 -0
  73. package/dist/core/servers/tunnels.js +33 -0
  74. package/dist/core/storage/builtins.js +1 -0
  75. package/dist/core/workflow/engine.d.ts.map +1 -1
  76. package/dist/core/workflow/engine.js +30 -1
  77. package/dist/core/workflow/sandbox-projection.d.ts +13 -2
  78. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
  79. package/dist/core/workflow/sandbox-projection.js +23 -0
  80. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  81. package/dist/core/workflow/spawn-claude.js +6 -1
  82. package/dist/core/workflow/spawn-codex.d.ts +2 -0
  83. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  84. package/dist/core/workflow/spawn-codex.js +20 -1
  85. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  86. package/dist/core/workflow/spawn-cursor.js +6 -1
  87. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  88. package/dist/core/workflow/spawn-opencode.js +4 -1
  89. package/dist/core/workflow/types.d.ts +47 -2
  90. package/dist/core/workflow/types.d.ts.map +1 -1
  91. package/dist/core/workflow/worker-access.d.ts +92 -0
  92. package/dist/core/workflow/worker-access.d.ts.map +1 -0
  93. package/dist/core/workflow/worker-access.js +450 -0
  94. package/dist/lib/browser/batch-steps.d.ts +18 -0
  95. package/dist/lib/browser/batch-steps.d.ts.map +1 -0
  96. package/dist/lib/browser/batch-steps.js +82 -0
  97. package/dist/lib/browser/collect/collector.d.ts +99 -0
  98. package/dist/lib/browser/collect/collector.d.ts.map +1 -0
  99. package/dist/lib/browser/collect/collector.js +202 -0
  100. package/dist/lib/browser/collect/fields.d.ts +31 -0
  101. package/dist/lib/browser/collect/fields.d.ts.map +1 -0
  102. package/dist/lib/browser/collect/fields.js +75 -0
  103. package/dist/lib/browser/collect/index.d.ts +6 -0
  104. package/dist/lib/browser/collect/index.d.ts.map +1 -0
  105. package/dist/lib/browser/collect/index.js +5 -0
  106. package/dist/lib/browser/collect/page-driver.d.ts +14 -0
  107. package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
  108. package/dist/lib/browser/collect/page-driver.js +306 -0
  109. package/dist/lib/browser/collect/presets.d.ts +52 -0
  110. package/dist/lib/browser/collect/presets.d.ts.map +1 -0
  111. package/dist/lib/browser/collect/presets.js +176 -0
  112. package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
  113. package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
  114. package/dist/lib/browser/collect/scroll-plan.js +118 -0
  115. package/dist/lib/browser/index.d.ts +1 -1
  116. package/dist/lib/browser/index.d.ts.map +1 -1
  117. package/dist/lib/browser/page-review-judge.d.ts +2 -0
  118. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  119. package/dist/lib/browser/page-review-judge.js +13 -0
  120. package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
  121. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  122. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  123. package/dist/lib/browser/qa-run.js +1 -0
  124. package/dist/lib/browser/windows-chrome.d.ts +59 -0
  125. package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
  126. package/dist/lib/browser/windows-chrome.js +179 -0
  127. package/dist/lib/claude-desktop-share.d.ts +136 -0
  128. package/dist/lib/claude-desktop-share.d.ts.map +1 -0
  129. package/dist/lib/claude-desktop-share.js +394 -0
  130. package/dist/lib/disk-usage.d.ts +50 -0
  131. package/dist/lib/disk-usage.d.ts.map +1 -0
  132. package/dist/lib/disk-usage.js +303 -0
  133. package/dist/lib/docs-lint.d.ts +6 -0
  134. package/dist/lib/docs-lint.d.ts.map +1 -1
  135. package/dist/lib/docs-lint.js +8 -3
  136. package/dist/lib/guarded-remove.d.ts +34 -0
  137. package/dist/lib/guarded-remove.d.ts.map +1 -0
  138. package/dist/lib/guarded-remove.js +310 -0
  139. package/dist/lib/instructions/templates.js +8 -8
  140. package/dist/lib/tunnel/error-page.d.ts +1 -1
  141. package/dist/lib/tunnel/error-page.d.ts.map +1 -1
  142. package/dist/lib/tunnel/error-page.js +19 -8
  143. package/dist/lib/tunnel/gate.js +20 -1
  144. package/dist/lib/tunnel/path-scope.d.ts +22 -0
  145. package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
  146. package/dist/lib/tunnel/path-scope.js +62 -0
  147. package/dist/lib/tunnel/state.d.ts +5 -0
  148. package/dist/lib/tunnel/state.d.ts.map +1 -1
  149. package/dist/lib/tunnel/state.js +17 -3
  150. package/package.json +6 -1
  151. package/schemas/config.schema.json +91 -0
  152. package/src/commander.ts +19 -0
  153. package/src/commands/agents.ts +14 -0
  154. package/src/commands/artifacts.ts +171 -19
  155. package/src/commands/browse.ts +400 -57
  156. package/src/commands/claude-desktop.ts +61 -0
  157. package/src/commands/disk.ts +114 -0
  158. package/src/commands/doctor.ts +52 -1
  159. package/src/commands/files.ts +93 -2
  160. package/src/commands/init.ts +55 -5
  161. package/src/commands/rm.ts +115 -0
  162. package/src/commands/servers.ts +506 -0
  163. package/src/commands/tunnel.ts +102 -2
  164. package/src/core/adapters/profiles.ts +22 -1
  165. package/src/core/adapters/types.ts +10 -0
  166. package/src/core/agents/cli.ts +14 -6
  167. package/src/core/agents/render/session-context.ts +1 -0
  168. package/src/core/agents/state/live-coordination-view.ts +73 -0
  169. package/src/core/artifacts/delivery-card.ts +10 -2
  170. package/src/core/artifacts/index.ts +697 -44
  171. package/src/core/config.ts +183 -4
  172. package/src/core/diagnostics/bundle.ts +2 -1
  173. package/src/core/events/v3/producers/recorder.ts +16 -0
  174. package/src/core/events/v3/projection.ts +11 -1
  175. package/src/core/hooks/adapter/events.ts +19 -0
  176. package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
  177. package/src/core/hooks/adapter/wiring.ts +116 -8
  178. package/src/core/hooks/cli.ts +47 -6
  179. package/src/core/hooks/run-markers.ts +132 -0
  180. package/src/core/servers/index.ts +712 -0
  181. package/src/core/servers/net.ts +283 -0
  182. package/src/core/servers/tunnels.ts +39 -0
  183. package/src/core/storage/builtins.ts +1 -0
  184. package/src/core/workflow/engine.ts +38 -1
  185. package/src/core/workflow/sandbox-projection.ts +38 -2
  186. package/src/core/workflow/spawn-claude.ts +5 -1
  187. package/src/core/workflow/spawn-codex.ts +18 -1
  188. package/src/core/workflow/spawn-cursor.ts +5 -1
  189. package/src/core/workflow/spawn-opencode.ts +4 -1
  190. package/src/core/workflow/types.ts +62 -2
  191. package/src/core/workflow/worker-access.ts +525 -0
  192. package/src/lib/browser/batch-steps.ts +79 -0
  193. package/src/lib/browser/collect/collector.ts +306 -0
  194. package/src/lib/browser/collect/fields.ts +87 -0
  195. package/src/lib/browser/collect/index.ts +47 -0
  196. package/src/lib/browser/collect/page-driver.ts +327 -0
  197. package/src/lib/browser/collect/presets.ts +237 -0
  198. package/src/lib/browser/collect/scroll-plan.ts +179 -0
  199. package/src/lib/browser/index.ts +1 -0
  200. package/src/lib/browser/page-review-judge.ts +16 -0
  201. package/src/lib/browser/qa-run-contracts.ts +11 -0
  202. package/src/lib/browser/qa-run.ts +1 -0
  203. package/src/lib/browser/windows-chrome.ts +227 -0
  204. package/src/lib/claude-desktop-share.ts +511 -0
  205. package/src/lib/disk-usage.ts +347 -0
  206. package/src/lib/docs-lint.ts +8 -3
  207. package/src/lib/guarded-remove.ts +376 -0
  208. package/src/lib/instructions/templates.ts +8 -8
  209. package/src/lib/tunnel/error-page.ts +25 -9
  210. package/src/lib/tunnel/gate.ts +22 -1
  211. package/src/lib/tunnel/path-scope.ts +73 -0
  212. package/src/lib/tunnel/state.ts +20 -3
@@ -0,0 +1,511 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import {
3
+ closeSync,
4
+ copyFileSync,
5
+ cpSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ readSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { type DesktopAccount, type DesktopSessionEntry, listAccounts } from "./claude-desktop.ts";
20
+
21
+ /**
22
+ * Cross-machine Claude desktop session sharing through a synced folder.
23
+ *
24
+ * The desktop app's sidebar (see claude-desktop.ts) and the transcripts it
25
+ * resumes from (`~/.claude/projects/<slug>/<cliSessionId>.jsonl`) are both
26
+ * machine-local. A user with two computers therefore sees only the sessions
27
+ * started on the one in front of them. `share` closes that gap through any
28
+ * folder both machines sync (iCloud Drive, Dropbox, a network share):
29
+ *
30
+ * <shareDir>/<machine>/manifest.json { machine, home, exported_at }
31
+ * <shareDir>/<machine>/sessions/<cliId>/entry.json
32
+ * <shareDir>/<machine>/sessions/<cliId>/transcript.jsonl
33
+ * <shareDir>/<machine>/sessions/<cliId>/sidecar/ (optional per-session dir)
34
+ *
35
+ * Export publishes this machine's recent sessions under its own directory;
36
+ * import reads every other machine's directory and makes each session
37
+ * listed and resumable here. Each machine writes only its own directory, so
38
+ * the synced folder never has two writers for one file.
39
+ *
40
+ * Transcripts are append-only, which gives a safe merge rule: a remote copy
41
+ * replaces the local one only when the local file is missing or is a strict
42
+ * byte prefix of the remote (the other machine continued the conversation).
43
+ * A local transcript that is longer, diverged, or written in the last few
44
+ * minutes (a live session here) is never overwritten.
45
+ *
46
+ * A synced folder can list a file before its bytes arrive (an iCloud
47
+ * "dataless" placeholder). Reading one blocks until the download finishes, or
48
+ * fails outright, so such sessions are skipped as "not-downloaded", a download
49
+ * is requested, and the next pass picks them up. One session that still fails
50
+ * to copy is reported under `failed` and never stops the rest of the pass.
51
+ */
52
+
53
+ export interface ShareOptions {
54
+ shareDir: string;
55
+ machine: string;
56
+ /** Only sessions active within this many days. */
57
+ days: number;
58
+ /** Desktop data dir (the one holding claude-code-sessions/). */
59
+ dataDir: string;
60
+ /** Account whose sidebar receives imported entries. */
61
+ targetAccountUuid: string;
62
+ /** Override for tests. */
63
+ home?: string;
64
+ /** Override for tests (ms since epoch). */
65
+ now?: number;
66
+ /** Local transcripts modified this recently count as live (ms). */
67
+ liveWindowMs?: number;
68
+ /** Override for tests: which of these paths are not downloaded yet. */
69
+ findPlaceholders?: (paths: string[]) => Set<string>;
70
+ /** Override for tests: ask the sync client to download these paths. */
71
+ requestDownload?: (paths: string[]) => void;
72
+ }
73
+
74
+ export interface ExportAction {
75
+ cliSessionId: string;
76
+ title: string | null;
77
+ transcript: string;
78
+ bytes: number;
79
+ }
80
+
81
+ export type ImportKind = "new" | "updated" | "entry-only";
82
+
83
+ export interface ImportAction {
84
+ kind: ImportKind;
85
+ machine: string;
86
+ cliSessionId: string;
87
+ title: string | null;
88
+ cwd: string;
89
+ /** Remote transcript to copy, or null when only the entry changes. */
90
+ transcriptFrom: string | null;
91
+ transcriptTo: string;
92
+ sidecarFrom: string | null;
93
+ entryFrom: string;
94
+ /** Local entry to patch (existing session) or create (new session). */
95
+ entryTo: string;
96
+ entryExists: boolean;
97
+ remoteLastActivityAt: number | null;
98
+ }
99
+
100
+ export interface ImportSkip {
101
+ machine: string;
102
+ cliSessionId: string;
103
+ title: string | null;
104
+ reason:
105
+ | "up-to-date"
106
+ | "local-newer"
107
+ | "diverged"
108
+ | "live-here"
109
+ | "missing-cwd"
110
+ | "unreadable"
111
+ | "not-downloaded";
112
+ }
113
+
114
+ export interface SharePlan {
115
+ exports: ExportAction[];
116
+ imports: ImportAction[];
117
+ skips: ImportSkip[];
118
+ machines: string[];
119
+ }
120
+
121
+ export interface ImportFailure {
122
+ machine: string;
123
+ cliSessionId: string;
124
+ title: string | null;
125
+ error: string;
126
+ }
127
+
128
+ /** macOS st_flags bit for a file whose contents live only in the cloud. */
129
+ const SF_DATALESS = 0x40000000;
130
+
131
+ /**
132
+ * Paths whose bytes are not on this disk yet. On macOS this reads st_flags
133
+ * (Node's stat does not expose them) in one `stat` call; everywhere else only
134
+ * the `.<name>.icloud` stub convention applies.
135
+ */
136
+ export function findPlaceholders(paths: string[]): Set<string> {
137
+ const out = new Set<string>();
138
+ const present: string[] = [];
139
+ for (const p of paths) {
140
+ if (existsSync(p)) present.push(p);
141
+ else if (existsSync(join(dirname(p), `.${p.slice(dirname(p).length + 1)}.icloud`))) out.add(p);
142
+ }
143
+ if (process.platform !== "darwin" || present.length === 0) return out;
144
+ const r = spawnSync("/usr/bin/stat", ["-f", "%Xf %N", ...present], { encoding: "utf8" });
145
+ for (const line of (r.stdout ?? "").split("\n")) {
146
+ const sp = line.indexOf(" ");
147
+ if (sp < 0) continue;
148
+ const flags = Number.parseInt(line.slice(0, sp), 16);
149
+ if (Number.isFinite(flags) && (flags & SF_DATALESS) !== 0) out.add(line.slice(sp + 1));
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** Best-effort, fire-and-forget: ask iCloud Drive to fetch these paths. */
155
+ export function requestDownload(paths: string[]): void {
156
+ if (process.platform !== "darwin") return;
157
+ for (const p of paths) {
158
+ try {
159
+ spawn("/usr/bin/brctl", ["download", p], { stdio: "ignore", detached: true }).unref();
160
+ } catch {
161
+ // Not an iCloud folder or brctl missing; the next pass retries.
162
+ }
163
+ }
164
+ }
165
+
166
+ export function defaultShareDir(home = homedir()): string | null {
167
+ if (process.platform !== "darwin") return null;
168
+ const icloud = join(home, "Library", "Mobile Documents", "com~apple~CloudDocs");
169
+ return existsSync(icloud) ? join(icloud, "Harnery", "claude-sessions") : null;
170
+ }
171
+
172
+ /** Claude Code's per-project transcript folder name for a working directory. */
173
+ export function projectSlug(cwd: string): string {
174
+ return cwd.replace(/[^a-zA-Z0-9]/g, "-");
175
+ }
176
+
177
+ function transcriptPath(home: string, cwd: string, cliSessionId: string): string {
178
+ return join(home, ".claude", "projects", projectSlug(cwd), `${cliSessionId}.jsonl`);
179
+ }
180
+
181
+ function safeStat(p: string) {
182
+ try {
183
+ return statSync(p);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ function safeReaddir(dir: string): string[] {
190
+ try {
191
+ return readdirSync(dir);
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
197
+ function readJson(p: string): Record<string, unknown> | null {
198
+ try {
199
+ const v = JSON.parse(readFileSync(p, "utf8"));
200
+ return typeof v === "object" && v !== null ? (v as Record<string, unknown>) : null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /** Rewrite a path from the remote home to the local one (same layout, different user). */
207
+ export function remapHome(p: string, remoteHome: string | null, localHome: string): string {
208
+ if (!remoteHome || remoteHome === localHome) return p;
209
+ if (p === remoteHome) return localHome;
210
+ return p.startsWith(`${remoteHome}/`) ? localHome + p.slice(remoteHome.length) : p;
211
+ }
212
+
213
+ /** True when `prefixFile`'s bytes are exactly the start of `fullFile`. */
214
+ export function isBytePrefix(prefixFile: string, fullFile: string): boolean {
215
+ const a = safeStat(prefixFile);
216
+ const b = safeStat(fullFile);
217
+ if (!a || !b || a.size > b.size) return false;
218
+ const chunk = 1 << 20;
219
+ const fa = openSync(prefixFile, "r");
220
+ const fb = openSync(fullFile, "r");
221
+ try {
222
+ const ba = Buffer.alloc(chunk);
223
+ const bb = Buffer.alloc(chunk);
224
+ let pos = 0;
225
+ while (pos < a.size) {
226
+ const n = Math.min(chunk, a.size - pos);
227
+ readSync(fa, ba, 0, n, pos);
228
+ readSync(fb, bb, 0, n, pos);
229
+ if (ba.compare(bb, 0, n, 0, n) !== 0) return false;
230
+ pos += n;
231
+ }
232
+ return true;
233
+ } finally {
234
+ closeSync(fa);
235
+ closeSync(fb);
236
+ }
237
+ }
238
+
239
+ /** Newest non-archived entry per cliSessionId across every local account. */
240
+ function localEntriesById(accounts: DesktopAccount[]): Map<string, DesktopSessionEntry> {
241
+ const out = new Map<string, DesktopSessionEntry>();
242
+ for (const a of accounts) {
243
+ for (const e of a.entries) {
244
+ if (!e.cliSessionId) continue;
245
+ const prev = out.get(e.cliSessionId);
246
+ if (!prev || (e.lastActivityAt ?? 0) > (prev.lastActivityAt ?? 0)) out.set(e.cliSessionId, e);
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /**
253
+ * Env directory new entries go into: the one holding the account's newest
254
+ * entry, else any env directory the app already created for the account.
255
+ */
256
+ function targetEnvDir(account: DesktopAccount | undefined): string | null {
257
+ if (!account) return null;
258
+ const newest = account.entries[0];
259
+ if (newest) return join(account.path, newest.envId);
260
+ const env = safeReaddir(account.path).find((d) => safeStat(join(account.path, d))?.isDirectory());
261
+ return env ? join(account.path, env) : null;
262
+ }
263
+
264
+ export function planShare(opts: ShareOptions): SharePlan {
265
+ const home = opts.home ?? homedir();
266
+ const now = opts.now ?? Date.now();
267
+ const liveWindow = opts.liveWindowMs ?? 3 * 60_000;
268
+ const cutoff = now - opts.days * 86_400_000;
269
+ const accounts = listAccounts(opts.dataDir);
270
+ const local = localEntriesById(accounts);
271
+
272
+ // Export: this machine's recent sessions whose transcript exists here and
273
+ // differs from what was last published.
274
+ const exports: ExportAction[] = [];
275
+ const mine = join(opts.shareDir, opts.machine, "sessions");
276
+ for (const e of local.values()) {
277
+ if (e.isArchived || !e.cwd || !e.cliSessionId) continue;
278
+ if ((e.lastActivityAt ?? 0) < cutoff) continue;
279
+ const t = transcriptPath(home, e.cwd, e.cliSessionId);
280
+ const st = safeStat(t);
281
+ if (!st) continue;
282
+ const published = safeStat(join(mine, e.cliSessionId, "transcript.jsonl"));
283
+ const publishedEntry = readJson(join(mine, e.cliSessionId, "entry.json"));
284
+ const entryCurrent =
285
+ publishedEntry?.lastActivityAt === e.lastActivityAt && publishedEntry?.title === e.title;
286
+ if (published && published.size === st.size && entryCurrent) continue;
287
+ exports.push({ cliSessionId: e.cliSessionId, title: e.title, transcript: t, bytes: st.size });
288
+ }
289
+
290
+ // Import: every other machine's published sessions.
291
+ const imports: ImportAction[] = [];
292
+ const skips: ImportSkip[] = [];
293
+ const machines: string[] = [];
294
+ const target = accounts.find((a) => a.accountUuid === opts.targetAccountUuid);
295
+ const envDir =
296
+ targetEnvDir(target) ??
297
+ join(opts.dataDir, "claude-code-sessions", opts.targetAccountUuid, "shared");
298
+ for (const machine of safeReaddir(opts.shareDir)) {
299
+ if (machine === opts.machine) continue;
300
+ const mdir = join(opts.shareDir, machine);
301
+ const manifest = readJson(join(mdir, "manifest.json"));
302
+ if (!manifest) continue;
303
+ machines.push(machine);
304
+ const remoteHome = typeof manifest.home === "string" ? manifest.home : null;
305
+ const ids = safeReaddir(join(mdir, "sessions")).filter((id) => !id.startsWith("."));
306
+ const files = ids.flatMap((id) => [
307
+ join(mdir, "sessions", id, "entry.json"),
308
+ join(mdir, "sessions", id, "transcript.jsonl"),
309
+ ]);
310
+ const placeholders = (opts.findPlaceholders ?? findPlaceholders)(files);
311
+ const toDownload: string[] = [];
312
+ for (const id of ids) {
313
+ const sdir = join(mdir, "sessions", id);
314
+ const entryFrom = join(sdir, "entry.json");
315
+ if (placeholders.has(entryFrom) || placeholders.has(join(sdir, "transcript.jsonl"))) {
316
+ toDownload.push(sdir);
317
+ skips.push({ machine, cliSessionId: id, title: null, reason: "not-downloaded" });
318
+ continue;
319
+ }
320
+ const remote = readJson(entryFrom);
321
+ const remoteTranscript = join(sdir, "transcript.jsonl");
322
+ const title = typeof remote?.title === "string" ? remote.title : null;
323
+ if (!remote || typeof remote.cwd !== "string" || !safeStat(remoteTranscript)) {
324
+ skips.push({ machine, cliSessionId: id, title, reason: "unreadable" });
325
+ continue;
326
+ }
327
+ const remoteLast = typeof remote.lastActivityAt === "number" ? remote.lastActivityAt : null;
328
+ if (remote.isArchived === true || (remoteLast ?? 0) < cutoff) continue;
329
+ const cwd = remapHome(remote.cwd, remoteHome, home);
330
+ if (!existsSync(cwd)) {
331
+ skips.push({ machine, cliSessionId: id, title, reason: "missing-cwd" });
332
+ continue;
333
+ }
334
+ const existing = local.get(id);
335
+ const transcriptTo = transcriptPath(home, cwd, id);
336
+ const localSt = safeStat(transcriptTo);
337
+ const remoteSt = safeStat(remoteTranscript);
338
+ let copyTranscript = false;
339
+ if (!localSt) {
340
+ copyTranscript = true;
341
+ } else if (remoteSt && remoteSt.size > localSt.size) {
342
+ if (now - localSt.mtimeMs < liveWindow) {
343
+ skips.push({ machine, cliSessionId: id, title, reason: "live-here" });
344
+ continue;
345
+ }
346
+ if (!isBytePrefix(transcriptTo, remoteTranscript)) {
347
+ skips.push({ machine, cliSessionId: id, title, reason: "diverged" });
348
+ continue;
349
+ }
350
+ copyTranscript = true;
351
+ } else if (remoteSt && remoteSt.size < localSt.size && existing) {
352
+ // Continued here since the other machine last saw it; keep ours.
353
+ skips.push({ machine, cliSessionId: id, title, reason: "local-newer" });
354
+ continue;
355
+ }
356
+ const entryStale =
357
+ !existing ||
358
+ (copyTranscript && (remoteLast ?? 0) > (existing.lastActivityAt ?? 0)) ||
359
+ (copyTranscript && title !== existing.title);
360
+ if (!copyTranscript && !entryStale) {
361
+ skips.push({ machine, cliSessionId: id, title, reason: "up-to-date" });
362
+ continue;
363
+ }
364
+ const sidecar = join(sdir, "sidecar");
365
+ imports.push({
366
+ kind: !existing ? (copyTranscript ? "new" : "entry-only") : "updated",
367
+ machine,
368
+ cliSessionId: id,
369
+ title,
370
+ cwd,
371
+ transcriptFrom: copyTranscript ? remoteTranscript : null,
372
+ transcriptTo,
373
+ sidecarFrom: copyTranscript && existsSync(sidecar) ? sidecar : null,
374
+ entryFrom,
375
+ entryTo: existing
376
+ ? existing.file
377
+ : join(
378
+ envDir,
379
+ `${typeof remote.sessionId === "string" ? remote.sessionId : `local_${id}`}.json`,
380
+ ),
381
+ entryExists: Boolean(existing),
382
+ remoteLastActivityAt: remoteLast,
383
+ });
384
+ }
385
+ if (toDownload.length > 0) (opts.requestDownload ?? requestDownload)(toDownload);
386
+ }
387
+ return { exports, imports, skips, machines };
388
+ }
389
+
390
+ /** Write via a temp file + rename so a syncing reader never sees half a file. */
391
+ function atomicCopy(from: string, to: string): void {
392
+ mkdirSync(dirname(to), { recursive: true });
393
+ const tmp = `${to}.harnery-tmp`;
394
+ try {
395
+ copyFileSync(from, tmp);
396
+ renameSync(tmp, to);
397
+ } catch (err) {
398
+ rmSync(tmp, { force: true });
399
+ throw err;
400
+ }
401
+ }
402
+
403
+ function atomicWrite(to: string, content: string): void {
404
+ mkdirSync(dirname(to), { recursive: true });
405
+ const tmp = `${to}.harnery-tmp`;
406
+ writeFileSync(tmp, content);
407
+ renameSync(tmp, to);
408
+ }
409
+
410
+ export function applyShare(
411
+ plan: SharePlan,
412
+ opts: ShareOptions,
413
+ ): { exported: number; imported: number; failed: ImportFailure[] } {
414
+ const home = opts.home ?? homedir();
415
+ const mdir = join(opts.shareDir, opts.machine);
416
+ const accounts = listAccounts(opts.dataDir);
417
+ const local = localEntriesById(accounts);
418
+
419
+ for (const x of plan.exports) {
420
+ const e = local.get(x.cliSessionId);
421
+ if (!e) continue;
422
+ const sdir = join(mdir, "sessions", x.cliSessionId);
423
+ atomicCopy(x.transcript, join(sdir, "transcript.jsonl"));
424
+ const sidecar = join(dirname(x.transcript), x.cliSessionId);
425
+ if (existsSync(sidecar))
426
+ cpSync(sidecar, join(sdir, "sidecar"), { recursive: true, force: true });
427
+ atomicCopy(e.file, join(sdir, "entry.json"));
428
+ }
429
+ atomicWrite(
430
+ join(mdir, "manifest.json"),
431
+ `${JSON.stringify({ machine: opts.machine, home, exported_at: new Date(opts.now ?? Date.now()).toISOString() }, null, 2)}\n`,
432
+ );
433
+
434
+ let imported = 0;
435
+ const failed: ImportFailure[] = [];
436
+ for (const x of plan.imports) {
437
+ try {
438
+ if (importOne(x, opts, home)) imported++;
439
+ } catch (err) {
440
+ failed.push({
441
+ machine: x.machine,
442
+ cliSessionId: x.cliSessionId,
443
+ title: x.title,
444
+ error: err instanceof Error ? err.message : String(err),
445
+ });
446
+ }
447
+ }
448
+ return { exported: plan.exports.length, imported, failed };
449
+ }
450
+
451
+ function importOne(x: ImportAction, opts: ShareOptions, home: string): boolean {
452
+ const remote = readJson(x.entryFrom);
453
+ if (!remote) return false;
454
+ const remoteManifest = readJson(join(opts.shareDir, x.machine, "manifest.json"));
455
+ const remoteHome = typeof remoteManifest?.home === "string" ? remoteManifest.home : null;
456
+ if (x.transcriptFrom) atomicCopy(x.transcriptFrom, x.transcriptTo);
457
+ if (x.sidecarFrom) {
458
+ cpSync(x.sidecarFrom, join(dirname(x.transcriptTo), x.cliSessionId), {
459
+ recursive: true,
460
+ force: true,
461
+ });
462
+ }
463
+ let entry: Record<string, unknown>;
464
+ if (x.entryExists) {
465
+ entry = readJson(x.entryTo) ?? {};
466
+ if (typeof remote.title === "string") entry.title = remote.title;
467
+ if (typeof remote.lastActivityAt === "number") entry.lastActivityAt = remote.lastActivityAt;
468
+ } else {
469
+ entry = { ...remote, isArchived: false };
470
+ for (const k of ["cwd", "originCwd"]) {
471
+ if (typeof entry[k] === "string") entry[k] = remapHome(entry[k] as string, remoteHome, home);
472
+ }
473
+ if (Array.isArray(entry.gitAnchors)) {
474
+ entry.gitAnchors = (entry.gitAnchors as Record<string, unknown>[]).map((g) => ({
475
+ ...g,
476
+ gitRoot: typeof g.gitRoot === "string" ? remapHome(g.gitRoot, remoteHome, home) : g.gitRoot,
477
+ commonDir:
478
+ typeof g.commonDir === "string" ? remapHome(g.commonDir, remoteHome, home) : g.commonDir,
479
+ }));
480
+ }
481
+ }
482
+ atomicWrite(x.entryTo, `${JSON.stringify(entry)}\n`);
483
+ return true;
484
+ }
485
+
486
+ export function shareSummary(plan: SharePlan) {
487
+ return {
488
+ machines: plan.machines,
489
+ export: plan.exports.map((x) => ({
490
+ title: x.title,
491
+ cli_session_id: x.cliSessionId,
492
+ bytes: x.bytes,
493
+ })),
494
+ import: plan.imports.map((x) => ({
495
+ kind: x.kind,
496
+ from_machine: x.machine,
497
+ title: x.title,
498
+ cli_session_id: x.cliSessionId,
499
+ })),
500
+ skipped: plan.skips
501
+ .filter((s) => s.reason !== "up-to-date" && s.reason !== "not-downloaded")
502
+ .map((s) => ({
503
+ from_machine: s.machine,
504
+ title: s.title,
505
+ cli_session_id: s.cliSessionId,
506
+ reason: s.reason,
507
+ })),
508
+ not_downloaded: plan.skips.filter((s) => s.reason === "not-downloaded").length,
509
+ up_to_date: plan.skips.filter((s) => s.reason === "up-to-date").length,
510
+ };
511
+ }