@shardflux/cli 0.18.0 → 0.19.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 (54) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +28 -1
  3. package/dist/account-args.js +1 -0
  4. package/dist/agent-commands.d.ts +1 -0
  5. package/dist/agent-commands.js +15 -5
  6. package/dist/agent-config-files.d.ts +16 -2
  7. package/dist/agent-config-files.js +48 -61
  8. package/dist/agent-config-sync.d.ts +12 -1
  9. package/dist/agent-config-sync.js +228 -54
  10. package/dist/agent-repository.d.ts +39 -9
  11. package/dist/agent-repository.js +309 -92
  12. package/dist/agent-state.d.ts +3 -0
  13. package/dist/agent-state.js +13 -0
  14. package/dist/agent-target-commands.d.ts +2 -0
  15. package/dist/agent-target-commands.js +55 -18
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +16 -4
  18. package/dist/command-center-actions.js +31 -5
  19. package/dist/conversation-commands.js +6 -1
  20. package/dist/conversation-router.js +7 -11
  21. package/dist/conversation-stash.js +64 -18
  22. package/dist/conversation-terminal.d.ts +11 -1
  23. package/dist/conversation-terminal.js +28 -7
  24. package/dist/conversations.d.ts +17 -3
  25. package/dist/conversations.js +58 -6
  26. package/dist/errors.d.ts +1 -1
  27. package/dist/errors.js +1 -1
  28. package/dist/files-mirror.d.ts +1 -9
  29. package/dist/files-mirror.js +19 -3
  30. package/dist/format.js +37 -2
  31. package/dist/handlers.js +3 -1
  32. package/dist/local-sessions.d.ts +60 -0
  33. package/dist/local-sessions.js +445 -0
  34. package/dist/main.js +12 -0
  35. package/dist/move.d.ts +44 -0
  36. package/dist/move.js +248 -0
  37. package/dist/moved-sessions.d.ts +33 -0
  38. package/dist/moved-sessions.js +91 -0
  39. package/dist/onboarding.d.ts +18 -3
  40. package/dist/onboarding.js +72 -46
  41. package/dist/project-sync-manifest.d.ts +31 -3
  42. package/dist/project-sync-manifest.js +192 -108
  43. package/dist/project-sync-transfer.d.ts +50 -4
  44. package/dist/project-sync-transfer.js +404 -106
  45. package/dist/project-sync.js +30 -33
  46. package/dist/seed-phases.d.ts +29 -0
  47. package/dist/seed-phases.js +83 -0
  48. package/dist/t3-launch.js +3 -1
  49. package/dist/t3-mode.d.ts +7 -2
  50. package/dist/t3-mode.js +39 -5
  51. package/dist/thread-on-computer.js +4 -0
  52. package/dist/worktree-twin.d.ts +2 -6
  53. package/dist/worktree-twin.js +16 -51
  54. package/package.json +5 -5
@@ -1,12 +1,14 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
3
- import { homedir, tmpdir } from 'node:os';
4
- import { join, resolve } from 'node:path';
5
- import { CodedError, UsageError } from "./errors.js";
2
+ import { spawn } from 'node:child_process';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { basename, join, resolve } from 'node:path';
6
+ import { UsageError } from "./errors.js";
6
7
  import { AgentStateStore } from "./agent-state.js";
7
8
  import { projectIdentity } from "./project-identity.js";
