@shardflux/cli 0.18.1 → 0.19.1

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 (53) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +27 -0
  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 +45 -15
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +14 -2
  18. package/dist/conversation-router.js +7 -11
  19. package/dist/conversation-stash.js +64 -18
  20. package/dist/conversation-terminal.d.ts +1 -1
  21. package/dist/conversation-terminal.js +9 -10
  22. package/dist/conversations.d.ts +9 -2
  23. package/dist/conversations.js +23 -6
  24. package/dist/errors.d.ts +1 -1
  25. package/dist/errors.js +1 -1
  26. package/dist/files-mirror.d.ts +1 -9
  27. package/dist/files-mirror.js +19 -3
  28. package/dist/format.js +37 -2
  29. package/dist/handlers.js +3 -1
  30. package/dist/initial-prompt.d.ts +15 -2
  31. package/dist/initial-prompt.js +29 -4
  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-mode.d.ts +7 -2
  49. package/dist/t3-mode.js +39 -5
  50. package/dist/thread-on-computer.js +4 -0
  51. package/dist/worktree-twin.d.ts +2 -6
  52. package/dist/worktree-twin.js +16 -51
  53. package/package.json +5 -5
@@ -1,13 +1,17 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { closeSync, mkdirSync, openSync } from 'node:fs';
1
4
  import { homedir } from 'node:os';
2
5
  import { join } from 'node:path';
3
6
  import { parse, stringify } from 'smol-toml';
4
- import { ShardfluxApiError } from '@shardflux/sdk';
7
+ import { ShardfluxApiError, tarEnd, tarHeader, tarPadding } from '@shardflux/sdk';
5
8
  import { AgentStateStore } from "./agent-state.js";
6
9
  import { UsageError } from "./errors.js";
7
10
  import { collectPlugins, collectUserFiles, object, readObject, readOptional, rewritePaths } from "./agent-config-files.js";
8
11
  import { digest, prepareMcp } from "./agent-config-mcp.js";
9
12
  import { configIssueLines } from "./agent-config-report.js";
10
13
  const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
14
+ const parseFailure = (e) => e instanceof SyntaxError || (e instanceof Error && /^expected an object in /.test(e.message));
11
15
  const markerPath = '.config/shardflux/agent-config.json';
12
16
  const safePath = (path) => !path.split('/').some(p => !p || p === '.' || p === '..') &&
