@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +214 -1
  2. package/README.md +215 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-return.d.ts +46 -2
  16. package/dist/agent-config-return.js +110 -4
  17. package/dist/agent-credentials.js +7 -3
  18. package/dist/agent-repository.d.ts +16 -3
  19. package/dist/agent-repository.js +149 -74
  20. package/dist/agent-setup.d.ts +14 -3
  21. package/dist/agent-setup.js +78 -36
  22. package/dist/agent-target-commands.js +24 -13
  23. package/dist/args.d.ts +1 -1
  24. package/dist/args.js +94 -54
  25. package/dist/bin.js +5 -1
  26. package/dist/browser.d.ts +13 -2
  27. package/dist/browser.js +30 -14
  28. package/dist/claude-cloud-login.d.ts +24 -2
  29. package/dist/claude-cloud-login.js +151 -11
  30. package/dist/cli-proxy-forward.d.ts +2 -0
  31. package/dist/cli-proxy-forward.js +9 -2
  32. package/dist/cli-proxy-hub.d.ts +69 -25
  33. package/dist/cli-proxy-hub.js +231 -54
  34. package/dist/cli-proxy.d.ts +70 -1
  35. package/dist/cli-proxy.js +298 -32
  36. package/dist/client-settings.d.ts +39 -0
  37. package/dist/client-settings.js +91 -0
  38. package/dist/codex-cloud.d.ts +5 -0
  39. package/dist/codex-cloud.js +88 -17
  40. package/dist/command-center-actions.js +5 -1
  41. package/dist/commands.js +171 -22
  42. package/dist/conversation-commands.d.ts +5 -0
  43. package/dist/conversation-commands.js +8 -5
  44. package/dist/conversation-terminal.d.ts +10 -2
  45. package/dist/conversation-terminal.js +77 -13
  46. package/dist/conversations.d.ts +73 -1
  47. package/dist/conversations.js +257 -23
  48. package/dist/device-bridge.d.ts +1 -1
  49. package/dist/device-bridge.js +6 -4
  50. package/dist/device-helper.js +43 -22
  51. package/dist/errors.d.ts +27 -1
  52. package/dist/errors.js +45 -5
  53. package/dist/filesystem-commands.d.ts +16 -9
  54. package/dist/filesystem-commands.js +108 -54
  55. package/dist/format.d.ts +2 -1
  56. package/dist/format.js +10 -1
  57. package/dist/handlers.js +3 -1
  58. package/dist/initial-prompt.d.ts +8 -0
  59. package/dist/initial-prompt.js +43 -1
  60. package/dist/large-media.d.ts +14 -0
  61. package/dist/large-media.js +100 -0
  62. package/dist/linux-terminal.d.ts +5 -3
  63. package/dist/linux-terminal.js +6 -20
  64. package/dist/local-sessions.d.ts +26 -2
  65. package/dist/local-sessions.js +113 -24
  66. package/dist/machine-setup.d.ts +11 -1
  67. package/dist/machine-setup.js +69 -22
  68. package/dist/machine-status.d.ts +67 -0
  69. package/dist/machine-status.js +140 -0
  70. package/dist/main.js +11 -13
  71. package/dist/move-worktrees-restore.py +144 -0
  72. package/dist/move-worktrees.d.ts +63 -0
  73. package/dist/move-worktrees.js +201 -0
  74. package/dist/move.d.ts +10 -1
  75. package/dist/move.js +116 -16
  76. package/dist/onboarding.d.ts +11 -7
  77. package/dist/onboarding.js +59 -27
  78. package/dist/prepared-repository.d.ts +202 -0
  79. package/dist/prepared-repository.js +860 -0
  80. package/dist/prepared-repository.py +1306 -0
  81. package/dist/project-preparation.d.ts +48 -0
  82. package/dist/project-preparation.js +304 -0
  83. package/dist/project-sync-manifest.d.ts +4 -0
  84. package/dist/project-sync-manifest.js +5 -1
  85. package/dist/project-sync-transfer.js +1 -1
  86. package/dist/project-sync.d.ts +19 -4
  87. package/dist/project-sync.js +31 -8
  88. package/dist/project-template-setup.d.ts +33 -1
  89. package/dist/project-template-setup.js +59 -2
  90. package/dist/secret-ask.d.ts +72 -0
  91. package/dist/secret-ask.js +351 -0
  92. package/dist/seed-phases.d.ts +8 -1
  93. package/dist/seed-phases.js +17 -2
  94. package/dist/setup-cache.d.ts +7 -0
  95. package/dist/setup-cache.js +32 -0
  96. package/dist/t3-launch.js +1 -4
  97. package/dist/t3-mode.d.ts +8 -2
  98. package/dist/t3-mode.js +19 -4
  99. package/dist/t3-phone.d.ts +4 -2
  100. package/dist/t3-phone.js +12 -4
  101. package/dist/terminal-apps.d.ts +157 -0
  102. package/dist/terminal-apps.js +561 -0
  103. package/dist/terminal-onboarding.js +6 -1
  104. package/dist/this-computer.d.ts +11 -0
  105. package/dist/this-computer.js +36 -0
  106. package/dist/thread-on-computer.js +4 -1
  107. package/dist/update.js +11 -3
  108. package/dist/worktree-twin.d.ts +10 -2
  109. package/dist/worktree-twin.js +27 -9
  110. 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
- const sessionCwd = realOr(s.cwd), inside = relative(folder, sessionCwd);
100
- if (inside.startsWith('..'))
101
- throw new UsageError('The session ran outside this project folder; run shard move in its project.');
102
- let moved = rewriteSessionPaths(bytes, folder, c.worktree_path);
103
- if (s.cwd !== sessionCwd && !s.cwd.startsWith(`${folder}/`) && s.cwd !== folder) {
104
- // The transcript recorded a path through a link (/tmp for /private/tmp): move that spelling too.
105
- moved = rewriteSessionPaths(moved, s.cwd.slice(0, s.cwd.length - inside.length).replace(/\/$/, ''), c.worktree_path);
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' ? here : projectRoot(existsSync(s.cwd) ? s.cwd : here);
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
- await waitForTurnEnd(ctx, s, env);
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) => { placed = await placeTranscript(ws.cell(), c, s, bytes, folder, ctx.signal); },
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
  });