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.
- package/dist/commander.js +3 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +9 -1
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +113 -12
- package/dist/commands/browse.d.ts +164 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +263 -51
- package/dist/commands/claude-desktop.d.ts.map +1 -1
- package/dist/commands/claude-desktop.js +52 -0
- package/dist/commands/disk.d.ts +6 -0
- package/dist/commands/disk.d.ts.map +1 -0
- package/dist/commands/disk.js +80 -0
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +48 -1
- package/dist/commands/files.d.ts +16 -0
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +54 -2
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +43 -9
- package/dist/commands/rm.d.ts +4 -0
- package/dist/commands/rm.d.ts.map +1 -0
- package/dist/commands/rm.js +87 -0
- package/dist/commands/servers.d.ts +7 -0
- package/dist/commands/servers.d.ts.map +1 -0
- package/dist/commands/servers.js +394 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +82 -2
- package/dist/core/adapters/profiles.d.ts +9 -0
- package/dist/core/adapters/profiles.d.ts.map +1 -1
- package/dist/core/adapters/profiles.js +22 -1
- package/dist/core/adapters/types.d.ts +9 -0
- package/dist/core/adapters/types.d.ts.map +1 -1
- package/dist/core/agents/cli.js +13 -6
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +2 -0
- package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +60 -0
- package/dist/core/artifacts/delivery-card.js +9 -2
- package/dist/core/artifacts/index.d.ts +140 -4
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +522 -49
- package/dist/core/config.d.ts +68 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +128 -3
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +2 -2
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +16 -0
- package/dist/core/events/v3/projection.js +9 -1
- package/dist/core/hooks/adapter/events.d.ts +17 -0
- package/dist/core/hooks/adapter/events.d.ts.map +1 -1
- package/dist/core/hooks/adapter/events.js +5 -0
- package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
- package/dist/core/hooks/adapter/wiring.d.ts +37 -7
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +62 -4
- package/dist/core/hooks/cli.js +45 -6
- package/dist/core/hooks/run-markers.d.ts +37 -0
- package/dist/core/hooks/run-markers.d.ts.map +1 -0
- package/dist/core/hooks/run-markers.js +109 -0
- package/dist/core/servers/index.d.ts +211 -0
- package/dist/core/servers/index.d.ts.map +1 -0
- package/dist/core/servers/index.js +560 -0
- package/dist/core/servers/net.d.ts +58 -0
- package/dist/core/servers/net.d.ts.map +1 -0
- package/dist/core/servers/net.js +270 -0
- package/dist/core/servers/tunnels.d.ts +14 -0
- package/dist/core/servers/tunnels.d.ts.map +1 -0
- package/dist/core/servers/tunnels.js +33 -0
- package/dist/core/storage/builtins.js +1 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +30 -1
- package/dist/core/workflow/sandbox-projection.d.ts +13 -2
- package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
- package/dist/core/workflow/sandbox-projection.js +23 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +6 -1
- package/dist/core/workflow/spawn-codex.d.ts +2 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +20 -1
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +6 -1
- package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
- package/dist/core/workflow/spawn-opencode.js +4 -1
- package/dist/core/workflow/types.d.ts +47 -2
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/worker-access.d.ts +92 -0
- package/dist/core/workflow/worker-access.d.ts.map +1 -0
- package/dist/core/workflow/worker-access.js +450 -0
- package/dist/lib/browser/batch-steps.d.ts +18 -0
- package/dist/lib/browser/batch-steps.d.ts.map +1 -0
- package/dist/lib/browser/batch-steps.js +82 -0
- package/dist/lib/browser/collect/collector.d.ts +99 -0
- package/dist/lib/browser/collect/collector.d.ts.map +1 -0
- package/dist/lib/browser/collect/collector.js +202 -0
- package/dist/lib/browser/collect/fields.d.ts +31 -0
- package/dist/lib/browser/collect/fields.d.ts.map +1 -0
- package/dist/lib/browser/collect/fields.js +75 -0
- package/dist/lib/browser/collect/index.d.ts +6 -0
- package/dist/lib/browser/collect/index.d.ts.map +1 -0
- package/dist/lib/browser/collect/index.js +5 -0
- package/dist/lib/browser/collect/page-driver.d.ts +14 -0
- package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
- package/dist/lib/browser/collect/page-driver.js +306 -0
- package/dist/lib/browser/collect/presets.d.ts +52 -0
- package/dist/lib/browser/collect/presets.d.ts.map +1 -0
- package/dist/lib/browser/collect/presets.js +176 -0
- package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
- package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
- package/dist/lib/browser/collect/scroll-plan.js +118 -0
- package/dist/lib/browser/index.d.ts +1 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.d.ts +2 -0
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.js +13 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +1 -0
- package/dist/lib/browser/windows-chrome.d.ts +59 -0
- package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
- package/dist/lib/browser/windows-chrome.js +179 -0
- package/dist/lib/claude-desktop-share.d.ts +136 -0
- package/dist/lib/claude-desktop-share.d.ts.map +1 -0
- package/dist/lib/claude-desktop-share.js +394 -0
- package/dist/lib/disk-usage.d.ts +50 -0
- package/dist/lib/disk-usage.d.ts.map +1 -0
- package/dist/lib/disk-usage.js +303 -0
- package/dist/lib/docs-lint.d.ts +6 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +8 -3
- package/dist/lib/guarded-remove.d.ts +34 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -0
- package/dist/lib/guarded-remove.js +310 -0
- package/dist/lib/instructions/templates.js +8 -8
- package/dist/lib/tunnel/error-page.d.ts +1 -1
- package/dist/lib/tunnel/error-page.d.ts.map +1 -1
- package/dist/lib/tunnel/error-page.js +19 -8
- package/dist/lib/tunnel/gate.js +20 -1
- package/dist/lib/tunnel/path-scope.d.ts +22 -0
- package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
- package/dist/lib/tunnel/path-scope.js +62 -0
- package/dist/lib/tunnel/state.d.ts +5 -0
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +17 -3
- package/package.json +6 -1
- package/schemas/config.schema.json +91 -0
- package/src/commander.ts +19 -0
- package/src/commands/agents.ts +14 -0
- package/src/commands/artifacts.ts +171 -19
- package/src/commands/browse.ts +400 -57
- package/src/commands/claude-desktop.ts +61 -0
- package/src/commands/disk.ts +114 -0
- package/src/commands/doctor.ts +52 -1
- package/src/commands/files.ts +93 -2
- package/src/commands/init.ts +55 -5
- package/src/commands/rm.ts +115 -0
- package/src/commands/servers.ts +506 -0
- package/src/commands/tunnel.ts +102 -2
- package/src/core/adapters/profiles.ts +22 -1
- package/src/core/adapters/types.ts +10 -0
- package/src/core/agents/cli.ts +14 -6
- package/src/core/agents/render/session-context.ts +1 -0
- package/src/core/agents/state/live-coordination-view.ts +73 -0
- package/src/core/artifacts/delivery-card.ts +10 -2
- package/src/core/artifacts/index.ts +697 -44
- package/src/core/config.ts +183 -4
- package/src/core/diagnostics/bundle.ts +2 -1
- package/src/core/events/v3/producers/recorder.ts +16 -0
- package/src/core/events/v3/projection.ts +11 -1
- package/src/core/hooks/adapter/events.ts +19 -0
- package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
- package/src/core/hooks/adapter/wiring.ts +116 -8
- package/src/core/hooks/cli.ts +47 -6
- package/src/core/hooks/run-markers.ts +132 -0
- package/src/core/servers/index.ts +712 -0
- package/src/core/servers/net.ts +283 -0
- package/src/core/servers/tunnels.ts +39 -0
- package/src/core/storage/builtins.ts +1 -0
- package/src/core/workflow/engine.ts +38 -1
- package/src/core/workflow/sandbox-projection.ts +38 -2
- package/src/core/workflow/spawn-claude.ts +5 -1
- package/src/core/workflow/spawn-codex.ts +18 -1
- package/src/core/workflow/spawn-cursor.ts +5 -1
- package/src/core/workflow/spawn-opencode.ts +4 -1
- package/src/core/workflow/types.ts +62 -2
- package/src/core/workflow/worker-access.ts +525 -0
- package/src/lib/browser/batch-steps.ts +79 -0
- package/src/lib/browser/collect/collector.ts +306 -0
- package/src/lib/browser/collect/fields.ts +87 -0
- package/src/lib/browser/collect/index.ts +47 -0
- package/src/lib/browser/collect/page-driver.ts +327 -0
- package/src/lib/browser/collect/presets.ts +237 -0
- package/src/lib/browser/collect/scroll-plan.ts +179 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/page-review-judge.ts +16 -0
- package/src/lib/browser/qa-run-contracts.ts +11 -0
- package/src/lib/browser/qa-run.ts +1 -0
- package/src/lib/browser/windows-chrome.ts +227 -0
- package/src/lib/claude-desktop-share.ts +511 -0
- package/src/lib/disk-usage.ts +347 -0
- package/src/lib/docs-lint.ts +8 -3
- package/src/lib/guarded-remove.ts +376 -0
- package/src/lib/instructions/templates.ts +8 -8
- package/src/lib/tunnel/error-page.ts +25 -9
- package/src/lib/tunnel/gate.ts +22 -1
- package/src/lib/tunnel/path-scope.ts +73 -0
- 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
|
+
}
|