8
- import { localManifest, projectGit } from "./project-sync-manifest.js";
9
- import { applyRemoteSyncBatch, readLocalSyncFile, writeSyncBlob } from "./project-sync-transfer.js";
9
+ import { excludedSyncPath, localEntry, projectGit, projectGitPaths, selectedProjectPaths, validSyncPath } from "./project-sync-manifest.js";
10
+ import { pushLocalFiles, SYNC_CHUNK_BYTES, writeSyncBlob } from "./project-sync-transfer.js";
11
+ import { seedPhase } from "./seed-phases.js";
10
12
  export function projectWorkspacePath(project) {
11
13
  if (!/^[a-z0-9][a-z0-9._-]{0,95}$/.test(project.slug))
12
14
  throw new Error('invalid sync project slug');
@@ -20,7 +22,28 @@ export function needsGitSeed(ctx, ws) {
20
22
  const seed = new AgentStateStore(ctx.store.dir).read().git_seeding?.[ws.id];
21
23
  return seed !== undefined && seed.api_url === ctx.apiUrl;
22
24
  }
23
- /** One history seed for C5 and C11. Bundles use the bounded files path, without a Git helper or second connection. */
25
+ /**
26
+ * The checks a start can fail on, run on this computer before a VM is allocated (0.19.0+). A project's size, file
27
+ * count or history never refuses a start; only a folder inside another repository does.
28
+ */
29
+ export async function preflightProjectSeed(folder, signal) {
30
+ let top;
31
+ try {
32
+ top = (await projectGit(folder, ['rev-parse', '--show-toplevel'], signal)).trim();
33
+ }
34
+ catch (error) {
35
+ if (signal?.aborted || error.code !== 128)
36
+ throw error;
37
+ return;
38
+ }
39
+ if (top !== folder)
40
+ throw new UsageError('project sync requires the Git repository root; run shard there');
41
+ }
42
+ /**
43
+ * One seed for every start (C5, C11). A Git project's files come from Git in the VM: HEAD is checked out there and only
44
+ * what differs from HEAD on this computer (changes, untracked files, deletions, .env files) is copied (0.19.0+). Its
45
+ * size, file count and history length do not change what is sent.
46
+ */
24
47
  export async function seedAgentRepository(ctx, ws, folder, options = {}) {
25
48
  const project = projectIdentity(folder);
26
49
  // A cloud-only `like` launch must never seed an unrelated laptop repository.
@@ -41,8 +64,11 @@ export async function seedAgentRepository(ctx, ws, folder, options = {}) {
41
64
  initial?.ready();
42
65
  state.update(doc => { const seeds = (doc.git_seeding ?? {}); delete seeds[ws.id]; doc.git_seeding = seeds; });
43
66
  }
44
- /** History above this goes through the project's origin instead of the bounded files path. */
45
- export const HISTORY_BUNDLE_LIMIT = 256 << 20;
67
+ /**
68
+ * Above this (an estimate: the repository's packed and loose objects) the workspace fetches the history from origin as a
69
+ * blobless partial clone, and receives only the commits origin lacks (0.19.0+). Below it, one small bundle is faster.
70
+ */
71
+ export const ORIGIN_FIRST_BYTES = 32 << 20;
46
72
  async function optionalGit(folder, args, signal) {
47
73
  try {
48
74
  return (await projectGit(folder, args, signal)).trim();
@@ -54,71 +80,137 @@ async function optionalGit(folder, args, signal) {
54
80
  }
55
81
  }
56
82
  const tail = (text) => text.trim().split('\n').slice(-3).join(' ').slice(-400);
57
- // Fetches one origin branch (or origin's HEAD) into an empty workspace repository and prints its commit.
58
- const FETCH_ORIGIN = 'set -eu; git init -q "$1"; case "$3" in HEAD) dst=refs/shard-sync/origin ;; *) dst="refs/remotes/origin/${3#refs/heads/}" ;; esac; ' +
59
- 'git -C "$1" -c core.hooksPath=/dev/null -c credential.interactive=false fetch -q --no-tags "$2" "+$3:$dst" >&2; git -C "$1" rev-parse --verify "$dst^{commit}"';
83
+ /** The repository's object store size, an upper bound of any one branch's history bundle. Reads sizes only. */
84
+ export async function historyEstimate(folder, signal) {
85
+ const counts = new Map((await optionalGit(folder, ['count-objects', '-v'], signal)).split('\n').map(line => line.split(': ')));
86
+ return (Number(counts.get('size') ?? 0) + Number(counts.get('size-pack') ?? 0)) * 1024;
87
+ }
88
+ // Fetches one origin branch (or origin's HEAD) into an empty workspace repository as a blobless partial clone (0.19.0+):
89
+ // every commit and tree. Alongside it, a depth-1 fetch of the same ref brings the tip's files in one pack, moved in
90
+ // beside the history, so the checkout reads them locally instead of asking for each blob. Prints the commit. A failed
91
+ // history fetch leaves a plain repository, so the bundle fallback is not a partial clone of an origin it cannot reach.
92
+ export const FETCH_ORIGIN = [
93
+ 'set -eu',
94
+ 'git init -q "$1"; git -C "$1" config remote.origin.url "$2"',
95
+ 'case "$3" in HEAD) dst=refs/shard-sync/origin ;; *) dst="refs/remotes/origin/${3#refs/heads/}" ;; esac',
96
+ 'tip="$1/.git/shard-sync-tip"; rm -rf "$tip"; git init -q --bare "$tip"',
97
+ 'git -C "$tip" -c core.hooksPath=/dev/null -c credential.interactive=false -c fetch.unpackLimit=1 fetch -q --no-tags --depth=1 "$2" "+$3:refs/tip" >/dev/null 2>&1 &',
98
+ 'job=$!',
99
+ 'if ! git -C "$1" -c core.hooksPath=/dev/null -c credential.interactive=false fetch -q --no-tags --filter=blob:none origin "+$3:$dst" >&2; then',
100
+ ' wait "$job" || true; rm -rf "$tip"',
101
+ ' git -C "$1" config --unset remote.origin.promisor || true; git -C "$1" config --unset remote.origin.partialclonefilter || true; exit 1',
102
+ 'fi',
103
+ 'if wait "$job"; then for pack in "$tip"/objects/pack/pack-*.pack; do [ -f "$pack" ] || continue; base=${pack%.pack}',
104
+ ' for ext in idx rev pack; do if [ -f "$base.$ext" ]; then mv "$base.$ext" "$1/.git/objects/pack/"; fi; done; done; fi',
105
+ 'rm -rf "$tip"',
106
+ 'git -C "$1" rev-parse --verify "$dst^{commit}"',
107
+ ].join('\n');
108
+ // Completes a blobless partial clone in the background, at the lowest priority, once the start is done: later pushes to
109
+ // the project hub, `git log -p` and blame then read local objects.
110
+ export const BACKFILL = 'set -eu; cd "$1"; git -c core.hooksPath=/dev/null -c credential.interactive=false fetch -q --no-tags --no-write-fetch-head --refetch --no-filter origin "$2"; ' +
111
+ 'git config --unset remote.origin.partialclonefilter || true; git config --unset remote.origin.promisor || true';
60
112
  // Applies an optional bundle of HEAD (plus its branch and upstream) and points the workspace HEAD at the laptop's.
61
113
  const APPLY_HISTORY = 'set -eu; git init -q "$1"; if [ -n "$2" ]; then git -C "$1" -c core.hooksPath=/dev/null fetch -q --update-head-ok "$2" HEAD:refs/shard-sync/initial ' +
62
114
  '"+refs/heads/*:refs/heads/*" "+refs/tags/*:refs/tags/*" "+refs/remotes/*:refs/remotes/*"; fi; ' +
63
115
  'if [ -n "$4" ]; then git -C "$1" symbolic-ref HEAD "$4"; else git -C "$1" update-ref --no-deref HEAD "$3"; fi; git -C "$1" update-ref HEAD "$3"; git -C "$1" read-tree HEAD; ' +
64
116
  'for ref in refs/shard-sync/initial refs/shard-sync/origin; do if git -C "$1" rev-parse -q --verify "$ref" >/dev/null; then git -C "$1" update-ref -d "$ref"; fi; done';
65
- /** A history the first copy cannot carry (repository_too_large, 0.13.3+): the facts, and the next step as the hint. */
66
- export function tooLarge(message, hint, branch, origin) {
67
- return new CodedError('repository_too_large', `${message}.`, hint, { limit_bytes: HISTORY_BUNDLE_LIMIT, branch, ...(origin ? { origin } : {}) });
117
+ // Writes HEAD's files that are missing from the workspace tree (never replacing one), except those this computer
118
+ // deleted. A partial clone first fetches the missing blobs in one request; Git LFS files are left to the copy.
119
+ export const CHECKOUT_HEAD = String.raw `
120
+ import subprocess,sys
121
+ root,skipfile=sys.argv[1],sys.argv[2]
122
+ def git(*a,data=None): return subprocess.run(['git','-C',root,'-c','core.hooksPath=/dev/null','-c','filter.lfs.smudge=cat','-c','filter.lfs.process=','-c','filter.lfs.required=false',*a],check=True,input=data,stdout=subprocess.PIPE).stdout
123
+ skip=set(open(skipfile,'rb').read().split(b'\0')) if skipfile else set()
124
+ missing=[p for p in git('ls-files','-z','--deleted').split(b'\0') if p and p not in skip]
125
+ if missing and git('config','--bool','--default','false','remote.origin.promisor').strip()==b'true':
126
+ want,wanted=set(),set(missing)
127
+ for rec in git('ls-files','-z','-s').split(b'\0'):
128
+ if rec:
129
+ meta,path=rec.split(b'\t',1)
130
+ if path in wanted: want.add(meta.split()[1])
131
+ absent={l[1:] for l in git('rev-list','--objects','--no-walk','--missing=print','HEAD').split(b'\n') if l.startswith(b'?')}
132
+ want&=absent
133
+ if want: git('-c','fetch.negotiationAlgorithm=noop','fetch','-q','--no-tags','--no-write-fetch-head','--recurse-submodules=no','--filter=blob:none','--stdin','origin',data=b'\n'.join(sorted(want))+b'\n')
134
+ for at in range(0,len(missing),4096): git('checkout-index','-z','--stdin',data=b'\0'.join(missing[at:at+4096])+b'\0')
135
+ print(len(missing))
136
+ `;
137
+ /** `git bundle create` streamed straight into the workspace in chunks: any size, no temporary file, no time limit. */
138
+ async function streamBundle(cell, folder, remote, revs, signal) {
139
+ const child = spawn('git', ['-C', folder, '-c', 'core.hooksPath=/dev/null', 'bundle', 'create', '-q', '-', ...revs], { stdio: ['ignore', 'pipe', 'pipe'], signal, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' } });
140
+ let stderr = '';
141
+ child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-4000); });
142
+ const exited = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', code => resolve(code ?? 1)); });
143
+ let pending = [], size = 0, total = 0;
144
+ const flush = async () => { if (!size && total)
145
+ return; await cell.files.write(remote, Buffer.concat(pending, size), { mode: '0600', append: total > 0 }); total += size; pending = []; size = 0; };
146
+ try {
147
+ for await (const chunk of child.stdout) {
148
+ pending.push(chunk);
149
+ size += chunk.length;
150
+ if (size >= SYNC_CHUNK_BYTES)
151
+ await flush();
152
+ }
153
+ await flush();
154
+ }
155
+ catch (error) {
156
+ child.kill();
157
+ await exited.catch(() => undefined);
158
+ throw error;
159
+ }
160
+ const code = await exited;
161
+ if (code !== 0)
162
+ throw new Error(`Git could not bundle the project history: ${tail(stderr)}`);
163
+ return total;
68
164
  }
69
165
  /**
70
166
  * Imports the checked-out branch's history into an empty workspace repository. Only HEAD, its branch and that branch's
71
- * upstream are bundled: other branches, worktrees and stashes stay on this computer. When that is still above `limit`,
72
- * the workspace fetches `origin` itself (with its own Git credentials) and receives only the commits origin lacks.
167
+ * upstream are sent: other branches, worktrees and stashes stay on this computer. A history above `originFirst` comes
168
+ * from origin as a blobless partial clone (the workspace's own Git credentials), plus a bundle of the commits origin
169
+ * lacks. Without a reachable origin the whole bundle is streamed in chunks: no history size refuses (0.19.0+).
73
170
  */
74
- export async function importProjectHistory(cell, folder, root, head, branch, origin, signal, limit = HISTORY_BUNDLE_LIMIT) {
171
+ export async function importProjectHistory(cell, folder, root, head, branch, origin, signal, originFirst = ORIGIN_FIRST_BYTES, report = () => undefined) {
75
172
  const upstream = branch ? await optionalGit(folder, ['rev-parse', '--symbolic-full-name', `${branch.slice(11)}@{upstream}`], signal) : '';
76
173
  const tracked = upstream.startsWith('refs/remotes/') ? upstream : '';
77
174
  const tips = ['HEAD', ...(branch ? [branch] : []), ...(tracked ? [tracked] : [])];
78
- const dir = await mkdtemp(join(tmpdir(), 'shard-sync-git-'));
79
- const local = join(dir, 'history.bundle'), remote = `/tmp/shard-sync-${randomUUID()}.bundle`;
175
+ const remote = `/tmp/shard-sync-${randomUUID()}.bundle`;
80
176
  try {
81
- await projectGit(folder, ['bundle', 'create', local, ...tips], signal);
82
- let bundle = true;
83
- if ((await stat(local)).size > limit) {
84
- const name = branch ? branch.slice(11) : 'HEAD';
85
- if (!origin)
86
- throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and the project has no network origin the workspace can clone`, 'add an origin remote, then run shard again', name, origin);
177
+ let revs = tips, partial = null;
178
+ if (origin && await historyEstimate(folder, signal) > originFirst) {
87
179
  const ref = upstream.startsWith('refs/remotes/origin/') ? `refs/heads/${upstream.slice(20)}` : 'HEAD';
88
180
  const fetched = await cell.exec.run(['sh', '-c', FETCH_ORIGIN, 'shard-sync', root, origin, ref], { timeoutMs: 600_000, maxOutputBytes: 8192, env: { GIT_TERMINAL_PROMPT: '0' }, signal });
181
+ seedPhase('origin_fetch');
89
182
  const base = fetched.stdout.trim();
90
- if (fetched.exitCode !== 0 || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(base)) {
91
- throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and the workspace could not fetch ${origin}: ${tail(fetched.stderr)}`, 'give the machine access to the repository (GitHub: run gh auth login in shard machine), then run shard again', name, origin);
92
- }
93
- // Commits origin already has are not sent again; a base this computer does not know falls back to its upstream.
94
- const exclude = await optionalGit(folder, ['cat-file', '-t', base], signal) === 'commit' ? base : tracked;
95
- if (!exclude)
96
- throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and shares no known commit with ${origin}`, 'push the branch, then run shard again', name, origin);
97
- let onOrigin = true;
98
- try {
99
- await projectGit(folder, ['merge-base', '--is-ancestor', head, exclude], signal);
100
- }
101
- catch (error) {
102
- if (signal?.aborted || error.code !== 1)
103
- throw error;
104
- onOrigin = false;
105
- }
106
- bundle = !onOrigin;
107
- if (bundle) {
108
- await rm(local, { force: true });
109
- await projectGit(folder, ['bundle', 'create', local, ...tips.filter(tip => tip !== tracked), '--not', exclude], signal);
110
- if ((await stat(local)).size > limit)
111
- throw tooLarge(`Commits of ${name} that ${origin} does not have exceed the 256 MiB sync limit`, 'push them, then run shard again', name, origin);
183
+ if (fetched.exitCode === 0 && /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(base)) {
184
+ // Commits origin already has are not sent again; a base this computer does not know falls back to its upstream.
185
+ const exclude = await optionalGit(folder, ['cat-file', '-t', base], signal) === 'commit' ? base : tracked;
186
+ let onOrigin = false;
187
+ if (exclude) {
188
+ try {
189
+ await projectGit(folder, ['merge-base', '--is-ancestor', head, exclude], signal);
190
+ onOrigin = true;
191
+ }
192
+ catch (error) {
193
+ if (signal?.aborted || error.code !== 1)
194
+ throw error;
195
+ }
196
+ }
197
+ revs = onOrigin ? null : exclude ? [...tips.filter(tip => tip !== tracked), '--not', exclude] : tips;
198
+ partial = ref;
112
199
  }
200
+ else
201
+ report(`Sending the history from this computer: the workspace could not fetch ${origin} (${tail(fetched.stderr)}).`);
202
+ }
203
+ if (revs) {
204
+ await streamBundle(cell, folder, remote, revs, signal);
205
+ seedPhase('bundle');
113
206
  }
114
- if (bundle)
115
- await writeSyncBlob(cell, remote, await readFile(local));
116
- const result = await cell.exec.run(['sh', '-c', APPLY_HISTORY, 'shard-sync', root, bundle ? remote : '', head, branch], { timeoutMs: 60_000, maxOutputBytes: 4096, signal });
207
+ const result = await cell.exec.run(['sh', '-c', APPLY_HISTORY, 'shard-sync', root, revs ? remote : '', head, branch], { timeoutMs: 600_000, maxOutputBytes: 4096, signal });
117
208
  if (result.exitCode !== 0)
118
209
  throw new Error(`initial Git bundle failed: ${result.stderr.trim()}`);
210
+ seedPhase('apply_history');
211
+ return { partial };
119
212
  }
120
213
  finally {
121
- await rm(dir, { recursive: true, force: true });
122
214
  await cell.files.remove(remote).catch(() => undefined);
123
215
  }
124
216
  }
@@ -131,7 +223,7 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
131
223
  if (signal?.aborted || error.code !== 128)
132
224
  throw error;
133
225
  if (populate)
134
- await seedProjectFiles(cell, folder, root, '', new Set(), true, signal, report, initial);
226
+ await copyProjectFiles(cell, folder, root, await missingInWorkspace(cell, root, await selectedProjectPaths(folder, signal), signal), [], signal, report, initial, false);
135
227
  return;
136
228
  }
137
229
  if (top !== folder)
@@ -156,9 +248,10 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
156
248
  if (signal?.aborted)
157
249
  throw error;
158
250
  }
251
+ let partial = null;
159
252
  if (head && importHistory) {
160
253
  const remote = projectIdentity(folder).remote;
161
- await importProjectHistory(cell, folder, root, head, branch, remote && !remote.startsWith('file:') ? remote : null, signal);
254
+ ({ partial } = await importProjectHistory(cell, folder, root, head, branch, remote && !remote.startsWith('file:') ? remote : null, signal, ORIGIN_FIRST_BYTES, report));
162
255
  }
163
256
  else if (importHistory) {
164
257
  const result = await cell.exec.run(['git', 'init', '-q', ...(branch.startsWith('refs/heads/') ? ['--initial-branch', branch.slice(11)] : []), root], { timeoutMs: 5000, signal });
@@ -194,11 +287,13 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
194
287
  throw new Error('could not copy Git upstream');
195
288
  }
196
289
  }
197
- // Ignore files must exist before the first remote scan; otherwise a
198
- // template's dependency outputs would look like agent-created source.
199
- // For unborn repos, intent-to-add preserves tracked ignored paths too.
200
- const tracked = new Set((await projectGit(folder, ['ls-files', '-z', '--cached'], signal)).split('\0'));
201
- await seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report, initial);
290
+ if (head)
291
+ await seedFromHead(cell, folder, root, populate, signal, report, initial);
292
+ else
293
+ await seedUnborn(cell, folder, root, populate, signal, report, initial);
294
+ // Optional work, after the start's own: the lowest priority, and a failure only leaves the clone partial.
295
+ if (partial)
296
+ await cell.exec.start({ argv: ['nice', '-n', '19', 'sh', '-c', BACKFILL, 'shard-backfill', root, partial], env: { GIT_TERMINAL_PROMPT: '0' }, timeout_ms: 3_600_000 }, signal).catch(() => undefined);
202
297
  }
203
298
  // Git's info/exclude and laptop-wide excludes are selection metadata, copied
204
299
  // into a dedicated Git exclude file without syncing credentials or .git data.
@@ -226,44 +321,166 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
226
321
  const result = await cell.exec.run(['git', '-C', root, 'config', 'core.excludesFile', `${root}/.git/shard-sync-excludes`], { timeoutMs: 5000, signal });
227
322
  if (result.exitCode !== 0)
228
323
  throw new Error('could not apply project Git exclude rules');
324
+ seedPhase('excludes');
229
325
  }
230
- async function seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report, initial) {
231
- const raw = await localManifest(folder, signal);
232
- for (const skip of raw.skipped)
233
- report(`Project seed skipped ${skip.path}: ${skip.reason}`);
234
- const seed = raw.entries.filter((entry) => populate || entry.path.split('/').at(-1) === '.gitignore' || (!head && tracked.has(entry.path)));
235
- const groups = initial ? [seed.filter(e => initial.paths.has(e.path)), seed.filter(e => !initial.paths.has(e.path))] : [seed];
326
+ const nul = (text) => text.split('\0').filter(Boolean);
327
+ /** Git's NUL-separated output of any size, streamed (no output buffer or time bound). */
328
+ async function gitList(folder, args, signal) {
329
+ const out = [];
330
+ await projectGitPaths(folder, args, path => { out.push(path); }, { signal });
331
+ return out;
332
+ }
333
+ /**
334
+ * What differs between HEAD and this computer's working tree, from Git's own status (0.19.0+): changed, added and
335
+ * untracked files Git does not ignore, the .env files we always carry, Git LFS files (their content lives outside Git),
336
+ * and deletions. Ignored files, nested repositories and submodule contents stay out.
337
+ */
338
+ export async function localChanges(folder, signal) {
339
+ const [fields, untracked, env, lfs] = await Promise.all([
340
+ gitList(folder, ['diff', '--name-status', '-z', '--no-renames', '--ignore-submodules=all', 'HEAD', '--'], signal),
341
+ gitList(folder, ['ls-files', '-z', '--others', '--exclude-standard', '--', '.'], signal),
342
+ gitList(folder, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', '.env*', '**/.env*'], signal),
343
+ gitList(folder, ['ls-files', '-z', '--cached', '--', ':(attr:filter=lfs)'], signal).catch((error) => { if (signal?.aborted)
344
+ throw error; return []; }),
345
+ ]);
346
+ const selected = new Set([...untracked, ...env.filter(path => basename(path).startsWith('.env')), ...lfs]);
347
+ const removed = new Set();
348
+ for (let at = 0; at + 1 < fields.length; at += 2) {
349
+ const status = fields[at], path = fields[at + 1];
350
+ if (!status || !path)
351
+ continue;
352
+ if (status === 'D' && !selected.has(path))
353
+ removed.add(path);
354
+ else
355
+ selected.add(path);
356
+ }
357
+ const copy = [], remove = [], skipped = [];
358
+ for (const path of [...selected].sort()) {
359
+ if (path.endsWith('/')) {
360
+ skipped.push({ path, reason: 'nested_repository' });
361
+ continue;
362
+ }
363
+ if (excludedSyncPath(path))
364
+ continue;
365
+ if (!validSyncPath(path)) {
366
+ skipped.push({ path, reason: 'unsupported_path' });
367
+ continue;
368
+ }
369
+ copy.push(path);
370
+ }
371
+ for (const path of [...removed].sort())
372
+ if (validSyncPath(path) && !excludedSyncPath(path))
373
+ remove.push(path);
374
+ return { copy, remove, skipped };
375
+ }
376
+ /** HEAD's files come from the workspace's own Git; then only this computer's changes are copied. */
377
+ async function seedFromHead(cell, folder, root, populate, signal, report, initial) {
378
+ if (!populate) {
379
+ // Ignore files must exist before the first remote scan; otherwise a template's dependency outputs would look like
380
+ // agent-created source.
381
+ const ignores = await gitList(folder, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.gitignore', '**/.gitignore'], signal);
382
+ await copyProjectFiles(cell, folder, root, await missingInWorkspace(cell, root, ignores, signal), [], signal, report, undefined, false);
383
+ return;
384
+ }
385
+ const changes = await localChanges(folder, signal);
386
+ seedPhase('local_changes');
387
+ const skip = changes.remove.length ? `/tmp/shard-sync-${randomUUID()}.skip` : '';
388
+ try {
389
+ if (skip)
390
+ await writeSyncBlob(cell, skip, Buffer.from(changes.remove.join('\0')));
391
+ const checkout = await cell.exec.run(['python3', '-c', CHECKOUT_HEAD, root, skip], { timeoutMs: 600_000, maxOutputBytes: 8192, env: { GIT_TERMINAL_PROMPT: '0' }, signal });
392
+ if (checkout.exitCode !== 0)
393
+ throw new Error(`the workspace could not check out HEAD: ${tail(checkout.stderr)}`);
394
+ }
395
+ finally {
396
+ if (skip)
397
+ await cell.files.remove(skip).catch(() => undefined);
398
+ }
399
+ seedPhase('checkout_head');
400
+ for (const skipped of changes.skipped)
401
+ report(`Project seed skipped ${skipped.path}: ${skipped.reason}`);
402
+ await copyProjectFiles(cell, folder, root, changes.copy, changes.remove, signal, report, initial);
403
+ }
404
+ /** An unborn repository has no history to check out: its files are copied, and tracked ones are marked intent-to-add. */
405
+ async function seedUnborn(cell, folder, root, populate, signal, report, initial) {
406
+ const tracked = (await gitList(folder, ['ls-files', '-z', '--cached'], signal)).filter(path => validSyncPath(path) && !excludedSyncPath(path));
407
+ const paths = populate ? await selectedProjectPaths(folder, signal) : [...new Set([...tracked, ...await gitList(folder, ['ls-files', '-z', '--others', '--exclude-standard', '--', '.gitignore', '**/.gitignore'], signal)])];
408
+ await copyProjectFiles(cell, folder, root, await missingInWorkspace(cell, root, paths, signal), [], signal, report, initial, false);
409
+ const present = new Set(paths);
410
+ const add = tracked.filter(path => present.has(path));
411
+ if (!add.length)
412
+ return;
413
+ const list = `/tmp/shard-sync-${randomUUID()}.add`;
414
+ try {
415
+ await writeSyncBlob(cell, list, Buffer.from(add.join('\0')));
416
+ const result = await cell.exec.run(['git', '-C', root, '-c', 'core.hooksPath=/dev/null', 'add', '-N', '-f', `--pathspec-from-file=${list}`, '--pathspec-file-nul'], { timeoutMs: 600_000, signal });
417
+ if (result.exitCode !== 0)
418
+ throw new Error(`initial tracked project files failed: ${result.stderr.trim()}`);
419
+ }
420
+ finally {
421
+ await cell.files.remove(list).catch(() => undefined);
422
+ }
423
+ }
424
+ // Rewrites the list file with only the paths the workspace tree lacks and prints its size: the answer is read back in
425
+ // chunks, so any number of paths fits.
426
+ const MISSING = 'import os,sys;root=sys.argv[1].encode();f=sys.argv[2];paths=[p for p in open(f,"rb").read().split(b"\\0") if p];' +
427
+ 'out=b"\\0".join(p for p in paths if not os.path.lexists(os.path.join(root,p)));open(f,"wb").write(out);print(len(out))';
428
+ /** The paths not yet in the workspace tree: a retried or partial seed never replaces what is there. */
429
+ async function missingInWorkspace(cell, root, paths, signal) {
430
+ if (!paths.length)
431
+ return [];
432
+ const list = `/tmp/shard-sync-${randomUUID()}.paths`;
433
+ try {
434
+ await writeSyncBlob(cell, list, Buffer.from(paths.join('\0')));
435
+ const probe = await cell.exec.run(['python3', '-c', MISSING, root, list], { timeoutMs: 600_000, maxOutputBytes: 4096, signal });
436
+ const size = Number(probe.stdout.trim());
437
+ if (probe.exitCode !== 0 || !Number.isSafeInteger(size))
438
+ throw new Error(`could not inspect the workspace project files: ${tail(probe.stderr)}`);
439
+ const parts = [];
440
+ for (let offset = 0; offset < size;) {
441
+ const chunk = await cell.files.read(list, { offset, length: Math.min(SYNC_CHUNK_BYTES, size - offset) });
442
+ if (!chunk.length)
443
+ throw new Error('the workspace file list changed while it was read');
444
+ parts.push(chunk);
445
+ offset += chunk.length;
446
+ }
447
+ return nul(Buffer.concat(parts).toString('utf8'));
448
+ }
449
+ finally {
450
+ await cell.files.remove(list).catch(() => undefined);
451
+ }
452
+ }
453
+ /**
454
+ * Copies these files from this computer (any number, any size: small ones batched, big ones streamed) and applies the
455
+ * deletions. `replace` (a seed over a fresh checkout) overwrites what the checkout wrote; otherwise only missing paths
456
+ * are passed in. Files a harness reads first (initial) go before the rest.
457
+ */
458
+ async function copyProjectFiles(cell, folder, root, paths, deletions, signal, report, initial, replace = true) {
459
+ const groups = initial ? [paths.filter(path => initial.paths.has(path)), paths.filter(path => !initial.paths.has(path))] : [paths];
236
460
  for (let group = 0; group < groups.length; group++) {
237
- for (let offset = 0; offset < groups[group].length; offset += 32) {
238
- const entries = groups[group].slice(offset, offset + 32);
239
- const probe = await cell.exec.run(['python3', '-c', 'import os,sys,json;root=sys.argv[1];print(json.dumps([p for p in json.loads(sys.argv[2]) if not os.path.lexists(root+"/"+p)]))', root, JSON.stringify(entries.map((entry) => entry.path))], { timeoutMs: 5000, signal });
240
- if (probe.exitCode !== 0)
241
- throw new Error('could not inspect initial project ignore files');
242
- const missing = new Set(JSON.parse(probe.stdout));
243
- let seeds = new Map(), bytes = 0;
244
- for (const entry of entries)
245
- if (missing.has(entry.path)) {
246
- if (bytes + entry.size > 8 << 20 && seeds.size) {
247
- await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
248
- seeds = new Map();
249
- bytes = 0;
250
- }
251
- const file = await readLocalSyncFile(folder, entry);
252
- seeds.set(entry.path, file);
253
- bytes += file.data.length;
254
- }
255
- if (seeds.size)
256
- await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
257
- if (!head) {
258
- const add = entries.filter((entry) => tracked.has(entry.path)).map((entry) => entry.path);
259
- if (add.length) {
260
- const result = await cell.exec.run(['git', '-C', root, '-c', 'core.hooksPath=/dev/null', 'add', '-N', '-f', '--', ...add], { timeoutMs: 5000, signal });
261
- if (result.exitCode !== 0)
262
- throw new Error(`initial tracked project files failed: ${result.stderr.trim()}`);
461
+ const entries = [];
462
+ let next = 0;
463
+ const list = groups[group];
464
+ await Promise.all(Array.from({ length: Math.min(16, list.length) }, async () => {
465
+ for (;;) {
466
+ const path = list[next++];
467
+ if (path === undefined)
468
+ break;
469
+ const entry = await localEntry(folder, path, signal);
470
+ if (entry === undefined)
471
+ continue;
472
+ if (!('sha256' in entry)) {
473
+ report(`Project seed skipped ${entry.path}: ${entry.reason}`);
474
+ continue;
263
475
  }
476
+ entries.push(entry);
264
477
  }
265
- }
478
+ }));
479
+ entries.sort((a, b) => a.path.localeCompare(b.path));
480
+ // Deletions go first: a directory this computer replaced with a file must be gone before the file arrives.
481
+ await pushLocalFiles(cell, root, folder, entries, group === 0 ? deletions : [], { signal, overwrite: replace });
266
482
  if (group === 0)
267
483
  initial?.ready();
268
484
  }
485
+ seedPhase('copy_changes');
269
486
  }
@@ -22,6 +22,9 @@ export declare class AgentStateStore {
22
22
  host: string;
23
23
  api_url: string;
24
24
  }): void;
25
+ /** The APIs whose account has its T3 Connect sign-in, as the last live check saw it: every project can start in T3. */
26
+ t3Connected(apiUrl: string): boolean;
27
+ saveT3Connected(apiUrl: string, connected: boolean): void;
25
28
  saveTarget(project: string, target: string): void;
26
29
  update(change: (state: State) => void): void;
27
30
  save(project: string, app: 'claude' | 'codex'): void;
@@ -41,6 +41,19 @@ export class AgentStateStore {
41
41
  doc.t3 = [...list.filter(e => !(e.host === entry.host && e.api_url === entry.api_url)), entry];
42
42
  });
43
43
  }
44
+ /** The APIs whose account has its T3 Connect sign-in, as the last live check saw it: every project can start in T3. */
45
+ t3Connected(apiUrl) {
46
+ const list = this.read().t3_connect;
47
+ return Array.isArray(list) && list.includes(apiUrl);
48
+ }
49
+ saveT3Connected(apiUrl, connected) {
50
+ if (this.t3Connected(apiUrl) === connected)
51
+ return;
52
+ this.update(doc => {
53
+ const list = (Array.isArray(doc.t3_connect) ? doc.t3_connect : []).filter((url) => typeof url === 'string' && url !== apiUrl);
54
+ doc.t3_connect = connected ? [...list, apiUrl] : list;
55
+ });
56
+ }
44
57
  saveTarget(project, target) {
45
58
  this.update(doc => {
46
59
  doc.projects ??= {};
@@ -33,5 +33,7 @@ export declare function openClaudeDesktopSession(ctx: Ctx, ws: Workspace, conver
33
33
  * server in the VM (SSH mode), and T3 is brought to the front with --json too (`opened: true`).
34
34
  */
35
35
  export declare function openDesktopWorkspace(ctx: Ctx, ws: Workspace, target: DesktopTarget, t3Version?: string, prewarmed?: boolean, thread?: T3ThreadRequest, install?: T3Install, conversation?: string | null): Promise<number>;
36
+ /** T3 Code's Connections page (its t3code:// link), where a project linked with T3 Connect is listed. */
37
+ export declare const T3_CONNECTIONS_PAGE = "t3code://app/settings/connections";
36
38
  export declare function queueT3Prewarm(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
37
39
  export declare const TARGET_HANDLERS: Record<string, Handler>;