13
17
  (/^(?:\.claude\/(?:settings\.json|CLAUDE\.md)|\.codex\/(?:config\.toml|AGENTS\.md))$/.test(path) ||
@@ -96,19 +100,129 @@ async function bindMcpSecret(ctx, ws, secrets, spec) {
96
100
  ctx.io.stderr.write(`MCP secret bound to ${ws.key}; an already running agent picks it up when restarted.\n`);
97
101
  return true;
98
102
  }
99
- export async function applyConfigFiles(cell, home, files, previous, trustManifest, issues, signal, deleteMissing = true, progress = () => { }) {
103
+ /** At least this many files to check go into the VM as one archive and one command instead of a stat and a write each. */
104
+ export const BULK_MIN_FILES = 8;
105
+ /**
106
+ * Applies a whole pass in two requests (0.19.0+): one tar archive written into the VM, and one command that compares
107
+ * each member with what is there (a regular file with the same SHA-256 and mode is left alone; anything else that is
108
+ * not a regular file is reported and kept), replaces the rest atomically, and removes the owned files that are gone
109
+ * locally while they still hold what this sync wrote. A 3,000-file config took ~6,000 sequential requests (5 minutes)
110
+ * file by file. Undefined when the VM cannot run it (no python3): the caller applies the pass file by file.
111
+ */
112
+ const BULK_APPLY = String.raw `
113
+ import hashlib, json, os, sys, tarfile
114
+ archive, home = sys.argv[1], sys.argv[2]
115
+ remove = json.loads(sys.stdin.read() or '[]')
116
+ out = {'existed': [], 'written': [], 'not_file': [], 'removed': [], 'changed': []}
117
+ def digest(path):
118
+ h = hashlib.sha256()
119
+ with open(path, 'rb') as f:
120
+ for chunk in iter(lambda: f.read(1 << 20), b''): h.update(chunk)
121
+ return h.hexdigest()
122
+ try:
123
+ with tarfile.open(archive) as tar:
124
+ for m in tar:
125
+ if not m.isfile(): continue
126
+ dest = os.path.join(home, m.name)
127
+ data = tar.extractfile(m).read()
128
+ mode = m.mode & 0o7777
129
+ if os.path.lexists(dest):
130
+ out['existed'].append(m.name)
131
+ if os.path.islink(dest) or not os.path.isfile(dest):
132
+ out['not_file'].append(m.name); continue
133
+ if (os.stat(dest).st_mode & 0o7777) == mode and digest(dest) == hashlib.sha256(data).hexdigest(): continue
134
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
135
+ tmp = dest + '.sf-sync'
136
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
137
+ with os.fdopen(fd, 'wb') as f: f.write(data)
138
+ os.chmod(tmp, mode)
139
+ os.replace(tmp, dest)
140
+ out['written'].append(m.name)
141
+ finally:
142
+ os.unlink(archive)
143
+ for name, expected in remove:
144
+ dest = os.path.join(home, name)
145
+ if not os.path.lexists(dest): continue
146
+ if os.path.islink(dest) or not os.path.isfile(dest) or digest(dest) != expected: out['changed'].append(name); continue
147
+ os.unlink(dest); out['removed'].append(name)
148
+ print(json.dumps(out))
149
+ `;
150
+ async function bulkApply(cell, home, files, remove, signal) {
151
+ const parts = [];
152
+ let size = 0;
153
+ const push = (bytes) => { parts.push(bytes); size += bytes.length; };
154
+ for (const [path, file] of files) {
155
+ push(tarHeader({ path, type: 'file', mode: Number.parseInt(file.mode, 8), size: file.content.length }));
156
+ push(file.content);
157
+ push(tarPadding(file.content.length));
158
+ }
159
+ push(tarEnd(size));
160
+ const archive = Buffer.concat(parts);
161
+ const path = `${home}/.config/shardflux/.config-sync-${randomUUID()}.tar`;
162
+ await cell.files.write(path, archive, { mode: '0600', createParents: true });
163
+ const run = await cell.exec.run(['python3', '-c', BULK_APPLY, path, home], { stdin: JSON.stringify(remove), signal, timeoutMs: 120_000, maxOutputBytes: 16 << 20 });
164
+ if (run.exitCode !== 0) {
165
+ await cell.files.remove(path).catch(() => undefined);
166
+ return undefined;
167
+ }
168
+ try {
169
+ const result = JSON.parse(run.stdout);
170
+ return Array.isArray(result.written) && Array.isArray(result.existed) ? result : undefined;
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ }
176
+ export async function applyConfigFiles(cell, home, files, previous, trustManifest, issues, signal, deleteMissing = true, progress = () => { }, trusted = new Set()) {
100
177
  const next = {};
101
178
  const copied = [], removed = [];
179
+ const pending = [];
102
180
  for (const [path, file] of files) {
103
- signal.throwIfAborted();
104
181
  if (!safePath(path))
105
182
  throw new Error('agent config attempted to write outside its allowlist');
106
- const hash = digest(file.content);
107
183
  const old = previous[path];
108
- if (trustManifest && old?.hash === hash && old.mode === file.mode) {
184
+ if ((trustManifest || trusted.has(path)) && old?.hash === digest(file.content) && old.mode === file.mode)
109
185
  next[path] = old;
186
+ else
187
+ pending.push([path, file]);
188
+ }
189
+ const gone = [];
190
+ for (const [path, old] of Object.entries(deleteMissing ? previous : {})) {
191
+ if (files.has(path))
110
192
  continue;
193
+ if (!safePath(path))
194
+ throw new Error('agent config manifest contains a path outside its allowlist');
195
+ if (old.created)
196
+ gone.push([path, old]);
197
+ }
198
+ if (pending.length + gone.length >= BULK_MIN_FILES) {
199
+ signal.throwIfAborted();
200
+ const bulk = await bulkApply(cell, home, pending, gone.map(([path, old]) => [path, old.hash]), signal);
201
+ if (bulk) {
202
+ const existed = new Set(bulk.existed), notFile = new Set(bulk.not_file), written = new Set(bulk.written);
203
+ for (const [path, file] of pending) {
204
+ if (notFile.has(path)) {
205
+ issues.push({ item: path, reason: 'remote path is not a regular file' });
206
+ continue;
207
+ }
208
+ if (written.has(path))
209
+ copied.push(path);
210
+ next[path] = { hash: digest(file.content), mode: file.mode, created: previous[path]?.created ?? !existed.has(path) };
211
+ progress(path, next[path]);
212
+ }
213
+ for (const path of bulk.changed)
214
+ issues.push({ item: path, reason: 'remote file was changed; deletion preserved it' });
215
+ for (const path of bulk.removed) {
216
+ removed.push(path);
217
+ progress(path, undefined);
218
+ }
219
+ return { files: inOrder(files, next), copied, removed };
111
220
  }
221
+ }
222
+ for (const [path, file] of pending) {
223
+ signal.throwIfAborted();
224
+ const hash = digest(file.content);
225
+ const old = previous[path];
112
226
  let exists = false, same = false;
113
227
  try {
114
228
  const info = await cell.files.stat(`${home}/${path}`, { revision: true });
@@ -130,14 +244,8 @@ export async function applyConfigFiles(cell, home, files, previous, trustManifes
130
244
  next[path] = { hash, mode: file.mode, created: old?.created ?? !exists };
131
245
  progress(path, next[path]);
132
246
  }
133
- for (const [path, old] of Object.entries(deleteMissing ? previous : {})) {
247
+ for (const [path, old] of gone) {
134
248
  signal.throwIfAborted();
135
- if (files.has(path))
136
- continue;
137
- if (!safePath(path))
138
- throw new Error('agent config manifest contains a path outside its allowlist');
139
- if (!old.created)
140
- continue;
141
249
  try {
142
250
  const info = await cell.files.stat(`${home}/${path}`, { revision: true });
143
251
  if (info.type !== 'file' || info.revision !== old.hash) {
@@ -153,7 +261,29 @@ export async function applyConfigFiles(cell, home, files, previous, trustManifes
153
261
  throw error;
154
262
  }
155
263
  }
156
- return { files: next, copied, removed };
264
+ return { files: inOrder(files, next), copied, removed };
265
+ }
266
+ /** The manifest lists files in the local order, so an unchanged config hashes the same whichever path applied it. */
267
+ function inOrder(files, next) {
268
+ const ordered = {};
269
+ for (const path of files.keys())
270
+ if (next[path])
271
+ ordered[path] = next[path];
272
+ return ordered;
273
+ }
274
+ /** Plugins named in the plan are installed by the VM's own Claude Code, from their marketplaces (one command). */
275
+ async function installPlugins(cell, plan, issues, signal) {
276
+ const script = 'command -v claude >/dev/null 2>&1 || { echo "missing"; exit 0; }\n' +
277
+ 'n=$1; shift; while [ "$n" -gt 0 ]; do claude plugin marketplace add "$1" >/dev/null 2>&1 || true; shift; n=$((n-1)); done\n' +
278
+ 'for id in "$@"; do claude plugin install "$id" --scope user >/dev/null 2>&1 || echo "failed $id"; done\n';
279
+ const run = await cell.exec.run(['sh', '-c', script, 'shard-plugins', String(plan.marketplaces.length), ...plan.marketplaces.map(m => m.source), ...plan.plugins], { signal, timeoutMs: 300_000, maxOutputBytes: 65536 });
280
+ if (run.stdout.trim() === 'missing') {
281
+ issues.push({ item: 'plugins', reason: 'Claude Code is not installed in this VM' });
282
+ return;
283
+ }
284
+ for (const line of run.stdout.split('\n'))
285
+ if (line.startsWith('failed '))
286
+ issues.push({ item: `plugin:${line.slice(7)}`, reason: 'could not be installed in the VM' });
157
287
  }
158
288
  export async function syncAgentConfig(ctx, ws) {
159
289
  const home = ctx.io.env.HOME || homedir();
@@ -162,8 +292,18 @@ export async function syncAgentConfig(ctx, ws) {
162
292
  const issues = [];
163
293
  const files = await collectUserFiles(home, issues);
164
294
  const codexBytes = await readOptional(join(home, '.codex/config.toml'));
165
- const claude = await readObject(join(home, '.claude.json'));
166
- const hasPlugins = await readOptional(join(home, '.claude/plugins/installed_plugins.json')) || await readOptional(join(home, '.claude/plugins/known_marketplaces.json'));
295
+ // One file that does not parse is skipped with a reason; it never stops the rest of the sync.
296
+ let claude;
297
+ try {
298
+ claude = await readObject(join(home, '.claude.json'));
299
+ }
300
+ catch (error) {
301
+ if (!parseFailure(error))
302
+ throw error;
303
+ issues.push({ item: '.claude.json', reason: 'not valid JSON; its MCP servers were not copied' });
304
+ }
305
+ const plugins = await collectPlugins(home, issues);
306
+ const hasPlugins = plugins.marketplaces.length > 0 || plugins.plugins.length > 0;
167
307
  const empty = { workspace_id: ws.id, workspace_key: ws.key, copied: [], removed: [], skipped: issues, mcp: [], synced_at: null };
168
308
  if (!files.size && !codexBytes && !hasPlugins && !Object.keys(object(claude?.mcpServers)).length && !previous)
169
309
  return empty;
@@ -172,17 +312,6 @@ export async function syncAgentConfig(ctx, ws) {
172
312
  const remoteHome = run.stdout.trim();
173
313
  if (run.exitCode !== 0 || !/^\/(?:[a-zA-Z0-9._-]+\/)*[a-zA-Z0-9._-]+$/.test(remoteHome) || remoteHome.split('/').some(p => p === '..' || p === '.'))
174
314
  throw new Error('agent config could not resolve the VM user home');
175
- const skippedPlugins = await collectPlugins(home, remoteHome, files, issues);
176
- if (skippedPlugins.size && files.has('.claude/settings.json')) {
177
- const file = files.get('.claude/settings.json');
178
- const settings = object(JSON.parse(file.content.toString('utf8')));
179
- const enabled = object(settings.enabledPlugins);
180
- for (const id of skippedPlugins)
181
- if (id in enabled)
182
- enabled[id] = false;
183
- settings.enabledPlugins = enabled;
184
- file.content = Buffer.from(JSON.stringify(settings) + '\n');
185
- }
186
315
  // Claude settings stay the user's. sf-agent hooks are in /etc/claude-code/managed-settings.json.
187
316
  const secrets = { ...previous?.secrets, ...object(object(store.read().agent_config_secrets)[key(ctx, ws.id)]) };
188
317
  const commandCache = new Map();
@@ -200,45 +329,44 @@ export async function syncAgentConfig(ctx, ws) {
200
329
  const partial = { v: 1, workspace_id: ws.id, workspace_key: ws.key, api_url: ctx.apiUrl, home: remoteHome,
201
330
  sync_state: 'applying', remote_hash: previous?.remote_hash, synced_at: previous?.synced_at ?? null, files: { ...previous?.files },
202
331
  claude_mcp: previous?.claude_mcp ?? [], claude_mcp_owned: previous?.claude_mcp_owned, secrets, skipped: issues, mcp: previous?.mcp ?? [], tunnels: previous?.tunnels ?? [] };
332
+ // Progress is recorded at most every 250 ms and at the end of each pass, never once per file of thousands.
333
+ let savedAt = 0;
334
+ const flushPartial = () => {
335
+ savedAt = Date.now();
336
+ store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = partial; doc.agent_config = all; });
337
+ };
203
338
  const savePartial = (path, file) => {
204
339
  if (file)
205
340
  partial.files[path] = file;
206
341
  else
207
342
  delete partial.files[path];
208
- store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = partial; doc.agent_config = all; });
343
+ if (Date.now() - savedAt >= 250)
344
+ flushPartial();
209
345
  };
210
346
  const marker = await optionalRemote(cell, `${remoteHome}/${markerPath}`);
211
347
  const trust = marker?.workspace_id === ws.id && typeof previous?.remote_hash === 'string' && marker.manifest_hash === previous.remote_hash;
212
348
  // Stage executable payloads before checking stdio paths. Structured configuration is prepared before transfer.
213
349
  const payload = new Map([...files].filter(([path]) => !/\.(?:json|toml)$/.test(path) && path !== '.claude/settings.json'));
214
- const staged = await applyConfigFiles(cell, remoteHome, payload, previous?.files ?? {}, trust, issues, ctx.signal, false, savePartial);
350
+ const staged = await applyConfigFiles(cell, remoteHome, payload, previous?.files ?? {}, trust, issues, ctx.signal, false, savePartial).finally(flushPartial);
215
351
  const mcpCtx = { home, remoteHome, workspaceId: ws.id, files, issues, env: ctx.io.env, exists, bind: (secret) => bindMcpSecret(ctx, ws, secrets, secret) };
216
- // Installed plugins can declare MCP servers inline or in .mcp.json. Treat their env exactly like user MCP env.
217
- for (const [path, file] of files) {
218
- if (!path.startsWith('.claude/plugins/cache/') || !(path.endsWith('/.mcp.json') || path.endsWith('/.claude-plugin/plugin.json')))
219
- continue;
220
- const doc = object(JSON.parse(file.content.toString('utf8')));
221
- const isManifest = path.endsWith('/plugin.json');
222
- const inline = doc.mcpServers;
223
- if (isManifest && (typeof inline === 'string' || Array.isArray(inline)))
224
- continue; // Referenced .mcp.json files are handled in this same pass.
225
- const servers = isManifest || inline ? object(inline) : doc;
226
- if (!Object.keys(servers).length)
227
- continue;
228
- const root = `${remoteHome}/${path.slice(0, path.lastIndexOf(isManifest ? '/.claude-plugin/' : '/'))}`;
229
- const pluginCtx = { ...mcpCtx, namespace: path, exists: (command) => exists(command.replaceAll('${CLAUDE_PLUGIN_ROOT}', root)) };
230
- const portable = await prepareMcp('claude', servers, pluginCtx, mcp, tunnels);
231
- file.content = Buffer.from(JSON.stringify(isManifest || inline ? { ...doc, mcpServers: portable } : portable) + '\n');
232
- }
233
352
  const claudeMcp = await prepareMcp('claude', object(claude?.mcpServers), mcpCtx, mcp, tunnels);
234
353
  if (codexBytes) {
235
- const config = parse(codexBytes.toString('utf8'));
236
- const servers = await prepareMcp('codex', object(config.mcp_servers), mcpCtx, mcp, tunnels);
237
- delete config.mcp_servers;
238
- const portable = object(rewritePaths(config, home, remoteHome, files, issues, 'codex:config'));
239
- if (Object.keys(servers).length || 'mcp_servers' in parse(codexBytes.toString('utf8')))
240
- portable.mcp_servers = servers;
241
- files.set('.codex/config.toml', { content: Buffer.from(stringify(portable)), mode: '0600' });
354
+ let config;
355
+ try {
356
+ config = parse(codexBytes.toString('utf8'));
357
+ }
358
+ catch {
359
+ issues.push({ item: '.codex/config.toml', reason: 'not valid TOML; not copied' });
360
+ }
361
+ if (config) {
362
+ const declared = 'mcp_servers' in config;
363
+ const servers = await prepareMcp('codex', object(config.mcp_servers), mcpCtx, mcp, tunnels);
364
+ delete config.mcp_servers;
365
+ const portable = object(rewritePaths(config, home, remoteHome, files, issues, 'codex:config'));
366
+ if (Object.keys(servers).length || declared)
367
+ portable.mcp_servers = servers;
368
+ files.set('.codex/config.toml', { content: Buffer.from(stringify(portable)), mode: '0600' });
369
+ }
242
370
  }
243
371
  // Retain encrypted secret records for later re-use, but stop injecting credentials for removed/skipped servers.
244
372
  const ownedSecrets = Object.keys(secrets);
@@ -252,8 +380,13 @@ export async function syncAgentConfig(ctx, ws) {
252
380
  await ws.secrets.set(names);
253
381
  }
254
382
  }
255
- const applied = await applyConfigFiles(cell, remoteHome, files, partial.files, trust, issues, ctx.signal, true, savePartial);
383
+ // The payload staged above is in the VM already; the second pass checks only what changed since.
384
+ const applied = await applyConfigFiles(cell, remoteHome, files, partial.files, trust, issues, ctx.signal, true, savePartial, new Set(Object.keys(staged.files))).finally(flushPartial);
256
385
  applied.copied.unshift(...staged.copied);
386
+ // Plugins are installed by the VM's Claude Code from their marketplaces, once per changed plan.
387
+ const pluginsHash = hasPlugins ? digest(JSON.stringify(plugins)) : undefined;
388
+ if (pluginsHash && !(trust && pluginsHash === previous?.plugins_hash))
389
+ await installPlugins(cell, plugins, issues, ctx.signal);
257
390
  // L2 owns oauthAccount and credentials. Patch just mcpServers under a revision check; preserve every other field.
258
391
  if (Object.keys(claudeMcp).length || previous?.claude_mcp.length) {
259
392
  const path = `${remoteHome}/.claude.json`;
@@ -296,7 +429,8 @@ export async function syncAgentConfig(ctx, ws) {
296
429
  store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = partial; doc.agent_config = all; });
297
430
  }
298
431
  const manifest = { v: 1, workspace_id: ws.id, workspace_key: ws.key, api_url: ctx.apiUrl, home: remoteHome,
299
- sync_state: 'synced', synced_at, files: applied.files, claude_mcp: Object.keys(claudeMcp), claude_mcp_owned: partial.claude_mcp_owned, secrets, skipped: issues, mcp, tunnels };
432
+ sync_state: 'synced', synced_at, files: applied.files, claude_mcp: Object.keys(claudeMcp), claude_mcp_owned: partial.claude_mcp_owned, secrets, skipped: issues, mcp, tunnels,
433
+ ...(pluginsHash ? { plugins_hash: pluginsHash } : {}) };
300
434
  manifest.remote_hash = digest(JSON.stringify({ files: applied.files, claude_mcp: claudeMcp, mcp, tunnels }));
301
435
  if (marker?.workspace_id !== ws.id || marker.manifest_hash !== manifest.remote_hash)
302
436
  await cell.files.write(`${remoteHome}/${markerPath}`, JSON.stringify({ workspace_id: ws.id, manifest_hash: manifest.remote_hash }) + '\n', { createParents: true, mode: '0600' });
@@ -311,6 +445,36 @@ export async function syncAgentConfig(ctx, ws) {
311
445
  }
312
446
  return result;
313
447
  }
448
+ /**
449
+ * Copies the laptop's agent config into a new conversation's VM after its agent started (0.19.0+): the first prompt
450
+ * never waits for it. From an installed shard it runs as its own detached process (`shard agents config-sync`, its
451
+ * output in agent-config-sync.log of the config directory), so the command that started the conversation reports the
452
+ * start at once; without a runtime (tests, an embedding) it runs here and the returned promise settles when it ends.
453
+ */
454
+ export function syncAgentConfigAfterStart(ctx, ws) {
455
+ if (ctx.runtime?.script) {
456
+ try {
457
+ mkdirSync(ctx.store.dir, { recursive: true, mode: 0o700 });
458
+ const log = openSync(join(ctx.store.dir, 'agent-config-sync.log'), 'a', 0o600);
459
+ try {
460
+ const child = spawn(ctx.runtime.node, [ctx.runtime.script, 'agents', 'config-sync', ws.id], {
461
+ detached: true, stdio: ['ignore', log, log],
462
+ env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, SHARDFLUX_NO_UPDATE_CHECK: '1' }
463
+ });
464
+ child.on('error', () => undefined);
465
+ child.unref();
466
+ }
467
+ finally {
468
+ closeSync(log);
469
+ }
470
+ return undefined;
471
+ }
472
+ catch { /* runs in this process below */ }
473
+ }
474
+ return syncAgentConfig(ctx, ws).then(() => undefined, (error) => {
475
+ ctx.io.stderr.write(`Agent config not copied to ${ws.key}: ${error instanceof Error ? error.message : String(error)}\n`);
476
+ });
477
+ }
314
478
  /** Tunnels are owned by the attachment, and close even if the terminal fails or the user detaches. */
315
479
  export async function startAgentConfigTunnels(ctx, ws) {
316
480
  const owned = [];
@@ -336,6 +500,16 @@ export async function startAgentConfigTunnels(ctx, ws) {
336
500
  }
337
501
  }
338
502
  export const AGENT_CONFIG_HANDLERS = {
503
+ /** The detached copy syncAgentConfigAfterStart starts; one line per run in agent-config-sync.log. */
504
+ async 'agents config-sync'(ctx, _values, [id]) {
505
+ const ws = await ctx.cloud().workspaces.get(id);
506
+ const started = Date.now();
507
+ const result = await syncAgentConfig({ ...ctx, json: true }, ws);
508
+ ctx.io.stderr.write(`${new Date().toISOString()} ${ws.key}: ${result.copied.length} copied, ${result.removed.length} removed, ${result.skipped.length} skipped in ${Date.now() - started} ms\n`);
509
+ for (const line of configIssueLines(result.skipped))
510
+ ctx.io.stderr.write(` ${line}\n`);
511
+ return 0;
512
+ },
339
513
  'agents config status'(ctx, values) {
340
514
  const workspace = typeof values.workspace === 'string' ? values.workspace : undefined;
341
515
  const manifests = Object.values(configManifests(ctx)).filter(m => m.api_url === ctx.apiUrl && (!workspace || m.workspace_id === workspace || m.workspace_key === workspace));
@@ -1,6 +1,5 @@
1
1
  import type { Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
- import { CodedError } from './errors.js';
4
3
  import type { SyncCell } from './project-sync-transfer.js';
5
4
  export declare function projectWorkspacePath(project: {
6
5
  slug: string;
@@ -8,20 +7,51 @@ export declare function projectWorkspacePath(project: {
8
7
  /** New workspaces label this path explicitly; custom remote paths remain available to Git callers. */
9
8
  export declare function workspaceRepositoryPath(ws: Pick<Workspace, 'labels' | 'key'>): string;
10
9
  export declare function needsGitSeed(ctx: Ctx, ws: Workspace): boolean;
11
- /** One history seed for C5 and C11. Bundles use the bounded files path, without a Git helper or second connection. */
10
+ /**
11
+ * The checks a start can fail on, run on this computer before a VM is allocated (0.19.0+). A project's size, file
12
+ * count or history never refuses a start; only a folder inside another repository does.
13
+ */
14
+ export declare function preflightProjectSeed(folder: string, signal?: AbortSignal): Promise<void>;
15
+ /**
16
+ * 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
17
+ * what differs from HEAD on this computer (changes, untracked files, deletions, .env files) is copied (0.19.0+). Its
18
+ * size, file count and history length do not change what is sent.
19
+ */
12
20
  export declare function seedAgentRepository(ctx: Ctx, ws: Workspace, folder: string, options?: {
13
21
  root?: string;
14
22
  populate?: boolean;
15
23
  initialFiles?: readonly string[];
16
24
  onInitialFiles?: () => void;
17
25
  }): Promise<void>;
18
- /** History above this goes through the project's origin instead of the bounded files path. */
19
- export declare const HISTORY_BUNDLE_LIMIT: number;
20
- /** A history the first copy cannot carry (repository_too_large, 0.13.3+): the facts, and the next step as the hint. */
21
- export declare function tooLarge(message: string, hint: string, branch: string, origin: string | null): CodedError;
26
+ /**
27
+ * Above this (an estimate: the repository's packed and loose objects) the workspace fetches the history from origin as a
28
+ * blobless partial clone, and receives only the commits origin lacks (0.19.0+). Below it, one small bundle is faster.
29
+ */
30
+ export declare const ORIGIN_FIRST_BYTES: number;
31
+ /** The repository's object store size, an upper bound of any one branch's history bundle. Reads sizes only. */
32
+ export declare function historyEstimate(folder: string, signal?: AbortSignal): Promise<number>;
33
+ export declare const FETCH_ORIGIN: string;
34
+ export declare const BACKFILL: string;
35
+ export declare const CHECKOUT_HEAD: string;
22
36
  /**
23
37
  * Imports the checked-out branch's history into an empty workspace repository. Only HEAD, its branch and that branch's
24
- * upstream are bundled: other branches, worktrees and stashes stay on this computer. When that is still above `limit`,
25
- * the workspace fetches `origin` itself (with its own Git credentials) and receives only the commits origin lacks.
38
+ * upstream are sent: other branches, worktrees and stashes stay on this computer. A history above `originFirst` comes
39
+ * from origin as a blobless partial clone (the workspace's own Git credentials), plus a bundle of the commits origin
40
+ * lacks. Without a reachable origin the whole bundle is streamed in chunks: no history size refuses (0.19.0+).
41
+ */
42
+ export declare function importProjectHistory(cell: SyncCell, folder: string, root: string, head: string, branch: string, origin: string | null, signal?: AbortSignal, originFirst?: number, report?: (message: string) => void): Promise<{
43
+ partial: string | null;
44
+ }>;
45
+ /**
46
+ * What differs between HEAD and this computer's working tree, from Git's own status (0.19.0+): changed, added and
47
+ * untracked files Git does not ignore, the .env files we always carry, Git LFS files (their content lives outside Git),
48
+ * and deletions. Ignored files, nested repositories and submodule contents stay out.
26
49
  */
27
- export declare function importProjectHistory(cell: SyncCell, folder: string, root: string, head: string, branch: string, origin: string | null, signal?: AbortSignal, limit?: number): Promise<void>;
50
+ export declare function localChanges(folder: string, signal?: AbortSignal): Promise<{
51
+ copy: string[];
52
+ remove: string[];
53
+ skipped: {
54
+ path: string;
55
+ reason: string;
56
+ }[];
57
+ }>;