@shardflux/cli 0.19.1 → 0.20.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.
@@ -7,7 +7,8 @@ import { parse, stringify } from 'smol-toml';
7
7
  import { ShardfluxApiError, tarEnd, tarHeader, tarPadding } from '@shardflux/sdk';
8
8
  import { AgentStateStore } from "./agent-state.js";
9
9
  import { UsageError } from "./errors.js";
10
- import { collectPlugins, collectUserFiles, object, readObject, readOptional, rewritePaths } from "./agent-config-files.js";
10
+ import { collectMemory, collectPlugins, collectUserFiles, object, readObject, readOptional, rewritePaths, vmMemoryPath } from "./agent-config-files.js";
11
+ import { localConversations } from "./conversations.js";
11
12
  import { digest, prepareMcp } from "./agent-config-mcp.js";
12
13
  import { configIssueLines } from "./agent-config-report.js";
13
14
  const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
@@ -15,7 +16,8 @@ const parseFailure = (e) => e instanceof SyntaxError || (e instanceof Error && /
15
16
  const markerPath = '.config/shardflux/agent-config.json';
16
17
  const safePath = (path) => !path.split('/').some(p => !p || p === '.' || p === '..') &&
17
18
  (/^(?:\.claude\/(?:settings\.json|CLAUDE\.md)|\.codex\/(?:config\.toml|AGENTS\.md))$/.test(path) ||
18
- /^\.claude\/(?:skills|commands|agents|output-styles|plugins)\/.+/.test(path) || /^\.codex\/(?:prompts|skills)\/.+/.test(path));
19
+ /^\.claude\/(?:skills|commands|agents|output-styles|plugins)\/.+/.test(path) || /^\.codex\/(?:prompts|skills)\/.+/.test(path) ||
20
+ /^\.claude\/projects\/[^/]+\/memory\/.+/.test(path));
19
21
  const key = (ctx, id) => `${ctx.apiUrl}:${id}`;
