@shardflux/cli 0.19.1 → 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 (115) hide show
  1. package/CHANGELOG.md +278 -1
  2. package/README.md +225 -73
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +20 -15
  6. package/dist/account-pool.js +51 -97
  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-files.d.ts +19 -0
  16. package/dist/agent-config-files.js +70 -1
  17. package/dist/agent-config-return.d.ts +194 -0
  18. package/dist/agent-config-return.js +724 -0
  19. package/dist/agent-config-sync.d.ts +10 -2
  20. package/dist/agent-config-sync.js +45 -51
  21. package/dist/agent-credentials.js +7 -3
  22. package/dist/agent-repository.d.ts +16 -3
  23. package/dist/agent-repository.js +149 -74
  24. package/dist/agent-setup.d.ts +14 -3
  25. package/dist/agent-setup.js +78 -36
  26. package/dist/agent-target-commands.js +24 -13
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +104 -55
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-forward.d.ts +2 -0
  35. package/dist/cli-proxy-forward.js +9 -2
  36. package/dist/cli-proxy-hub.d.ts +69 -25
  37. package/dist/cli-proxy-hub.js +231 -54
  38. package/dist/cli-proxy.d.ts +70 -1
  39. package/dist/cli-proxy.js +298 -32
  40. package/dist/client-settings.d.ts +39 -0
  41. package/dist/client-settings.js +91 -0
  42. package/dist/codex-cloud.d.ts +5 -0
  43. package/dist/codex-cloud.js +88 -17
  44. package/dist/command-center-actions.js +5 -1
  45. package/dist/commands.js +171 -22
  46. package/dist/conversation-commands.d.ts +5 -0
  47. package/dist/conversation-commands.js +8 -5
  48. package/dist/conversation-terminal.d.ts +10 -2
  49. package/dist/conversation-terminal.js +81 -15
  50. package/dist/conversations.d.ts +77 -1
  51. package/dist/conversations.js +257 -23
  52. package/dist/device-bridge.d.ts +1 -1
  53. package/dist/device-bridge.js +6 -4
  54. package/dist/device-helper.js +52 -4
  55. package/dist/errors.d.ts +34 -1
  56. package/dist/errors.js +56 -5
  57. package/dist/filesystem-commands.d.ts +22 -3
  58. package/dist/filesystem-commands.js +132 -16
  59. package/dist/format.d.ts +2 -1
  60. package/dist/format.js +10 -1
  61. package/dist/handlers.js +4 -1
  62. package/dist/initial-prompt.d.ts +8 -0
  63. package/dist/initial-prompt.js +43 -1
  64. package/dist/large-media.d.ts +14 -0
  65. package/dist/large-media.js +100 -0
  66. package/dist/linux-terminal.d.ts +5 -3
  67. package/dist/linux-terminal.js +6 -20
  68. package/dist/local-sessions.d.ts +26 -2
  69. package/dist/local-sessions.js +113 -24
  70. package/dist/machine-setup.d.ts +190 -0
  71. package/dist/machine-setup.js +849 -0
  72. package/dist/machine-status.d.ts +67 -0
  73. package/dist/machine-status.js +140 -0
  74. package/dist/main.js +23 -13
  75. package/dist/move-worktrees-restore.py +144 -0
  76. package/dist/move-worktrees.d.ts +63 -0
  77. package/dist/move-worktrees.js +201 -0
  78. package/dist/move.d.ts +10 -1
  79. package/dist/move.js +116 -16
  80. package/dist/onboarding.d.ts +11 -7
  81. package/dist/onboarding.js +59 -27
  82. package/dist/prepared-repository.d.ts +202 -0
  83. package/dist/prepared-repository.js +860 -0
  84. package/dist/prepared-repository.py +1306 -0
  85. package/dist/project-preparation.d.ts +48 -0
  86. package/dist/project-preparation.js +304 -0
  87. package/dist/project-sync-manifest.d.ts +4 -0
  88. package/dist/project-sync-manifest.js +5 -1
  89. package/dist/project-sync-transfer.js +1 -1
  90. package/dist/project-sync.d.ts +19 -4
  91. package/dist/project-sync.js +31 -8
  92. package/dist/project-template-setup.d.ts +33 -1
  93. package/dist/project-template-setup.js +59 -2
  94. package/dist/secret-ask.d.ts +72 -0
  95. package/dist/secret-ask.js +351 -0
  96. package/dist/seed-phases.d.ts +13 -3
  97. package/dist/seed-phases.js +21 -3
  98. package/dist/setup-cache.d.ts +7 -0
  99. package/dist/setup-cache.js +32 -0
  100. package/dist/t3-launch.js +1 -4
  101. package/dist/t3-mode.d.ts +8 -2
  102. package/dist/t3-mode.js +19 -4
  103. package/dist/t3-phone.d.ts +4 -2
  104. package/dist/t3-phone.js +12 -4
  105. package/dist/terminal-apps.d.ts +157 -0
  106. package/dist/terminal-apps.js +561 -0
  107. package/dist/terminal-onboarding.d.ts +2 -0
  108. package/dist/terminal-onboarding.js +11 -1
  109. package/dist/this-computer.d.ts +11 -0
  110. package/dist/this-computer.js +36 -0
  111. package/dist/thread-on-computer.js +4 -1
  112. package/dist/update.js +11 -3
  113. package/dist/worktree-twin.d.ts +10 -2
  114. package/dist/worktree-twin.js +27 -9
  115. package/package.json +5 -5
