@shardflux/cli 0.20.0 → 0.22.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/CHANGELOG.md +214 -1
- package/README.md +215 -75
- package/dist/account-args.js +7 -2
- package/dist/account-commands.js +56 -106
- package/dist/account-pool.d.ts +19 -15
- package/dist/account-pool.js +47 -96
- package/dist/account-scope.d.ts +65 -0
- package/dist/account-scope.js +246 -0
- package/dist/acp-login.d.ts +4 -3
- package/dist/acp-login.js +7 -5
- package/dist/acp-models.js +20 -15
- package/dist/acp.js +66 -13
- package/dist/agent-commands.d.ts +4 -1
- package/dist/agent-commands.js +86 -32
- package/dist/agent-config-return.d.ts +46 -2
- package/dist/agent-config-return.js +110 -4
- package/dist/agent-credentials.js +7 -3
- package/dist/agent-repository.d.ts +16 -3
- package/dist/agent-repository.js +149 -74
- package/dist/agent-setup.d.ts +14 -3
- package/dist/agent-setup.js +78 -36
- package/dist/agent-target-commands.js +24 -13
- package/dist/args.d.ts +1 -1
- package/dist/args.js +94 -54
- package/dist/bin.js +5 -1
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -14
- package/dist/claude-cloud-login.d.ts +24 -2
- package/dist/claude-cloud-login.js +151 -11
- package/dist/cli-proxy-forward.d.ts +2 -0
- package/dist/cli-proxy-forward.js +9 -2
- package/dist/cli-proxy-hub.d.ts +69 -25
- package/dist/cli-proxy-hub.js +231 -54
- package/dist/cli-proxy.d.ts +70 -1
- package/dist/cli-proxy.js +298 -32
- package/dist/client-settings.d.ts +39 -0
- package/dist/client-settings.js +91 -0
- package/dist/codex-cloud.d.ts +5 -0
- package/dist/codex-cloud.js +88 -17
- package/dist/command-center-actions.js +5 -1
- package/dist/commands.js +171 -22
- package/dist/conversation-commands.d.ts +5 -0
- package/dist/conversation-commands.js +8 -5
- package/dist/conversation-terminal.d.ts +10 -2
- package/dist/conversation-terminal.js +77 -13
- package/dist/conversations.d.ts +73 -1
- package/dist/conversations.js +257 -23
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +6 -4
- package/dist/device-helper.js +43 -22
- package/dist/errors.d.ts +27 -1
- package/dist/errors.js +45 -5
- package/dist/filesystem-commands.d.ts +16 -9
- package/dist/filesystem-commands.js +108 -54
- package/dist/format.d.ts +2 -1
- package/dist/format.js +10 -1
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +8 -0
- package/dist/initial-prompt.js +43 -1
- package/dist/large-media.d.ts +14 -0
- package/dist/large-media.js +100 -0
- package/dist/linux-terminal.d.ts +5 -3
- package/dist/linux-terminal.js +6 -20
- package/dist/local-sessions.d.ts +26 -2
- package/dist/local-sessions.js +113 -24
- package/dist/machine-setup.d.ts +11 -1
- package/dist/machine-setup.js +69 -22
- package/dist/machine-status.d.ts +67 -0
- package/dist/machine-status.js +140 -0
- package/dist/main.js +11 -13
- package/dist/move-worktrees-restore.py +144 -0
- package/dist/move-worktrees.d.ts +63 -0
- package/dist/move-worktrees.js +201 -0
- package/dist/move.d.ts +10 -1
- package/dist/move.js +116 -16
- package/dist/onboarding.d.ts +11 -7
- package/dist/onboarding.js +59 -27
- package/dist/prepared-repository.d.ts +202 -0
- package/dist/prepared-repository.js +860 -0
- package/dist/prepared-repository.py +1306 -0
- package/dist/project-preparation.d.ts +48 -0
- package/dist/project-preparation.js +304 -0
- package/dist/project-sync-manifest.d.ts +4 -0
- package/dist/project-sync-manifest.js +5 -1
- package/dist/project-sync-transfer.js +1 -1
- package/dist/project-sync.d.ts +19 -4
- package/dist/project-sync.js +31 -8
- package/dist/project-template-setup.d.ts +33 -1
- package/dist/project-template-setup.js +59 -2
- package/dist/secret-ask.d.ts +72 -0
- package/dist/secret-ask.js +351 -0
- package/dist/seed-phases.d.ts +8 -1
- package/dist/seed-phases.js +17 -2
- package/dist/setup-cache.d.ts +7 -0
- package/dist/setup-cache.js +32 -0
- package/dist/t3-launch.js +1 -4
- package/dist/t3-mode.d.ts +8 -2
- package/dist/t3-mode.js +19 -4
- package/dist/t3-phone.d.ts +4 -2
- package/dist/t3-phone.js +12 -4
- package/dist/terminal-apps.d.ts +157 -0
- package/dist/terminal-apps.js +561 -0
- package/dist/terminal-onboarding.js +6 -1
- package/dist/this-computer.d.ts +11 -0
- package/dist/this-computer.js +36 -0
- package/dist/thread-on-computer.js +4 -1
- package/dist/update.js +11 -3
- package/dist/worktree-twin.d.ts +10 -2
- package/dist/worktree-twin.js +27 -9
- package/package.json +5 -5
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Restore an owned migration into an EMPTY folder; never overwrite continued work.
|
|
2
|
+
|
|
3
|
+
The immutable archives retain original Git metadata. Only location pointers in the
|
|
4
|
+
restored copy change. Can be run again with a new empty root for recovery.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
|
|
15
|
+
manifest = Path(sys.argv[1]).resolve()
|
|
16
|
+
plan = json.loads(manifest.read_text())
|
|
17
|
+
root = Path(sys.argv[2]).absolute()
|
|
18
|
+
if root.is_symlink() or (root.exists() and any(root.iterdir())):
|
|
19
|
+
raise ValueError("Restore requires a new empty folder; existing work was not replaced")
|
|
20
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
def destination(target):
|
|
23
|
+
parts = PurePosixPath(target).parts
|
|
24
|
+
if target != "." and (target.startswith("/") or any(p in ("", "..") for p in parts)):
|
|
25
|
+
raise ValueError("Unsafe worktree destination")
|
|
26
|
+
return root if target == "." else root.joinpath(*parts)
|
|
27
|
+
|
|
28
|
+
targets = {t["source"]: destination(t["target"]) for t in plan["trees"]}
|
|
29
|
+
expected = plan["bytes"] + plan["files"] * 1024
|
|
30
|
+
if shutil.disk_usage(root).free < expected:
|
|
31
|
+
raise ValueError(f"Restore requires at least {expected} free bytes on the target filesystem")
|
|
32
|
+
|
|
33
|
+
def parents_safe(path, base):
|
|
34
|
+
parent = path.parent
|
|
35
|
+
while parent != base:
|
|
36
|
+
if parent.is_symlink():
|
|
37
|
+
raise ValueError("Archive parent is a symlink")
|
|
38
|
+
if parent == parent.parent:
|
|
39
|
+
raise ValueError("Archive escaped its root")
|
|
40
|
+
parent = parent.parent
|
|
41
|
+
if base.is_symlink():
|
|
42
|
+
raise ValueError("Archive root is a symlink")
|
|
43
|
+
|
|
44
|
+
for i, tree in enumerate(plan["trees"]):
|
|
45
|
+
base = targets[tree["source"]]
|
|
46
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
directories = []
|
|
48
|
+
with tarfile.open(manifest.parent / f"{i}.tar", "r:") as archive:
|
|
49
|
+
for member in archive:
|
|
50
|
+
parts = PurePosixPath(member.name).parts
|
|
51
|
+
if member.name in (".", "./"):
|
|
52
|
+
continue
|
|
53
|
+
if member.name.startswith("/") or any(p in ("", "..") for p in parts):
|
|
54
|
+
raise ValueError("Unsafe archive member")
|
|
55
|
+
path = base.joinpath(*parts)
|
|
56
|
+
parents_safe(path, base)
|
|
57
|
+
if member.isdir():
|
|
58
|
+
if path.is_symlink():
|
|
59
|
+
raise ValueError("Archive directory is a symlink")
|
|
60
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
directories.append((path, member.mode & 0o777, member.mtime))
|
|
62
|
+
elif member.issym():
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
path.symlink_to(member.linkname)
|
|
65
|
+
elif member.isreg() or member.islnk():
|
|
66
|
+
# Expand hard links: Git index/content is preserved, no link may escape.
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
with archive.extractfile(member) as src, path.open("xb") as out:
|
|
69
|
+
shutil.copyfileobj(src, out, 1024 * 1024)
|
|
70
|
+
path.chmod(member.mode & 0o777)
|
|
71
|
+
os.utime(path, (member.mtime, member.mtime))
|
|
72
|
+
else:
|
|
73
|
+
raise ValueError("Special archive member is not supported")
|
|
74
|
+
# Read-only directory modes must be applied after writing their contents.
|
|
75
|
+
for path, mode, mtime in reversed(directories):
|
|
76
|
+
path.chmod(mode)
|
|
77
|
+
os.utime(path, (mtime, mtime))
|
|
78
|
+
for entry in tree["files"]:
|
|
79
|
+
path = base / entry["path"]
|
|
80
|
+
if "link" in entry:
|
|
81
|
+
if not path.is_symlink() or os.readlink(path) != entry["link"]:
|
|
82
|
+
raise ValueError(f"Symlink verification failed: {path}")
|
|
83
|
+
digest = hashlib.sha256(("symlink\0" + entry["link"]).encode()).hexdigest()
|
|
84
|
+
else:
|
|
85
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size != entry["size"]:
|
|
86
|
+
raise ValueError(f"File size/type verification failed: {path}")
|
|
87
|
+
sha = hashlib.sha256()
|
|
88
|
+
with path.open("rb") as src:
|
|
89
|
+
for chunk in iter(lambda: src.read(1024 * 1024), b""):
|
|
90
|
+
sha.update(chunk)
|
|
91
|
+
digest = sha.hexdigest()
|
|
92
|
+
if path.stat().st_mode & 0o777 != entry["mode"]:
|
|
93
|
+
raise ValueError(f"File mode verification failed: {path}")
|
|
94
|
+
if digest != entry["sha256"]:
|
|
95
|
+
raise ValueError(f"File hash verification failed: {path}")
|
|
96
|
+
|
|
97
|
+
common = targets[plan["main"]] / ".git"
|
|
98
|
+
env = {**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"}
|
|
99
|
+
def git(tree, *args):
|
|
100
|
+
return subprocess.run(["git", "-C", str(tree), "-c", "core.hooksPath=/dev/null", *args],
|
|
101
|
+
env=env, check=True, stdout=subprocess.PIPE).stdout
|
|
102
|
+
|
|
103
|
+
# Repair forward/back pointers without running checkout/reset or refreshing an index.
|
|
104
|
+
for tree in plan["trees"]:
|
|
105
|
+
dest = targets[tree["source"]]
|
|
106
|
+
if tree["gitDir"] != plan["commonDir"]:
|
|
107
|
+
gitdir = common / Path(tree["gitDir"]).relative_to(plan["commonDir"])
|
|
108
|
+
(dest / ".git").write_text(f"gitdir: {gitdir}\n")
|
|
109
|
+
(gitdir / "gitdir").write_text(f"{dest / '.git'}\n")
|
|
110
|
+
(gitdir / "commondir").write_text(os.path.relpath(common, gitdir) + "\n")
|
|
111
|
+
worktree_config = gitdir / "config.worktree"
|
|
112
|
+
if worktree_config.exists():
|
|
113
|
+
subprocess.run(["git", "config", "--file", str(worktree_config), "core.worktree", str(dest)], check=True)
|
|
114
|
+
git(targets[plan["main"]], "config", "core.worktree", str(targets[plan["main"]]))
|
|
115
|
+
# External registered worktrees live in this private recovery area, outside the
|
|
116
|
+
# selected checkout's tracked content. Hide only that explicit location.
|
|
117
|
+
exclude = common / "info/exclude"
|
|
118
|
+
exclude.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
with exclude.open("a") as out:
|
|
120
|
+
out.write("\n/.shard/preserved-worktrees/\n")
|
|
121
|
+
|
|
122
|
+
selected = targets[plan["selected"]]
|
|
123
|
+
refs = git(selected, "for-each-ref", "--format=%(objectname) %(refname)").decode()
|
|
124
|
+
if refs != plan["refs"]:
|
|
125
|
+
raise ValueError("Local refs did not survive migration")
|
|
126
|
+
for tree in plan["trees"]:
|
|
127
|
+
dest = targets[tree["source"]]
|
|
128
|
+
if git(dest, "rev-parse", "HEAD").decode().strip() != tree["head"]:
|
|
129
|
+
raise ValueError(f"HEAD did not survive migration: {dest}")
|
|
130
|
+
status = git(dest, "status", "--porcelain=v1", "-z", "--untracked-files=all").decode()
|
|
131
|
+
if status != tree["status"]:
|
|
132
|
+
raise ValueError(f"Git status changed during migration: {dest}")
|
|
133
|
+
|
|
134
|
+
# The platform tracks this conversation by its own branch; the original branch
|
|
135
|
+
# and raw HEAD remain in refs/archives. Index and working bytes stay untouched.
|
|
136
|
+
branch = plan["branch"]
|
|
137
|
+
git(selected, "check-ref-format", branch)
|
|
138
|
+
if any(line.endswith(" " + branch) for line in refs.splitlines()):
|
|
139
|
+
raise ValueError("Conversation branch collides with a preserved local ref")
|
|
140
|
+
if "--defer-conversation-branch" not in sys.argv[3:]:
|
|
141
|
+
git(selected, "update-ref", branch, git(selected, "rev-parse", "HEAD").decode().strip())
|
|
142
|
+
git(selected, "symbolic-ref", "HEAD", branch)
|
|
143
|
+
print(json.dumps({"worktrees": len(plan["trees"]), "files": plan["files"], "bytes": plan["bytes"],
|
|
144
|
+
"root": str(root), "refs": len(refs.splitlines()), "status": "matched"}))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SyncCell } from './project-sync-transfer.js';
|
|
2
|
+
export interface MigrationFile {
|
|
3
|
+
path: string;
|
|
4
|
+
size: number;
|
|
5
|
+
mode: number;
|
|
6
|
+
sha256: string;
|
|
7
|
+
link?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MigrationTree {
|
|
10
|
+
source: string;
|
|
11
|
+
spellings: string[];
|
|
12
|
+
target: string;
|
|
13
|
+
head: string;
|
|
14
|
+
branch: string;
|
|
15
|
+
gitDir: string;
|
|
16
|
+
status: string;
|
|
17
|
+
files: MigrationFile[];
|
|
18
|
+
exclude: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface WorktreeMigration {
|
|
21
|
+
version: 1;
|
|
22
|
+
selected: string;
|
|
23
|
+
commonDir: string;
|
|
24
|
+
main: string;
|
|
25
|
+
refs: string;
|
|
26
|
+
trees: MigrationTree[];
|
|
27
|
+
bytes: number;
|
|
28
|
+
files: number;
|
|
29
|
+
referenced: string[];
|
|
30
|
+
}
|
|
31
|
+
/** Git is the source of membership; sibling folders and symlink targets never expand the upload scope. */
|
|
32
|
+
export declare function inspectWorktreeMigration(folder: string, transcript: Buffer, signal?: AbortSignal): Promise<WorktreeMigration | null>;
|
|
33
|
+
/** Size/hash pass only after preserve-all is selected (ignored caches may be large). */
|
|
34
|
+
export declare function captureWorktreeMigration(plan: WorktreeMigration, signal?: AbortSignal): Promise<void>;
|
|
35
|
+
export declare function migrationScope(plan: WorktreeMigration): {
|
|
36
|
+
worktrees: {
|
|
37
|
+
path: string;
|
|
38
|
+
branch: string;
|
|
39
|
+
target: string;
|
|
40
|
+
}[];
|
|
41
|
+
referenced: string[];
|
|
42
|
+
local_refs: string[];
|
|
43
|
+
bytes: number;
|
|
44
|
+
files: number;
|
|
45
|
+
scope: string;
|
|
46
|
+
size_basis: string;
|
|
47
|
+
};
|
|
48
|
+
/** User refs a normal move leaves behind. Shardflux's own refs/shard/* (twin snapshots, land and agent refs) are rebuilt, never user work. */
|
|
49
|
+
export declare function omittedLocalRefs(plan: WorktreeMigration): string[];
|
|
50
|
+
/** Longest paths first, before replacing a parent. Message text and signed thinking keep the existing rewrite contract. */
|
|
51
|
+
export declare function rewriteWorktreeTranscript(bytes: Buffer, plan: WorktreeMigration, root: string): Buffer;
|
|
52
|
+
export interface MigrationResult {
|
|
53
|
+
snapshot: string;
|
|
54
|
+
bytes: number;
|
|
55
|
+
worktrees: number;
|
|
56
|
+
files: number;
|
|
57
|
+
}
|
|
58
|
+
/** Called instead of regular seeding: the guest starts only after its refs, index, bytes and statuses are checked. */
|
|
59
|
+
export declare function preserveWorktreeMigration(cell: SyncCell, plan: WorktreeMigration, c: {
|
|
60
|
+
id: string;
|
|
61
|
+
branch: string;
|
|
62
|
+
worktree_path: string;
|
|
63
|
+
}, signal?: AbortSignal): Promise<MigrationResult>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/** An explicit, recoverable cold copy of a repository and its registered worktrees. Never writes to local Git. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { lstat, readFile, readdir, readlink, realpath } from 'node:fs/promises';
|
|
6
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
7
|
+
import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
8
|
+
import { projectGit } from "./project-sync-manifest.js";
|
|
9
|
+
import { UsageError } from "./errors.js";
|
|
10
|
+
import { rewriteSessionPaths } from "./local-sessions.js";
|
|
11
|
+
const inside = (root, path) => path === root || path.startsWith(`${root}/`);
|
|
12
|
+
const git = (folder, args, signal) => projectGit(folder, args, signal);
|
|
13
|
+
const hash = async (file, signal) => {
|
|
14
|
+
const sha = createHash('sha256');
|
|
15
|
+
for await (const chunk of createReadStream(file, { signal }))
|
|
16
|
+
sha.update(chunk);
|
|
17
|
+
return sha.digest('hex');
|
|
18
|
+
};
|
|
19
|
+
/** Git is the source of membership; sibling folders and symlink targets never expand the upload scope. */
|
|
20
|
+
export async function inspectWorktreeMigration(folder, transcript, signal) {
|
|
21
|
+
let commonDir;
|
|
22
|
+
try {
|
|
23
|
+
commonDir = (await git(folder, ['rev-parse', '--path-format=absolute', '--git-common-dir'], signal)).trim();
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (signal?.aborted || e.code !== 128)
|
|
27
|
+
throw e;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
folder = await realpath(folder);
|
|
31
|
+
commonDir = await realpath(commonDir);
|
|
32
|
+
try {
|
|
33
|
+
await git(folder, ['rev-parse', '--verify', 'HEAD'], signal);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (signal?.aborted || e.code !== 128)
|
|
37
|
+
throw e;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const main = dirname(commonDir);
|
|
41
|
+
if (basename(commonDir) !== '.git' || !(await lstat(commonDir)).isDirectory())
|
|
42
|
+
throw new UsageError('Preserve-all needs a repository with its own .git directory; separate Git directories are not supported.');
|
|
43
|
+
if ((await git(main, ['rev-parse', '--show-toplevel'], signal)).trim() !== main)
|
|
44
|
+
throw new UsageError('The main checkout is unavailable; restore it before moving all worktrees.');
|
|
45
|
+
// External alternate stores cannot be represented by this bounded copy.
|
|
46
|
+
const alternates = join(commonDir, 'objects/info/alternates');
|
|
47
|
+
if (await readFile(alternates, 'utf8').then(s => !!s.trim(), (e) => { if (e.code !== 'ENOENT')
|
|
48
|
+
throw e; return false; }))
|
|
49
|
+
throw new UsageError('This repository borrows an external Git object store. Make it self-contained before preserving all worktrees.');
|
|
50
|
+
const registered = (await git(folder, ['worktree', 'list', '--porcelain', '-z'], signal)).split('\0').filter(v => v.startsWith('worktree ')).map(v => v.slice(9));
|
|
51
|
+
const sources = [];
|
|
52
|
+
for (const path of registered) {
|
|
53
|
+
try {
|
|
54
|
+
sources.push(await realpath(path));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new UsageError(`Registered worktree is missing: ${path}. Recover it or explicitly remove its stale Git registration before moving all worktrees.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!sources.includes(folder) || !sources.includes(main))
|
|
61
|
+
throw new UsageError('Git did not list the selected and main worktrees.');
|
|
62
|
+
const trees = [];
|
|
63
|
+
for (const [i, source] of sources.entries()) {
|
|
64
|
+
// Worktrees inside the selected folder keep their relative paths, including ignored .worktrees.
|
|
65
|
+
const target = source === folder ? '.' : inside(folder, source) ? relative(folder, source) : `.shard/preserved-worktrees/${i}-${basename(source)}`;
|
|
66
|
+
const gitDir = (await git(source, ['rev-parse', '--absolute-git-dir'], signal)).trim();
|
|
67
|
+
if (!inside(commonDir, gitDir))
|
|
68
|
+
throw new UsageError(`Worktree ${source} uses an unrelated Git store.`);
|
|
69
|
+
const branch = await git(source, ['symbolic-ref', '-q', 'HEAD'], signal).then(s => s.trim(), (e) => { if (e.code !== 1)
|
|
70
|
+
throw e; return ''; });
|
|
71
|
+
const spellings = [...new Set([source, registered[i]])];
|
|
72
|
+
trees.push({ source, spellings, target, gitDir, branch, head: (await git(source, ['rev-parse', 'HEAD'], signal)).trim(),
|
|
73
|
+
status: await git(source, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], signal), files: [],
|
|
74
|
+
exclude: sources.filter(p => p !== source && inside(source, p)).map(p => relative(source, p)) });
|
|
75
|
+
}
|
|
76
|
+
const text = transcript.toString('utf8');
|
|
77
|
+
const referenced = trees.filter(t => t.source !== folder && t.spellings.some(p => text.includes(p) || text.includes(JSON.stringify(p).slice(1, -1)))).map(t => t.source);
|
|
78
|
+
return { version: 1, selected: folder, commonDir, main, trees, referenced,
|
|
79
|
+
refs: await git(folder, ['for-each-ref', '--format=%(objectname) %(refname)'], signal), bytes: 0, files: 0 };
|
|
80
|
+
}
|
|
81
|
+
/** Size/hash pass only after preserve-all is selected (ignored caches may be large). */
|
|
82
|
+
export async function captureWorktreeMigration(plan, signal) {
|
|
83
|
+
plan.bytes = 0;
|
|
84
|
+
plan.files = 0;
|
|
85
|
+
for (const tree of plan.trees) {
|
|
86
|
+
tree.files = [];
|
|
87
|
+
const walk = async (path) => {
|
|
88
|
+
signal?.throwIfAborted();
|
|
89
|
+
if (tree.exclude.some(p => inside(p, path)))
|
|
90
|
+
return;
|
|
91
|
+
if (path.endsWith('/.git'))
|
|
92
|
+
throw new UsageError(`Nested repository ${join(tree.source, path)} is not a registered worktree. Migrate it separately before preserving all worktrees.`);
|
|
93
|
+
const file = join(tree.source, path), st = await lstat(file);
|
|
94
|
+
if (st.isDirectory()) {
|
|
95
|
+
for (const name of (await readdir(file)).sort())
|
|
96
|
+
await walk(path ? `${path}/${name}` : name);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!st.isFile() && !st.isSymbolicLink())
|
|
100
|
+
throw new UsageError(`Cannot preserve special file ${file}. Remove it from this migration scope before retrying.`);
|
|
101
|
+
if (path.endsWith('.lock') && inside('.git', path))
|
|
102
|
+
throw new UsageError(`Git is writing ${file}; finish the Git operation before migrating.`);
|
|
103
|
+
const link = st.isSymbolicLink() ? await readlink(file) : undefined;
|
|
104
|
+
const sha256 = link === undefined ? await hash(file, signal) : createHash('sha256').update(`symlink\0${link}`).digest('hex');
|
|
105
|
+
tree.files.push({ path, size: st.size, mode: st.mode & 0o777, sha256, ...(link !== undefined ? { link } : {}) });
|
|
106
|
+
plan.bytes += st.size;
|
|
107
|
+
plan.files++;
|
|
108
|
+
};
|
|
109
|
+
await walk('');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function migrationScope(plan) {
|
|
113
|
+
return { worktrees: plan.trees.map(t => ({ path: t.source, branch: t.branch || '(detached)', target: t.target })), referenced: plan.referenced,
|
|
114
|
+
local_refs: plan.refs.trim().split('\n').filter(Boolean).map(line => line.slice(line.indexOf(' ') + 1)),
|
|
115
|
+
bytes: plan.bytes, files: plan.files, scope: 'All files, including ignored files and Git metadata, inside these registered worktrees. Symlink targets and unrelated sibling folders are not uploaded.',
|
|
116
|
+
size_basis: 'File bytes before tar headers; archive bytes and free-space requirement are larger. Ignored dependency/build caches are included.' };
|
|
117
|
+
}
|
|
118
|
+
/** User refs a normal move leaves behind. Shardflux's own refs/shard/* (twin snapshots, land and agent refs) are rebuilt, never user work. */
|
|
119
|
+
export function omittedLocalRefs(plan) {
|
|
120
|
+
const selected = plan.trees.find(t => t.source === plan.selected);
|
|
121
|
+
return plan.refs.trim().split('\n').filter(Boolean).map(line => line.slice(line.indexOf(' ') + 1))
|
|
122
|
+
.filter(ref => ref !== selected.branch && !ref.startsWith('refs/remotes/') && !ref.startsWith('refs/shard/'));
|
|
123
|
+
}
|
|
124
|
+
/** Longest paths first, before replacing a parent. Message text and signed thinking keep the existing rewrite contract. */
|
|
125
|
+
export function rewriteWorktreeTranscript(bytes, plan, root) {
|
|
126
|
+
const mappings = plan.trees.flatMap(tree => tree.spellings.map(from => ({ from, to: tree.target === '.' ? root : `${root}/${tree.target}` })));
|
|
127
|
+
for (const { from, to } of mappings.sort((a, b) => b.from.length - a.from.length))
|
|
128
|
+
bytes = rewriteSessionPaths(bytes, from, to);
|
|
129
|
+
return bytes;
|
|
130
|
+
}
|
|
131
|
+
async function uploadTree(cell, tree, dest, signal) {
|
|
132
|
+
const child = spawn('tar', ['-cf', '-', ...tree.exclude.map(p => `--exclude=./${p}`), '-C', tree.source, '.'], { stdio: ['ignore', 'pipe', 'pipe'], signal, env: { ...process.env, COPYFILE_DISABLE: '1' } });
|
|
133
|
+
let stderr = '';
|
|
134
|
+
child.stderr.on('data', (b) => { stderr = (stderr + b.toString()).slice(-2000); });
|
|
135
|
+
const exited = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', code => resolve(code ?? 1)); });
|
|
136
|
+
// Attach a rejection handler while reading the stream.
|
|
137
|
+
void exited.catch(() => undefined);
|
|
138
|
+
let total = 0, size = 0, pending = [];
|
|
139
|
+
const flush = async () => { if (!size)
|
|
140
|
+
return; const bytes = Buffer.concat(pending, size); await cell.files.write(dest, bytes, { mode: '0600', append: total > 0 }); total += size; size = 0; pending = []; };
|
|
141
|
+
try {
|
|
142
|
+
for await (const chunk of child.stdout) {
|
|
143
|
+
let b = chunk;
|
|
144
|
+
while (b.length) {
|
|
145
|
+
const n = Math.min(b.length, SYNC_CHUNK_BYTES - size);
|
|
146
|
+
pending.push(b.subarray(0, n));
|
|
147
|
+
size += n;
|
|
148
|
+
b = b.subarray(n);
|
|
149
|
+
if (size === SYNC_CHUNK_BYTES)
|
|
150
|
+
await flush();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
await flush();
|
|
154
|
+
if (await exited !== 0)
|
|
155
|
+
throw new Error(`Worktree archive failed: ${stderr}`);
|
|
156
|
+
return total;
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
child.kill();
|
|
160
|
+
await exited.catch(() => undefined);
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Called instead of regular seeding: the guest starts only after its refs, index, bytes and statuses are checked. */
|
|
165
|
+
export async function preserveWorktreeMigration(cell, plan, c, signal) {
|
|
166
|
+
if (!/^conv_[a-z0-9]{26}$/.test(c.id))
|
|
167
|
+
throw new Error('Invalid migration identity');
|
|
168
|
+
const snapshot = `/shardflux/project/migrations/${c.id}`;
|
|
169
|
+
const mounted = await cell.exec.run(['test', '-d', '/shardflux/project/git/hub.git'], { timeoutMs: 10_000, signal });
|
|
170
|
+
if (mounted.exitCode !== 0)
|
|
171
|
+
throw new Error('The durable project space is unavailable; preserve-all did not upload or resume the session.');
|
|
172
|
+
await cell.files.mkdir(snapshot, { parents: true });
|
|
173
|
+
const space = await cell.exec.run(['python3', '-c', 'import os,shutil,sys; a,b,n=sys.argv[1:]; os.makedirs(b,exist_ok=True); need=int(n); assert shutil.disk_usage(a).free>=need and shutil.disk_usage(b).free>=need, "Migration needs more free disk space for archives and restored files"', snapshot, c.worktree_path,
|
|
174
|
+
String(2 * (plan.bytes + plan.files * 1024))], { timeoutMs: 10_000, signal });
|
|
175
|
+
if (space.exitCode !== 0)
|
|
176
|
+
throw new UsageError(space.stderr.trim());
|
|
177
|
+
let bytes = 0;
|
|
178
|
+
for (const [i, tree] of plan.trees.entries())
|
|
179
|
+
bytes += await uploadTree(cell, tree, `${snapshot}/${i}.tar`, signal);
|
|
180
|
+
const script = await readFile(new URL('./move-worktrees-restore.py', import.meta.url), 'utf8');
|
|
181
|
+
await cell.files.write(`${snapshot}/restore.py`, script, { mode: '0600' });
|
|
182
|
+
const branch = c.branch.startsWith('refs/heads/') ? c.branch : `refs/heads/${c.branch}`;
|
|
183
|
+
await cell.files.write(`${snapshot}/manifest.json`, JSON.stringify({ ...plan, branch, root: c.worktree_path }), { mode: '0600' });
|
|
184
|
+
const applied = await cell.exec.run(['python3', `${snapshot}/restore.py`, `${snapshot}/manifest.json`, c.worktree_path, '--defer-conversation-branch'], { timeoutMs: 600_000, maxOutputBytes: 8192, signal });
|
|
185
|
+
if (applied.exitCode !== 0)
|
|
186
|
+
throw new Error(`Preserve-all verification failed; the local source is unchanged: ${applied.stderr.trim()}`);
|
|
187
|
+
// No move is recorded or resumed if another local process changed a tree during upload.
|
|
188
|
+
const source = (await inspectWorktreeMigration(plan.selected, Buffer.alloc(0), signal));
|
|
189
|
+
await captureWorktreeMigration(source, signal);
|
|
190
|
+
if (source.refs !== plan.refs || JSON.stringify(source.trees.map(t => [t.source, t.head, t.branch, t.status, t.files])) !== JSON.stringify(plan.trees.map(t => [t.source, t.head, t.branch, t.status, t.files]))) {
|
|
191
|
+
throw new UsageError('A local worktree changed during migration; no session was resumed. Finish its work and retry. The captured recovery archive is retained.');
|
|
192
|
+
}
|
|
193
|
+
await cell.files.write(`${snapshot}/ready.json`, JSON.stringify({ bytes, worktrees: plan.trees.length, files: plan.files }), { mode: '0600' });
|
|
194
|
+
// MovedInReady in the guest observes this symbolic branch. It is the last
|
|
195
|
+
// readiness transition, after all copies and both local/remote verification.
|
|
196
|
+
const ready = await cell.exec.run(['sh', '-c', 'set -e; git -C "$1" -c core.hooksPath=/dev/null update-ref "$2" HEAD; git -C "$1" symbolic-ref HEAD "$2"',
|
|
197
|
+
'shard-migration-ready', c.worktree_path, branch], { timeoutMs: 10_000, signal });
|
|
198
|
+
if (ready.exitCode !== 0)
|
|
199
|
+
throw new Error(`Migration branch could not be activated: ${ready.stderr.trim()}`);
|
|
200
|
+
return { snapshot, bytes, worktrees: plan.trees.length, files: plan.files };
|
|
201
|
+
}
|
package/dist/move.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Ctx, Handler } from './commands.js';
|
|
|
3
3
|
import type { Values } from './args.js';
|
|
4
4
|
import type { Conversation } from './conversations.js';
|
|
5
5
|
import type { LocalSession, SessionHarness, SessionState } from './local-sessions.js';
|
|
6
|
+
import type { WorktreeMigration } from './move-worktrees.js';
|
|
6
7
|
type Env = Record<string, string | undefined>;
|
|
7
8
|
/** The project a folder belongs to: its Git repository's top, or the folder itself. */
|
|
8
9
|
export declare function projectRoot(folder: string): string;
|
|
@@ -15,10 +16,17 @@ type MoveCell = Pick<ReturnType<Workspace['cell']>, 'exec' | 'files'>;
|
|
|
15
16
|
* project space is mounted, as the conversation's durable copy with the same checkpoint metadata the guest writes
|
|
16
17
|
* (transcripts/<id>/meta.json), so the session survives the VM from its first minute.
|
|
17
18
|
*/
|
|
18
|
-
export declare function placeTranscript(cell: MoveCell, c: Pick<Conversation, 'id' | 'harness' | 'worktree_path'>, s: LocalSession, bytes: Buffer, folder: string, signal?: AbortSignal): Promise<{
|
|
19
|
+
export declare function placeTranscript(cell: MoveCell, c: Pick<Conversation, 'id' | 'harness' | 'worktree_path'>, s: LocalSession, bytes: Buffer, folder: string, signal?: AbortSignal, worktrees?: WorktreeMigration): Promise<{
|
|
19
20
|
native: string;
|
|
20
21
|
durable: boolean;
|
|
21
22
|
}>;
|
|
23
|
+
/** Every working directory the whole transcript names in its record metadata, never in message or tool content. */
|
|
24
|
+
export declare function transcriptCwds(bytes: Buffer): string[];
|
|
25
|
+
/**
|
|
26
|
+
* The project a move goes to: --path when the session's records confirm it worked there (placeTranscript checks), the
|
|
27
|
+
* current folder's project when it worked there, else the project of where it works now.
|
|
28
|
+
*/
|
|
29
|
+
export declare function moveFolder(s: Pick<LocalSession, 'cwd' | 'cwds'>, here: string, explicit: boolean): string;
|
|
22
30
|
export declare function moveSession(ctx: Ctx, values: Values): Promise<number>;
|
|
23
31
|
/** `shard move --list`: this project's sessions on this computer, and where each moved one continues. */
|
|
24
32
|
export declare function listMovable(ctx: Pick<Ctx, 'store' | 'apiUrl'>, values: Values, env: Env, now?: number): {
|
|
@@ -30,6 +38,7 @@ export declare function listMovable(ctx: Pick<Ctx, 'store' | 'apiUrl'>, values:
|
|
|
30
38
|
updated_at: string;
|
|
31
39
|
source: string;
|
|
32
40
|
cwd: string;
|
|
41
|
+
cwds: string[];
|
|
33
42
|
running: boolean;
|
|
34
43
|
turn: "working" | "idle";
|
|
35
44
|
moved: {
|
package/dist/move.js
CHANGED
|
@@ -31,8 +31,9 @@ import { openInChoices } from "./t3-mode.js";
|
|
|
31
31
|
import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-commands.js";
|
|
32
32
|
import { openUrl } from "./browser.js";
|
|
33
33
|
import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
34
|
-
import { completeRecords, continuedAfter, findLocalSession, homeRelativeTranscript, listLocalSessions, readTranscript, rewriteSessionPaths, sessionState } from "./local-sessions.js";
|
|
34
|
+
import { completeRecords, continuedAfter, findLocalSession, homeRelativeTranscript, listLocalSessions, readTranscript, recordCwd, rewriteSessionPaths, sessionState } from "./local-sessions.js";
|
|
35
35
|
import { installDivergenceNotice, movedKey, movedSession, movedSessions, rememberMoved } from "./moved-sessions.js";
|
|
36
|
+
import { captureWorktreeMigration, inspectWorktreeMigration, migrationScope, omittedLocalRefs, preserveWorktreeMigration, rewriteWorktreeTranscript } from "./move-worktrees.js";
|
|
36
37
|
const VM_HOME = '/home/user';
|
|
37
38
|
const SPACE = '/shardflux/project';
|
|
38
39
|
/** A transcript above this moves with the repository's history bound. */
|
|
@@ -95,14 +96,27 @@ async function writeChunks(cell, path, bytes) {
|
|
|
95
96
|
* project space is mounted, as the conversation's durable copy with the same checkpoint metadata the guest writes
|
|
96
97
|
* (transcripts/<id>/meta.json), so the session survives the VM from its first minute.
|
|
97
98
|
*/
|
|
98
|
-
export async function placeTranscript(cell, c, s, bytes, folder, signal) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
export async function placeTranscript(cell, c, s, bytes, folder, signal, worktrees) {
|
|
100
|
+
// The project is confirmed by the session's own records (0.20.2+): any of them working in the folder, not only the
|
|
101
|
+
// first (a session started in a scratch folder that went on in this repository). Paths are moved for this folder;
|
|
102
|
+
// records of a folder outside it keep their bytes.
|
|
103
|
+
const here = confirmedCwds(s, bytes, folder, worktrees);
|
|
104
|
+
let moved = bytes;
|
|
105
|
+
if (worktrees) {
|
|
106
|
+
// Normalize linked spellings confirmed by record metadata before mapping
|
|
107
|
+
// longest registered prefixes. Scratch records outside the family stay intact.
|
|
108
|
+
for (const tree of [...worktrees.trees].sort((a, b) => b.source.length - a.source.length)) {
|
|
109
|
+
const spellings = here.filter(cwd => isInside(tree.source, realOr(cwd))).map(cwd => linkedSpelling(cwd, tree.source));
|
|
110
|
+
for (const spelled of new Set(spellings.filter((p) => !!p)))
|
|
111
|
+
moved = rewriteSessionPaths(moved, spelled, tree.source);
|
|
112
|
+
}
|
|
113
|
+
moved = rewriteWorktreeTranscript(moved, worktrees, c.worktree_path);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
moved = rewriteSessionPaths(moved, folder, c.worktree_path);
|
|
117
|
+
// A path recorded through a link (/tmp for /private/tmp): move that spelling too.
|
|
118
|
+
for (const spelled of new Set(here.map(cwd => linkedSpelling(cwd, folder)).filter((p) => !!p)))
|
|
119
|
+
moved = rewriteSessionPaths(moved, spelled, c.worktree_path);
|
|
106
120
|
}
|
|
107
121
|
const homeRelative = homeRelativeTranscript(s, c.worktree_path);
|
|
108
122
|
const native = `${VM_HOME}/${homeRelative}`;
|
|
@@ -125,6 +139,52 @@ const realOr = (path) => { try {
|
|
|
125
139
|
catch {
|
|
126
140
|
return resolve(path);
|
|
127
141
|
} };
|
|
142
|
+
const isInside = (folder, path) => path === folder || path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
|
143
|
+
/** The folder as `cwd` spells it through a link (/tmp/x for /private/tmp/x), or undefined when it spells it plainly. */
|
|
144
|
+
function linkedSpelling(cwd, folder) {
|
|
145
|
+
if (isInside(folder, cwd))
|
|
146
|
+
return undefined;
|
|
147
|
+
const inside = relative(folder, realOr(cwd));
|
|
148
|
+
if (inside && !cwd.endsWith(`/${inside}`))
|
|
149
|
+
return undefined;
|
|
150
|
+
return inside ? cwd.slice(0, cwd.length - inside.length - 1) : cwd;
|
|
151
|
+
}
|
|
152
|
+
/** Every working directory the whole transcript names in its record metadata, never in message or tool content. */
|
|
153
|
+
export function transcriptCwds(bytes) {
|
|
154
|
+
const out = new Set();
|
|
155
|
+
for (const line of bytes.toString('utf8').split('\n')) {
|
|
156
|
+
try {
|
|
157
|
+
const r = JSON.parse(line);
|
|
158
|
+
if (!r || typeof r !== 'object' || Array.isArray(r))
|
|
159
|
+
continue;
|
|
160
|
+
const cwd = recordCwd(r);
|
|
161
|
+
if (cwd)
|
|
162
|
+
out.add(cwd);
|
|
163
|
+
}
|
|
164
|
+
catch { /* a partial record */ }
|
|
165
|
+
}
|
|
166
|
+
return [...out];
|
|
167
|
+
}
|
|
168
|
+
function confirmedCwds(s, bytes, folder, worktrees) {
|
|
169
|
+
const roots = [folder, ...(worktrees?.trees.map(t => t.source) ?? [])];
|
|
170
|
+
const here = [...new Set([s.cwd, ...s.cwds, ...transcriptCwds(bytes)])].filter(cwd => roots.some(root => isInside(root, realOr(cwd))));
|
|
171
|
+
if (!here.length)
|
|
172
|
+
throw new UsageError('The session ran outside this project folder; run shard move in its project, or pass --path with the repository it worked in.');
|
|
173
|
+
return here;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The project a move goes to: --path when the session's records confirm it worked there (placeTranscript checks), the
|
|
177
|
+
* current folder's project when it worked there, else the project of where it works now.
|
|
178
|
+
*/
|
|
179
|
+
export function moveFolder(s, here, explicit) {
|
|
180
|
+
if (explicit)
|
|
181
|
+
return here;
|
|
182
|
+
const root = (cwd) => (existsSync(cwd) ? projectRoot(cwd) : here);
|
|
183
|
+
if (isInside(here, realOr(s.cwd)))
|
|
184
|
+
return root(s.cwd);
|
|
185
|
+
const inHere = [...s.cwds].reverse().find(cwd => isInside(here, realOr(cwd)));
|
|
186
|
+
return inHere ? root(inHere) : root(s.cwd);
|
|
187
|
+
}
|
|
128
188
|
async function alreadyMoved(ctx, m) {
|
|
129
189
|
try {
|
|
130
190
|
const c = await getConversation(ctx, m.conversation_id);
|
|
@@ -136,23 +196,58 @@ async function alreadyMoved(ctx, m) {
|
|
|
136
196
|
throw e;
|
|
137
197
|
}
|
|
138
198
|
}
|
|
199
|
+
function requireExplicitWorktreeScope(plan, values, sessionId) {
|
|
200
|
+
if (!plan || values['preserve-all-worktrees'] === true || values['this-worktree-only'] === true)
|
|
201
|
+
return;
|
|
202
|
+
const omitted = omittedLocalRefs(plan);
|
|
203
|
+
if (plan.trees.length > 1 || omitted.length)
|
|
204
|
+
throw new UsageError(`This repository has ${plan.trees.length} registered worktrees and ${omitted.length} other local refs${plan.referenced.length ? `; the session references ${plan.referenced.length} other worktrees` : ''}. Normal migration omits those refs and other worktree files. Inspect paths and bytes with shard move --session ${sessionId} --plan, then choose --preserve-all-worktrees or explicitly --this-worktree-only.`);
|
|
205
|
+
}
|
|
139
206
|
export async function moveSession(ctx, values) {
|
|
140
207
|
const env = { ...process.env, ...ctx.io.env };
|
|
141
208
|
const here = projectRoot(typeof values.path === 'string' ? values.path : process.cwd());
|
|
142
209
|
const s = chooseSession(values, env, here);
|
|
143
|
-
const folder = typeof values.path === 'string'
|
|
210
|
+
const folder = moveFolder(s, here, typeof values.path === 'string');
|
|
144
211
|
const prior = movedSession(ctx.store.dir, ctx.apiUrl, s.harness, s.session_id);
|
|
145
|
-
if (prior) {
|
|
212
|
+
if (prior && values.plan !== true) {
|
|
146
213
|
const live = await alreadyMoved(ctx, prior);
|
|
147
214
|
if (live) {
|
|
148
215
|
out(ctx, `This session already continues in the cloud as ${prior.key}. Open it: shard agents open ${prior.key}\n`, { already: true, moved: prior, conversation: live });
|
|
149
216
|
return 0;
|
|
150
217
|
}
|
|
151
218
|
}
|
|
152
|
-
|
|
219
|
+
if (values.plan !== true) {
|
|
220
|
+
// A local MCP caller is still in its turn. Report the required scope choice
|
|
221
|
+
// before waiting, so it can answer while it still runs on this computer.
|
|
222
|
+
if (values['preserve-all-worktrees'] !== true && values['this-worktree-only'] !== true) {
|
|
223
|
+
const current = completeRecords(readTranscript(s.file));
|
|
224
|
+
confirmedCwds(s, current, folder);
|
|
225
|
+
requireExplicitWorktreeScope(await inspectWorktreeMigration(folder, current, ctx.signal), values, s.session_id);
|
|
226
|
+
}
|
|
227
|
+
await waitForTurnEnd(ctx, s, env);
|
|
228
|
+
}
|
|
153
229
|
const bytes = completeRecords(readTranscript(s.file));
|
|
154
230
|
if (bytes.length > TRANSCRIPT_LIMIT)
|
|
155
231
|
throw new UsageError('This session’s transcript is above the 256 MiB move bound.');
|
|
232
|
+
if (values['preserve-all-worktrees'] && values['this-worktree-only'])
|
|
233
|
+
throw new UsageError('Choose --preserve-all-worktrees or --this-worktree-only.');
|
|
234
|
+
const plan = values['this-worktree-only'] === true && values.plan !== true ? null : await inspectWorktreeMigration(folder, bytes, ctx.signal);
|
|
235
|
+
// Refuse before syncing credentials, creating a VM or snapshotting an unrelated
|
|
236
|
+
// folder. An explicit all-worktree scope also accepts record-confirmed linked owners.
|
|
237
|
+
confirmedCwds(s, bytes, folder, values['preserve-all-worktrees'] === true || values.plan === true ? plan ?? undefined : undefined);
|
|
238
|
+
if (values.plan === true || values['preserve-all-worktrees'] === true) {
|
|
239
|
+
if (!plan)
|
|
240
|
+
throw new UsageError('Preserve-all-worktrees requires a Git repository.');
|
|
241
|
+
await captureWorktreeMigration(plan, ctx.signal);
|
|
242
|
+
const scope = migrationScope(plan);
|
|
243
|
+
if (values.plan === true) {
|
|
244
|
+
out(ctx, `${JSON.stringify(scope, null, 2)}\n`, scope);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
ctx.io.stderr.write(`Preserving ${plan.trees.length} registered worktrees, ${plan.files} files, ${plan.bytes} file bytes (archives add headers). Includes ignored files and Git metadata:\n${plan.trees.map(t => ` ${t.source}`).join('\n')}\nSymlink targets and unrelated sibling folders are not uploaded.\n`);
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
requireExplicitWorktreeScope(plan, values, s.session_id);
|
|
156
251
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
157
252
|
if (!ctx.json)
|
|
158
253
|
ctx.io.stderr.write(`Moving ${s.harness === 'claude' ? 'Claude Code' : 'Codex'} session “${s.title}” to the cloud…\n`);
|
|
@@ -162,11 +257,16 @@ export async function moveSession(ctx, values) {
|
|
|
162
257
|
requireClaudeTerminal(ctx);
|
|
163
258
|
let created;
|
|
164
259
|
let placed;
|
|
260
|
+
let preserved;
|
|
165
261
|
try {
|
|
166
262
|
created = await createConversation(ctx, folder, s.harness, 'terminal', undefined, {}, {
|
|
167
263
|
session_id: s.session_id, title: s.title,
|
|
264
|
+
...(values['preserve-all-worktrees'] === true && plan ? { preserveWorktrees: true,
|
|
265
|
+
seedRepository: async (ws, c) => { preserved = await preserveWorktreeMigration(ws.cell(), plan, c, ctx.signal); } } : {}),
|
|
168
266
|
// The workspace's cell handle is shared with the seed that follows: it stays open.
|
|
169
|
-
beforeSeed: async (ws, c) => {
|
|
267
|
+
beforeSeed: async (ws, c) => {
|
|
268
|
+
placed = await placeTranscript(ws.cell(), c, s, bytes, folder, ctx.signal, values['preserve-all-worktrees'] === true && plan ? plan : undefined);
|
|
269
|
+
},
|
|
170
270
|
});
|
|
171
271
|
}
|
|
172
272
|
catch (e) {
|
|
@@ -198,7 +298,7 @@ export async function moveSession(ctx, values) {
|
|
|
198
298
|
if (!attach) {
|
|
199
299
|
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
200
300
|
await conversationTerminal(quiet, ws, c, place, { ...values, detach: true });
|
|
201
|
-
out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}) });
|
|
301
|
+
out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}), ...(preserved ? { preserved } : {}) });
|
|
202
302
|
return 0;
|
|
203
303
|
}
|
|
204
304
|
const where = openInChoices(ctx, project.slug)[s.harness];
|
|
@@ -208,7 +308,7 @@ export async function moveSession(ctx, values) {
|
|
|
208
308
|
if (where === 'claude-desktop')
|
|
209
309
|
return openClaudeDesktopSession(ctx, ws, c);
|
|
210
310
|
const url = await remoteControlUrl(ctx, ws, c);
|
|
211
|
-
if (!openUrl(url, ctx.io.env))
|
|
311
|
+
if (!(await openUrl(url, ctx.io.env)))
|
|
212
312
|
throw new Error(`could not open ${url}`);
|
|
213
313
|
out(ctx, `${url}\n`, { moved, conversation: c, url });
|
|
214
314
|
return 0;
|
|
@@ -223,7 +323,7 @@ export function listMovable(ctx, values, env, now = Date.now()) {
|
|
|
223
323
|
const sessions = listLocalSessions(folder, env, { harness: harnessOf(values), limit: 10 }).map(s => {
|
|
224
324
|
const m = moved[movedKey(s.harness, s.session_id)];
|
|
225
325
|
const state = sessionState(s, env, now);
|
|
226
|
-
return { harness: s.harness, session_id: s.session_id, title: s.title, updated_at: s.updated_at, source: s.source, cwd: s.cwd,
|
|
326
|
+
return { harness: s.harness, session_id: s.session_id, title: s.title, updated_at: s.updated_at, source: s.source, cwd: s.cwd, cwds: s.cwds,
|
|
227
327
|
running: state.running, turn: state.turn,
|
|
228
328
|
moved: m ? { conversation_id: m.conversation_id, key: m.key, moved_at: m.moved_at, diverged: continuedAfter(s, m.bytes) } : null };
|
|
229
329
|
});
|