20
22
  export function configManifests(ctx) {
21
23
  const value = object(new AgentStateStore(ctx.store.dir).read().agent_config);
@@ -285,33 +287,51 @@ async function installPlugins(cell, plan, issues, signal) {
285
287
  if (line.startsWith('failed '))
286
288
  issues.push({ item: `plugin:${line.slice(7)}`, reason: 'could not be installed in the VM' });
287
289
  }
288
- export async function syncAgentConfig(ctx, ws) {
290
+ /**
291
+ * `memoryOnly` (0.20.0+): just the project memory, for an account whose saved machine carries the rest. A conversation
292
+ * VM gets this computer's memory for its project, under the VM project folder's name (Claude keys memory by path).
293
+ */
294
+ export async function syncAgentConfig(ctx, ws, opts = {}) {
289
295
  const home = ctx.io.env.HOME || homedir();
290
296
  const store = new AgentStateStore(ctx.store.dir);
291
297
  const previous = configManifests(ctx)[key(ctx, ws.id)];
292
298
  const issues = [];
293
- const files = await collectUserFiles(home, issues);
294
- const codexBytes = await readOptional(join(home, '.codex/config.toml'));
299
+ const files = opts.memoryOnly ? new Map() : await collectUserFiles(home, issues);
300
+ const codexBytes = opts.memoryOnly ? undefined : await readOptional(join(home, '.codex/config.toml'));
295
301
  // One file that does not parse is skipped with a reason; it never stops the rest of the sync.
296
302
  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' });
303
+ if (!opts.memoryOnly) {
304
+ try {
305
+ claude = await readObject(join(home, '.claude.json'));
306
+ }
307
+ catch (error) {
308
+ if (!parseFailure(error))
309
+ throw error;
310
+ issues.push({ item: '.claude.json', reason: 'not valid JSON; its MCP servers were not copied' });
311
+ }
304
312
  }
305
- const plugins = await collectPlugins(home, issues);
313
+ const plugins = opts.memoryOnly ? { marketplaces: [], plugins: [] } : await collectPlugins(home, issues);
306
314
  const hasPlugins = plugins.marketplaces.length > 0 || plugins.plugins.length > 0;
315
+ const records = Object.values(localConversations(ctx));
316
+ const conversation = records.find(r => r.conversation.id === ws.labels.shard_conversation) ?? records.find(r => r.conversation.workspace_id === ws.id);
317
+ const memory = conversation ? await collectMemory(home, conversation.folder, issues) : undefined;
307
318
  const empty = { workspace_id: ws.id, workspace_key: ws.key, copied: [], removed: [], skipped: issues, mcp: [], synced_at: null };
308
- if (!files.size && !codexBytes && !hasPlugins && !Object.keys(object(claude?.mcpServers)).length && !previous)
319
+ if (!files.size && !memory?.files.size && !codexBytes && !hasPlugins && !Object.keys(object(claude?.mcpServers)).length && !previous)
309
320
  return empty;
310
321
  const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
311
- const run = await cell.exec.run(['sh', '-c', 'printf "%s\\n" "$HOME"'], { signal: ctx.signal, timeoutMs: 5000, maxOutputBytes: 4096 });
312
- const remoteHome = run.stdout.trim();
322
+ // The VM home, and the project folder its Claude keys memory by (the main checkout of the conversation's worktree).
323
+ const run = await cell.exec.run(['sh', '-c', 'printf "%s\\n" "$HOME"; [ -n "$1" ] && git -C "$1" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true', 'shard-config', conversation?.conversation.worktree_path ?? ''], { signal: ctx.signal, timeoutMs: 5000, maxOutputBytes: 4096 });
324
+ const [remoteHome = '', common = ''] = run.stdout.trim().split('\n').map(line => line.trim());
313
325
  if (run.exitCode !== 0 || !/^\/(?:[a-zA-Z0-9._-]+\/)*[a-zA-Z0-9._-]+$/.test(remoteHome) || remoteHome.split('/').some(p => p === '..' || p === '.'))
314
326
  throw new Error('agent config could not resolve the VM user home');
327
+ if (memory && conversation) {
328
+ const vmRoot = common.endsWith('/.git') ? common.slice(0, -5) : conversation.conversation.worktree_path;
329
+ for (const [path, file] of memory.files) {
330
+ const dest = vmMemoryPath(path, memory.root, vmRoot);
331
+ if (dest)
332
+ files.set(dest, file);
333
+ }
334
+ }
315
335
  // Claude settings stay the user's. sf-agent hooks are in /etc/claude-code/managed-settings.json.
316
336
  const secrets = { ...previous?.secrets, ...object(object(store.read().agent_config_secrets)[key(ctx, ws.id)]) };
317
337
  const commandCache = new Map();
@@ -432,8 +452,10 @@ export async function syncAgentConfig(ctx, ws) {
432
452
  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
453
  ...(pluginsHash ? { plugins_hash: pluginsHash } : {}) };
434
454
  manifest.remote_hash = digest(JSON.stringify({ files: applied.files, claude_mcp: claudeMcp, mcp, tunnels }));
435
- if (marker?.workspace_id !== ws.id || marker.manifest_hash !== manifest.remote_hash)
436
- await cell.files.write(`${remoteHome}/${markerPath}`, JSON.stringify({ workspace_id: ws.id, manifest_hash: manifest.remote_hash }) + '\n', { createParents: true, mode: '0600' });
455
+ // The marker names each file's hash (0.20.0+): the guest tells this copy's writes from the agent's own changes.
456
+ if (marker?.workspace_id !== ws.id || marker.manifest_hash !== manifest.remote_hash || !marker.files)
457
+ await cell.files.write(`${remoteHome}/${markerPath}`, JSON.stringify({ workspace_id: ws.id, manifest_hash: manifest.remote_hash,
458
+ files: Object.fromEntries(Object.entries(applied.files).map(([path, file]) => [path, file.hash])) }) + '\n', { createParents: true, mode: '0600' });
437
459
  store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = manifest; doc.agent_config = all; });
438
460
  const result = { ...empty, copied: applied.copied, removed: applied.removed, skipped: issues, mcp, synced_at };
439
461
  if (!ctx.json) {
@@ -451,13 +473,13 @@ export async function syncAgentConfig(ctx, ws) {
451
473
  * output in agent-config-sync.log of the config directory), so the command that started the conversation reports the
452
474
  * start at once; without a runtime (tests, an embedding) it runs here and the returned promise settles when it ends.
453
475
  */
454
- export function syncAgentConfigAfterStart(ctx, ws) {
476
+ export function syncAgentConfigAfterStart(ctx, ws, opts = {}) {
455
477
  if (ctx.runtime?.script) {
456
478
  try {
457
479
  mkdirSync(ctx.store.dir, { recursive: true, mode: 0o700 });
458
480
  const log = openSync(join(ctx.store.dir, 'agent-config-sync.log'), 'a', 0o600);
459
481
  try {
460
- const child = spawn(ctx.runtime.node, [ctx.runtime.script, 'agents', 'config-sync', ws.id], {
482
+ const child = spawn(ctx.runtime.node, [ctx.runtime.script, 'agents', 'config-sync', ws.id, ...(opts.memoryOnly ? ['--memory-only'] : [])], {
461
483
  detached: true, stdio: ['ignore', log, log],
462
484
  env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, SHARDFLUX_NO_UPDATE_CHECK: '1' }
463
485
  });
@@ -471,7 +493,7 @@ export function syncAgentConfigAfterStart(ctx, ws) {
471
493
  }
472
494
  catch { /* runs in this process below */ }
473
495
  }
474
- return syncAgentConfig(ctx, ws).then(() => undefined, (error) => {
496
+ return syncAgentConfig(ctx, ws, opts).then(() => undefined, (error) => {
475
497
  ctx.io.stderr.write(`Agent config not copied to ${ws.key}: ${error instanceof Error ? error.message : String(error)}\n`);
476
498
  });
477
499
  }
@@ -501,41 +523,13 @@ export async function startAgentConfigTunnels(ctx, ws) {
501
523
  }
502
524
  export const AGENT_CONFIG_HANDLERS = {
503
525
  /** The detached copy syncAgentConfigAfterStart starts; one line per run in agent-config-sync.log. */
504
- async 'agents config-sync'(ctx, _values, [id]) {
526
+ async 'agents config-sync'(ctx, values, [id]) {
505
527
  const ws = await ctx.cloud().workspaces.get(id);
506
528
  const started = Date.now();
507
- const result = await syncAgentConfig({ ...ctx, json: true }, ws);
529
+ const result = await syncAgentConfig({ ...ctx, json: true }, ws, { memoryOnly: values['memory-only'] === true });
508
530
  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
531
  for (const line of configIssueLines(result.skipped))
510
532
  ctx.io.stderr.write(` ${line}\n`);
511
533
  return 0;
512
534
  },
513
- 'agents config status'(ctx, values) {
514
- const workspace = typeof values.workspace === 'string' ? values.workspace : undefined;
515
- const manifests = Object.values(configManifests(ctx)).filter(m => m.api_url === ctx.apiUrl && (!workspace || m.workspace_id === workspace || m.workspace_key === workspace));
516
- const report = { observation: 'last sync on this machine', workspaces: manifests };
517
- ctx.io.stdout.write(ctx.json ? JSON.stringify(report, null, 2) + '\n' : manifests.length ? manifests.map(m => `${m.workspace_key}: ${Object.keys(m.files).length} files; ${m.sync_state === 'synced' ? `last sync ${m.synced_at}` : 'sync interrupted; run config sync again'}\n` +
518
- m.skipped.map(s => ` skipped ${s.item}: ${s.reason}\n`).join('') +
519
- m.mcp.map(s => ` MCP ${s.app}/${s.name}: ${s.status}${s.reason ? ` (${s.reason})` : ''}\n`).join('')).join('') : 'No agent config has been synced on this machine.\n');
520
- return Promise.resolve(0);
521
- },
522
- async 'agents config sync'(ctx, values) {
523
- const workspace = typeof values.workspace === 'string' ? values.workspace : undefined;
524
- const cloud = ctx.cloud();
525
- const targets = [];
526
- if (workspace) {
527
- const ws = await cloud.workspaces.findByKey(workspace, { includeDeleted: false, signal: ctx.signal });
528
- targets.push(ws ?? await cloud.workspaces.get(workspace));
529
- }
530
- else
531
- for await (const ws of cloud.workspaces.listAll()) {
532
- if (ws.labels.shard_project && ws.mode === 'processful')
533
- targets.push(ws);
534
- }
535
- const results = [];
536
- for (const ws of targets)
537
- results.push(await syncAgentConfig(ctx, ws));
538
- ctx.io.stdout.write(ctx.json ? JSON.stringify({ workspaces: results }, null, 2) + '\n' : results.length ? results.map(r => r.synced_at === null ? `${r.workspace_key}: no local agent config found\n` : `${r.workspace_key}: ${r.copied.length} files copied, ${r.removed.length} removed\n`).join('') : 'No agent workspaces found.\n');
539
- return 0;
540
- },
541
535
  };
@@ -54,8 +54,8 @@ export function setupGuide(state) {
54
54
  {
55
55
  step: 'Build the machine',
56
56
  ask: 'What should every cloud agent start with? Languages and runtimes, CLIs and tools, dotfiles and shell, secrets and credentials, editor and agent config.',
57
- commands: ['shard machine open --json', "shard ws exec <key> -- bash -lc '<install commands>'", 'shard files write <key> <path> --from -', 'shard machine save --wait --json'],
58
- note: 'machine open prints the machine VM’s workspace key. Install with ws exec, write dotfiles and secret files with files write (contents on standard input, never in arguments), then save: every new conversation starts from it.',
57
+ commands: ['shard machine setup --from-this-computer --json', 'shard machine open --json', "shard ws exec <key> -- bash -lc '<install commands>'", 'shard files write <key> <path> --from -', 'shard machine save --wait --json'],
58
+ note: 'machine setup recreates this computer’s tools, runtimes, shell, git identity and dotfiles on the machine with this computer’s Claude Code or Codex, and saves it (0.20.0+). For the rest: machine open prints the machine VM’s workspace key. Install with ws exec, write dotfiles and secret files with files write (contents on standard input, never in arguments), then save: every new conversation starts from it.',
59
59
  },
60
60
  {
61
61
  step: 'Set up the project',
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.19.1";
1
+ export declare const CLI_VERSION = "0.20.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
15
  import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
16
- export const CLI_VERSION = '0.19.1';
16
+ export const CLI_VERSION = '0.20.0';
17
17
  export const GLOBAL_OPTIONS = {
18
18
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
19
19
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -176,6 +176,15 @@ const KEY_COMMANDS = [
176
176
  { path: ['machine', 'open'], summary: 'Create or resume your machine VM without attaching to it, and print its workspace key (0.17.1+)', positionals: [], options: {},
177
177
  description: 'For an agent building your machine from a terminal it does not attach to: install with "shard ws exec <key> -- bash -lc ...", write dotfiles and secret files with "shard files write <key> <path> --from -" (contents on standard input), then "shard machine save --wait". The first open copies your Claude and Codex settings into it, as "shard machine" does.',
178
178
  examples: ['shard machine open --json', "shard ws exec \"$(shard machine open)\" -- bash -lc 'sudo apt-get install -y ripgrep'", 'shard machine save --wait --json'] },
179
+ { path: ['machine', 'setup'], summary: 'Let your own Claude Code or Codex set up your machine from this computer (0.20.0+)', positionals: [], options: {
180
+ 'from-this-computer': { type: 'boolean', description: 'Recreate this computer’s development setup on your machine: its CLIs and tools, language runtimes, shell config and aliases, git identity and dotfiles. Required.' },
181
+ 'if-needed': { type: 'boolean', description: 'Only when your machine was never set up from this computer (what the apps run after onboarding).' },
182
+ status: { type: 'boolean', description: 'Show the last setup from this computer instead of running one.' },
183
+ agent: { type: 'string', value: '<claude|codex|cloud>', description: 'Which agent sets it up (default: this computer’s Claude Code, else its Codex, else your cloud Claude in the machine VM).' },
184
+ timeout: { type: 'string', value: '<duration>', description: 'Time box for the agent (default 10m).' },
185
+ },
186
+ description: 'Your own coding agent does it, on your subscription, with no question asked: shard starts this computer’s Claude Code (claude -p) or Codex (codex exec) with a fixed task. On this computer it may only read (no edits, no ~/.ssh, keychain, token or credential files) and run shard on your machine VM; files go over standard input. Without Claude Code or Codex here, your cloud Claude does it inside the machine VM from a list of this computer’s tools and versions. Then shard copies your Claude and Codex config and saves the machine (shard machine save --wait). Progress goes to stderr, one line per step.',
187
+ examples: ['shard machine setup --from-this-computer', 'shard machine setup --from-this-computer --if-needed --json', 'shard machine setup --status --json'] },
179
188
  { path: ['agents', 'list'], summary: 'List coding agent sessions.', positionals: [], options: {
180
189
  ...PAGE,
181
190
  state: { type: 'string', value: '<working|waiting|exited>', description: 'Agent state filter.' },
@@ -1299,7 +1308,7 @@ const AGENT_COMMANDS = [
1299
1308
  } },
1300
1309
  { path: ['agents', 'fork'], summary: 'Fork an agent’s memory and disk', positionals: [{ name: 'agent', required: true }, { name: 'name', required: true }], options: WAIT },
1301
1310
  { path: ['agents', 'park'], summary: 'Suspend an agent when idle', positionals: [{ name: 'agent', required: true }], options: { 'when-idle': { type: 'string', value: '<duration>', description: 'Idle grace before suspend (default 1s).' } } },
1302
- { path: ['agents', 'config-sync'], hidden: true, summary: 'Copy this computer’s Claude and Codex config into a conversation VM (started in the background after a start, 0.19.0+)', positionals: [{ name: 'workspace', required: true }], options: {} },
1311
+ { path: ['agents', 'config-sync'], hidden: true, summary: 'Copy this computer’s Claude and Codex config into a conversation VM (started in the background after a start, 0.19.0+)', positionals: [{ name: 'workspace', required: true }], options: { 'memory-only': { type: 'boolean', description: 'Only the project memory (an account with a saved machine, 0.20.0+).' } } },
1303
1312
  { path: ['agents', 'logs'], summary: 'Tail an agent’s terminal from its output offset: a conversation id (0.18.1+) or an agent workspace key', positionals: [{ name: 'agent', required: true }], options: {
1304
1313
  app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice; a conversation uses its own).' },
1305
1314
  offset: { type: 'string', value: '<bytes>', description: 'Read from this output byte offset (default last 64 KiB).' },
@@ -24,6 +24,8 @@ import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-comma
24
24
  import { openUrl } from "./browser.js";
25
25
  import { out } from "./commands.js";
26
26
  import { progress } from "./progress.js";
27
+ /** This computer's agent config, copied after the start; with a saved machine (which carries it) just the project memory. */
28
+ const afterStartConfig = (ctx, ws, machine) => () => syncAgentConfigAfterStart(ctx, ws, machine ? { memoryOnly: true } : {});
27
29
  /** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
28
30
  export async function conversationTerminal(ctx, ws, c, project, values, afterStart) {
29
31
  if (c.route === 'codex-cloud')
@@ -48,7 +50,7 @@ export async function reopenConversation(ctx, c, values) {
48
50
  progress(ctx, 'Restored');
49
51
  return { conversation: restored, ws };
50
52
  }
51
- const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
53
+ const afterStart = afterStartConfig(ctx, ws, !!(await savedMachine(ctx))?.template);
52
54
  const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
53
55
  await conversationTerminal(quiet, ws, restored, ws.labels.repo_remote || ws.labels.shard_project || ws.key, { ...values, detach: true }, afterStart);
54
56
  return { conversation: restored, ws };
@@ -148,7 +150,7 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
148
150
  if (!signedIn)
149
151
  await loginClaudeInWorkspace(ctx, ws);
150
152
  // Without a saved machine the laptop's Claude and Codex config is copied in, after the agent has its first prompt.
151
- const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
153
+ const afterStart = afterStartConfig(ctx, ws, !!(await savedMachine(ctx))?.template);
152
154
  await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
153
155
  if (openIn) {
154
156
  // The agent starts detached in its VM; the conversation then opens in Claude Desktop or claude.ai (Remote Control).
@@ -49,6 +49,10 @@ export interface Conversation {
49
49
  } | null;
50
50
  /** Why it needs you, while `state` is needs_you; null otherwise (0.18.0+, APIs that return it). */
51
51
  question?: string | null;
52
+ /** The user who started it (0.20.0+, APIs that record it); null when unknown. */
53
+ started_by?: {
54
+ user_id: string;
55
+ } | null;
52
56
  }
53
57
  export interface LocalConversation {
54
58
  api_url: string;
@@ -12,6 +12,7 @@ import { writePrivateFile } from "./credentials.js";
12
12
  import { conversationClient } from "./conversation-commands.js";
13
13
  import { Readable } from 'node:stream';
14
14
  import { runWorktreeTwin } from "./worktree-twin-engine.js";
15
+ import { AGENT_CONFIG_POLL_MS, AgentConfigReturnState, returnAgentConfigsOnce } from "./agent-config-return.js";
15
16
  import { askLocalDeviceApproval } from "./device-approvals.js";
16
17
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
17
18
  export async function runDeviceHelper(ctx, options = {}) {
@@ -55,7 +56,7 @@ export async function runDeviceHelper(ctx, options = {}) {
55
56
  ctx.io.stdout.write(JSON.stringify({ type: 'approval_resolved', request_id: approval.request_id }) + '\n'); });
56
57
  };
57
58
  const working = new Map();
58
- const mirrored = new ConversationMirrorState(), files = new FilesMirrorState();
59
+ const mirrored = new ConversationMirrorState(), files = new FilesMirrorState(), config = new AgentConfigReturnState();
59
60
  const deviceFile = join(ctx.store.dir, 'device-id');
60
61
  let deviceId;
61
62
  try {
@@ -67,11 +68,35 @@ export async function runDeviceHelper(ctx, options = {}) {
67
68
  }
68
69
  const executor = new DeviceExecutor(ctx, ask, deviceId);
69
70
  const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
70
- let mirroring, twin;
71
+ let mirroring, twin, returning;
71
72
  try {
72
73
  return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
73
74
  // Finished conversations land in their local branches (worktree-twin-engine.ts); its own loop and backoff.
74
75
  twin = runWorktreeTwin(ctx, deviceId, signal).catch(report);
76
+ // Skills, memories and CLAUDE.md lines saved in the cloud come back on their own cadence (agent-config-return.ts):
77
+ // never behind a files-mirror pass, which takes minutes for an account with hundreds of conversations.
78
+ returning = (async () => {
79
+ let failures = 0;
80
+ const reported = new Set();
81
+ while (!signal.aborted) {
82
+ let wait = AGENT_CONFIG_POLL_MS;
83
+ try {
84
+ await returnAgentConfigsOnce(ctx, config);
85
+ failures = 0;
86
+ }
87
+ catch (e) {
88
+ if (signal.aborted)
89
+ break;
90
+ wait = Math.min(300_000, 5000 * 2 ** Math.min(failures++, 6));
91
+ const message = e instanceof Error ? e.message : String(e);
92
+ if (!conversationsUnavailable(e) && !reported.has(message)) {
93
+ reported.add(message);
94
+ ctx.io.stderr.write(`Agent config: ${message} (retrying in ${wait / 1000}s).\n`);
95
+ }
96
+ }
97
+ await delay(wait, undefined, { signal }).catch(() => undefined);
98
+ }
99
+ })();
75
100
  // A slow or failed transcript copy must not delay heartbeats, request polling or approvals.
76
101
  mirroring = (async () => {
77
102
  let failures = 0;
@@ -133,6 +158,8 @@ export async function runDeviceHelper(ctx, options = {}) {
133
158
  await executor.close();
134
159
  await Promise.all([...working.values()]);
135
160
  await mirroring;
161
+ await returning;
162
+ await config.folding;
136
163
  await twin;
137
164
  await input.catch(() => undefined);
138
165
  }
package/dist/errors.d.ts CHANGED
@@ -62,6 +62,13 @@ export declare class AuthConfigError extends HintedError {
62
62
  export declare class NotFoundError extends HintedError {
63
63
  name: string;
64
64
  }
65
+ /**
66
+ * `shard machine setup --from-this-computer` (0.20.0+) ran but did not save a machine: the agent failed, ran past its
67
+ * time box, or the save failed. Retryable: the same command runs it again (exit 6).
68
+ */
69
+ export declare class MachineSetupError extends HintedError {
70
+ name: string;
71
+ }
65
72
  /** `auth login` of a two-factor account without a code: the pending sign-in is saved; `shard auth mfa` completes it (exit 4). */
66
73
  export declare class MfaPendingError extends HintedError {
67
74
  name: string;
package/dist/errors.js CHANGED
@@ -87,6 +87,13 @@ export class AuthConfigError extends HintedError {
87
87
  export class NotFoundError extends HintedError {
88
88
  name = 'NotFoundError';
89
89
  }
90
+ /**
91
+ * `shard machine setup --from-this-computer` (0.20.0+) ran but did not save a machine: the agent failed, ran past its
92
+ * time box, or the save failed. Retryable: the same command runs it again (exit 6).
93
+ */
94
+ export class MachineSetupError extends HintedError {
95
+ name = 'MachineSetupError';
96
+ }
90
97
  /** `auth login` of a two-factor account without a code: the pending sign-in is saved; `shard auth mfa` completes it (exit 4). */
91
98
  export class MfaPendingError extends HintedError {
92
99
  name = 'MfaPendingError';
@@ -265,6 +272,8 @@ export function describeError(err) {
265
272
  return { code: 'mfa_required', message: err.message };
266
273
  if (err instanceof NotFoundError)
267
274
  return { code: 'not_found', message: err.message };
275
+ if (err instanceof MachineSetupError)
276
+ return { code: 'machine_setup_failed', message: err.message, retryable: true };
268
277
  if (err instanceof InterruptedError) {
269
278
  return { code: 'interrupted', message: err.message, ...(err.operationId ? { operation_id: err.operationId } : {}), ...(err.executionId ? { execution_id: err.executionId } : {}) };
270
279
  }
@@ -294,6 +303,8 @@ export function exitCodeFor(err, interrupted = false) {
294
303
  return EXIT.operationFailed;
295
304
  if (err instanceof NotFoundError)
296
305
  return EXIT.notFound;
306
+ if (err instanceof MachineSetupError)
307
+ return EXIT.operationFailed;
297
308
  if (err instanceof OperationTimeoutError || err instanceof CheckoutTimeoutError || isNamed(err, 'TimeoutError'))
298
309
  return EXIT.timeout;
299
310
  if (err instanceof OperationFailedError || err instanceof DurabilityLostError || err instanceof ExecutionFailedError || err instanceof CheckoutEndedError)
@@ -4,6 +4,18 @@ import type { Ctx, Handler } from './commands.js';
4
4
  export declare function filesystemLine(state: AgentFilesystem & {
5
5
  cache_scope?: string;
6
6
  }): string;
7
+ /** A build's place while it waits for one of the plan's build slots (API: TemplateBuild.queue). */
8
+ export interface BuildQueue {
9
+ position: number;
10
+ slots: number;
11
+ expected_wait_seconds: number;
12
+ }
13
+ /** "about 40 s" / "about 3 min": the queue's estimate, rounded the way people say it. */
14
+ export declare function aboutWait(seconds: number): string;
15
+ /** One line for a build that waits for a slot (0.20.0+): "Queued behind 3 builds · about 40 s" (running ones + those ahead). */
16
+ export declare function queueLine(q: BuildQueue): string;
17
+ /** How often a waited save reads its build's place in the queue. */
18
+ export declare const QUEUE_POLL_MS = 2500;
7
19
  /** The save's one line about software under the home that went into the image (0.18.0+), as the in-VM shard says it. */
8
20
  export declare function movedLine(state: AgentFilesystem, level: 'machine' | 'template'): string;
9
21
  /** Creates or resumes the machine VM of the saved machine version; the first one gets this computer's agent config. */
@@ -29,17 +29,79 @@ function output(ctx, state) {
29
29
  ctx.io.stdout.write(ctx.json ? JSON.stringify(state) + '\n' : `${filesystemLine(state)}\n`);
30
30
  return 0;
31
31
  }
32
- /** Save output: one line naming the version being saved, or with --wait the saved version (progress on stderr). */
32
+ /** "about 40 s" / "about 3 min": the queue's estimate, rounded the way people say it. */
33
+ export function aboutWait(seconds) {
34
+ if (seconds < 90)
35
+ return `about ${Math.max(10, Math.round(seconds / 10) * 10)} s`;
36
+ return `about ${Math.round(seconds / 60)} min`;
37
+ }
38
+ /** One line for a build that waits for a slot (0.20.0+): "Queued behind 3 builds · about 40 s" (running ones + those ahead). */
39
+ export function queueLine(q) {
40
+ const behind = q.slots + q.position - 1;
41
+ return `Queued behind ${behind} build${behind === 1 ? '' : 's'} · ${aboutWait(q.expected_wait_seconds)}`;
42
+ }
43
+ /** How often a waited save reads its build's place in the queue. */
44
+ export const QUEUE_POLL_MS = 2_500;
45
+ /** The pending build's queue, if it waits for a slot; nothing when the API predates it or the build cannot be read. */
46
+ async function pendingQueue(ctx, state) {
47
+ if (!state.pending)
48
+ return undefined;
49
+ try {
50
+ const build = await ctx.cloud().templates.builds.get(state.organization_id, state.pending.build_id);
51
+ return build.queue ?? undefined;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ /**
58
+ * Save output: one line naming the version being saved (and its place in the queue when the plan's build slots are in
59
+ * use, 0.20.0+), or with --wait the saved version (progress on stderr, the queue line updating while it waits).
60
+ */
33
61
  async function saved(ctx, values, save, level) {
34
62
  if (values.wait !== true && values.timeout !== undefined)
35
63
  throw new UsageError('--timeout needs --wait');
36
- const wait = values.wait === true ? { timeoutMs: waitTimeout(values, 600_000), onChange: (state) => { if (state.state === 'building' && state.pending)
37
- progress(ctx, `Saving ${level} version ${state.pending.version}…`); } } : undefined;
38
- const state = await save({ signal: ctx.signal, ...(wait ? { wait } : {}) });
64
+ const stop = new AbortController();
65
+ let watcher;
66
+ // While the save waits, its build's place in the queue: one line each time it changes.
67
+ const watchQueue = (state) => {
68
+ if (watcher || !state.pending)
69
+ return;
70
+ watcher = (async () => {
71
+ let last = '';
72
+ while (!stop.signal.aborted) {
73
+ const q = await pendingQueue(ctx, state);
74
+ const line = q ? queueLine(q) : '';
75
+ if (line && line !== last && !stop.signal.aborted)
76
+ progress(ctx, line);
77
+ last = line;
78
+ await new Promise(resolve => {
79
+ const timer = setTimeout(resolve, QUEUE_POLL_MS);
80
+ stop.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
81
+ });
82
+ }
83
+ })();
84
+ };
85
+ const wait = values.wait === true ? { timeoutMs: waitTimeout(values, 600_000), onChange: (state) => {
86
+ if (state.state === 'building' && state.pending) {
87
+ progress(ctx, `Saving ${level} version ${state.pending.version}…`);
88
+ watchQueue(state);
89
+ }
90
+ } } : undefined;
91
+ let state;
92
+ try {
93
+ state = await save({ signal: ctx.signal, ...(wait ? { wait } : {}) });
94
+ }
95
+ finally {
96
+ stop.abort();
97
+ await watcher;
98
+ }
99
+ const queue = wait ? undefined : await pendingQueue(ctx, state);
39
100
  if (ctx.json)
40
- return output(ctx, state);
101
+ return output(ctx, queue ? { ...state, queue } : state);
41
102
  const moved = movedLine(state, level);
42
- ctx.io.stdout.write(wait ? `${level}: version ${state.version} saved${moved}\n` : state.pending ? `${filesystemLine(state)}; shard ${level} shows when it is ready${moved}\n` : `${filesystemLine(state)}${moved}\n`);
103
+ const queued = queue ? `; ${queueLine(queue).replace(/^Q/, 'q')}` : '';
104
+ ctx.io.stdout.write(wait ? `${level}: version ${state.version} saved${moved}\n` : state.pending ? `${filesystemLine(state)}${queued}; shard ${level} shows when it is ready${moved}\n` : `${filesystemLine(state)}${moved}\n`);
43
105
  return 0;
44
106
  }
45
107
  /** The save's one line about software under the home that went into the image (0.18.0+), as the in-VM shard says it. */
package/dist/handlers.js CHANGED
@@ -23,4 +23,5 @@ import { CLI_PROXY_HANDLERS } from "./cli-proxy.js";
23
23
  import { ACCOUNT_POOL_HANDLERS } from "./account-pool.js";
24
24
  import { MOVE_HANDLERS } from "./move.js";
25
25
  import { AGENT_CONFIG_HANDLERS } from "./agent-config-sync.js";
26
- export const HANDLERS = { ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS, 'agents config-sync': AGENT_CONFIG_HANDLERS['agents config-sync'] };
26
+ import { MACHINE_SETUP_HANDLERS } from "./machine-setup.js";
27
+ export const HANDLERS = { ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...MACHINE_SETUP_HANDLERS, ...UPDATE_HANDLERS, ...AGENT_CONFIG_HANDLERS };