@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.
- package/CHANGELOG.md +278 -1
- package/README.md +225 -73
- package/dist/account-args.js +7 -2
- package/dist/account-commands.js +56 -106
- package/dist/account-pool.d.ts +20 -15
- package/dist/account-pool.js +51 -97
- 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-files.d.ts +19 -0
- package/dist/agent-config-files.js +70 -1
- package/dist/agent-config-return.d.ts +194 -0
- package/dist/agent-config-return.js +724 -0
- package/dist/agent-config-sync.d.ts +10 -2
- package/dist/agent-config-sync.js +45 -51
- 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 +104 -55
- 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 +81 -15
- package/dist/conversations.d.ts +77 -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 +52 -4
- package/dist/errors.d.ts +34 -1
- package/dist/errors.js +56 -5
- package/dist/filesystem-commands.d.ts +22 -3
- package/dist/filesystem-commands.js +132 -16
- package/dist/format.d.ts +2 -1
- package/dist/format.js +10 -1
- package/dist/handlers.js +4 -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 +190 -0
- package/dist/machine-setup.js +849 -0
- package/dist/machine-status.d.ts +67 -0
- package/dist/machine-status.js +140 -0
- package/dist/main.js +23 -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 +13 -3
- package/dist/seed-phases.js +21 -3
- 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.d.ts +2 -0
- package/dist/terminal-onboarding.js +11 -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,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
|
});
|
package/dist/onboarding.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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" | "
|
|
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" | "
|
|
187
|
+
state: "running" | "failed" | "creating" | "starting" | "suspending" | "suspended" | "resuming" | "forking" | "stopping" | "deleting" | "deleted";
|
|
184
188
|
workspace: {
|
|
185
189
|
id: string;
|
|
186
190
|
key: string;
|