@@ -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
  });
@@ -1,6 +1,7 @@
1
1
  import type { Ctx } from './commands.js';
2
2
  import type { Values } from './args.js';
3
3
  import type { CodexConnectorView, CodexHealth } from './codex-cloud.js';
4
+ import type { MachineRow } from './machine-status.js';
4
5
  /**
5
6
  * The first-run demo's task: it shows the agent's own computer at work. The user copies it and pastes it into the
6
7
  * launcher themselves; Daylight's own CLAUDE.md and AGENTS.md say how to run it and test it in the desktop browser.
@@ -13,7 +14,7 @@ export type AgentApp = 'claude' | 'codex';
13
14
  * rest (Claude Code's installer uses ~/.local/bin).
14
15
  */
15
16
  export declare function installDirs(home: string): string[];
16
- /** Claude Code or Codex is on this Mac: its home folder (~/.claude, ~/.codex) or its command on PATH or in an install folder. */
17
+ /** Claude Code or Codex is on this computer: its home folder (~/.claude, ~/.codex) or its command on PATH or in an install folder. */
17
18
  export declare function foundApps(env: NodeJS.ProcessEnv): Promise<AgentApp[]>;
18
19
  /** --apps claude,codex (repeatable or comma separated); unknown names are refused. */
19
20
  export declare function parseApps(value: unknown): AgentApp[] | undefined;
@@ -71,32 +72,35 @@ export interface T3SignInRow {
71
72
  * appears in T3 Code and the T3 app by itself. `startSignIn` (`--t3-connect`) starts that sign-in. Null when T3 Code
72
73
  * is not installed.
73
74
  */
74
- export declare function t3Connection(ctx: Ctx, signedIn: boolean, appDirs?: string[], platform?: NodeJS.Platform, startSignIn?: boolean): Promise<Connection | null>;
75
+ export declare function t3Connection(ctx: Ctx, signedIn: boolean, appDirs?: string[], platform?: NodeJS.Platform, startSignIn?: boolean, scope?: Values): Promise<Connection | null>;
75
76
  /** Claude: the account login is ready and Claude itself accepts it right now. */
76
77
  export declare function claudeConnection(row: CredentialRow | undefined): Omit<Connection, 'id' | 'picked' | 'found'>;
77
- /** Codex: this Mac's Codex login is copied and the account's Codex remote device (one executor) is connected. */
78
+ /** Codex: this computer's Codex login is copied and the account's Codex remote device (one executor) is connected. */
78
79
  export declare function codexConnection(local: boolean, synced: boolean, connector: CodexConnectorView | null, health: CodexHealth): Omit<Connection, 'id' | 'picked' | 'found'>;
79
80
  /**
80
81
  * The onboarding report, for an agent setting itself up from chat and for the menu bar app. It verifies every
81
82
  * connection live: the Shardflux account, Claude (Claude itself accepts the login) and Codex (the login is copied and
82
- * the account's Codex remote device is connected), for the apps picked (--apps, else saved, else found on this Mac).
83
+ * the account's Codex remote device is connected), for the apps picked (--apps, else saved, else found on this computer).
83
84
  * With --connect it also does what needs no sign-in: copies the Codex login and enables the Codex remote device.
84
85
  */
85
- export declare function onboardingReport(ctx: Ctx, values: Values): Promise<{
86
+ export declare function onboardingReport(ctx: Ctx, values: Values, platform?: NodeJS.Platform): Promise<{
86
87
  version: number;
87
88
  api_url: string;
88
89
  apps: AgentApp[];
89
90
  found_apps: AgentApp[];
90
91
  connections: Connection[];
91
92
  ready: boolean;
93
+ connections_ready: boolean;
94
+ machine_ready: boolean | null;
92
95
  account_error: string | null;
96
+ machine: MachineRow | null;
93
97
  demo: {
94
98
  prompt: string;
95
99
  project: string;
96
100
  prepare: string[];
97
101
  };
98
102
  activity: {
99
- state: "running" | "failed" | "suspended" | "deleted" | "creating" | "starting" | "suspending" | "resuming" | "forking" | "stopping" | "deleting";
103
+ state: "running" | "failed" | "creating" | "starting" | "suspending" | "suspended" | "resuming" | "forking" | "stopping" | "deleting" | "deleted";
100
104
  workspace: {
101
105
  id: string;
102
106
  key: string;
@@ -180,7 +184,7 @@ interface ProcessRow {
180
184
  export declare function activeCommands(processes: ProcessRow[]): string[];
181
185
  /** Commands and listening ports of a running VM. Never wakes a parked computer. */
182
186
  export declare function liveActivity(ctx: Ctx, ref: string): Promise<{
183
- state: "running" | "failed" | "suspended" | "deleted" | "creating" | "starting" | "suspending" | "resuming" | "forking" | "stopping" | "deleting";
187
+ state: "running" | "failed" | "creating" | "starting" | "suspending" | "suspended" | "resuming" | "forking" | "stopping" | "deleting" | "deleted";
184
188
  workspace: {
185
189
  id: string;
186
190
  key: string;