@shardflux/cli 0.20.0 → 0.23.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 (124) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +242 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-journal.js +58 -6
  10. package/dist/acp-login.d.ts +13 -5
  11. package/dist/acp-login.js +37 -8
  12. package/dist/acp-models.d.ts +6 -1
  13. package/dist/acp-models.js +29 -19
  14. package/dist/acp.js +120 -14
  15. package/dist/agent-commands.d.ts +4 -1
  16. package/dist/agent-commands.js +110 -42
  17. package/dist/agent-config-return.d.ts +46 -2
  18. package/dist/agent-config-return.js +110 -4
  19. package/dist/agent-credentials.js +7 -3
  20. package/dist/agent-repository.d.ts +16 -3
  21. package/dist/agent-repository.js +149 -74
  22. package/dist/agent-setup.d.ts +14 -3
  23. package/dist/agent-setup.js +78 -36
  24. package/dist/agent-target-commands.d.ts +20 -1
  25. package/dist/agent-target-commands.js +73 -31
  26. package/dist/agent-targets.js +6 -1
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +95 -54
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-hub.d.ts +61 -25
  35. package/dist/cli-proxy-hub.js +202 -54
  36. package/dist/cli-proxy.d.ts +44 -1
  37. package/dist/cli-proxy.js +177 -28
  38. package/dist/client-settings.d.ts +39 -0
  39. package/dist/client-settings.js +91 -0
  40. package/dist/codex-cloud.d.ts +14 -4
  41. package/dist/codex-cloud.js +135 -31
  42. package/dist/codex-linux.d.ts +14 -0
  43. package/dist/codex-linux.js +69 -0
  44. package/dist/command-center-actions.js +18 -5
  45. package/dist/command-center-model.d.ts +2 -0
  46. package/dist/command-center-model.js +6 -1
  47. package/dist/command-center.js +3 -3
  48. package/dist/commands.js +173 -24
  49. package/dist/conversation-commands.d.ts +5 -0
  50. package/dist/conversation-commands.js +8 -5
  51. package/dist/conversation-terminal.d.ts +10 -2
  52. package/dist/conversation-terminal.js +78 -13
  53. package/dist/conversations.d.ts +86 -1
  54. package/dist/conversations.js +306 -23
  55. package/dist/desktop-linux.js +16 -3
  56. package/dist/device-bridge.d.ts +1 -1
  57. package/dist/device-bridge.js +6 -4
  58. package/dist/device-helper.js +43 -22
  59. package/dist/errors.d.ts +29 -2
  60. package/dist/errors.js +50 -7
  61. package/dist/filesystem-commands.d.ts +16 -9
  62. package/dist/filesystem-commands.js +133 -57
  63. package/dist/format.d.ts +2 -1
  64. package/dist/format.js +10 -1
  65. package/dist/handlers.js +3 -1
  66. package/dist/initial-prompt.d.ts +8 -0
  67. package/dist/initial-prompt.js +43 -1
  68. package/dist/large-media.d.ts +14 -0
  69. package/dist/large-media.js +100 -0
  70. package/dist/linux-terminal.d.ts +5 -3
  71. package/dist/linux-terminal.js +6 -20
  72. package/dist/local-sessions.d.ts +26 -2
  73. package/dist/local-sessions.js +113 -24
  74. package/dist/machine-setup.d.ts +11 -1
  75. package/dist/machine-setup.js +69 -22
  76. package/dist/machine-status.d.ts +67 -0
  77. package/dist/machine-status.js +140 -0
  78. package/dist/main.js +11 -13
  79. package/dist/menubar.d.ts +6 -5
  80. package/dist/menubar.js +38 -28
  81. package/dist/move-worktrees-restore.py +144 -0
  82. package/dist/move-worktrees.d.ts +69 -0
  83. package/dist/move-worktrees.js +252 -0
  84. package/dist/move.d.ts +10 -1
  85. package/dist/move.js +111 -16
  86. package/dist/onboarding.d.ts +11 -7
  87. package/dist/onboarding.js +63 -29
  88. package/dist/prepared-repository.d.ts +204 -0
  89. package/dist/prepared-repository.js +860 -0
  90. package/dist/prepared-repository.py +1307 -0
  91. package/dist/project-preparation.d.ts +62 -0
  92. package/dist/project-preparation.js +316 -0
  93. package/dist/project-sync-manifest.d.ts +4 -0
  94. package/dist/project-sync-manifest.js +5 -1
  95. package/dist/project-sync-transfer.js +1 -1
  96. package/dist/project-sync.d.ts +22 -4
  97. package/dist/project-sync.js +34 -8
  98. package/dist/project-template-setup.d.ts +33 -1
  99. package/dist/project-template-setup.js +59 -2
  100. package/dist/secret-ask.d.ts +72 -0
  101. package/dist/secret-ask.js +351 -0
  102. package/dist/seed-phases.d.ts +8 -1
  103. package/dist/seed-phases.js +17 -2
  104. package/dist/setup-cache.d.ts +7 -0
  105. package/dist/setup-cache.js +32 -0
  106. package/dist/t3-launch.d.ts +2 -1
  107. package/dist/t3-launch.js +12 -6
  108. package/dist/t3-mode.d.ts +8 -2
  109. package/dist/t3-mode.js +19 -4
  110. package/dist/t3-phone.d.ts +4 -2
  111. package/dist/t3-phone.js +12 -4
  112. package/dist/t3-proxy.d.ts +81 -0
  113. package/dist/t3-proxy.js +325 -0
  114. package/dist/terminal-apps.d.ts +168 -0
  115. package/dist/terminal-apps.js +574 -0
  116. package/dist/terminal-onboarding.js +6 -1
  117. package/dist/this-computer.d.ts +11 -0
  118. package/dist/this-computer.js +36 -0
  119. package/dist/thread-on-computer.js +4 -1
  120. package/dist/update.d.ts +2 -0
  121. package/dist/update.js +43 -9
  122. package/dist/worktree-twin.d.ts +10 -2
  123. package/dist/worktree-twin.js +27 -9
  124. package/package.json +5 -5
@@ -25,6 +25,7 @@ import { syncAgentConfig } from "./agent-config-sync.js";
25
25
  import { cloudClaudeLogin } from "./claude-cloud-login.js";
26
26
  import { MachineSetupError, UsageError } from "./errors.js";
27
27
  import { openMachine } from "./filesystem-commands.js";
28
+ import { machineRow, readMachineRow, rowText, statusDoc } from "./machine-status.js";
28
29
  import { installDirs } from "./onboarding.js";
29
30
  import { progress as stderrProgress } from "./progress.js";
30
31
  import { writeProgress } from "./seed-phases.js";
@@ -248,6 +249,14 @@ export function setupGuard(env, command, values, positionals) {
248
249
  return 'machine setup reads files only to standard output';
249
250
  return undefined;
250
251
  }
252
+ function setupCapacity(ws) {
253
+ const disk = ws.grants?.disk_gib;
254
+ if (typeof disk !== 'number' || !Number.isFinite(disk) || disk <= 0)
255
+ return undefined;
256
+ const requested = ws.caps?.disk_gib ?? null;
257
+ const source = ws.ceilings?.disk_gib === disk ? ws.ceilings.sources.disk_gib : requested === disk ? 'cap' : null;
258
+ return { disk_gib: disk, requested_disk_gib: requested, source };
259
+ }
251
260
  const CLIS = ['gh', 'git-lfs', 'docker', 'kubectl', 'helm', 'k9s', 'kind', 'terraform', 'tofu', 'pulumi', 'aws', 'gcloud', 'az', 'doctl', 'flyctl', 'vercel',
252
261
  'netlify', 'heroku', 'supabase', 'wrangler', 'stripe', 'jq', 'yq', 'rg', 'fd', 'fzf', 'bat', 'eza', 'tree', 'htop', 'btop', 'tmux', 'nvim', 'vim', 'emacs',
253
262
  'http', 'wget', 'make', 'cmake', 'just', 'direnv', 'starship', 'zoxide', 'atuin', 'lazygit', 'delta', 'tig', 'uv', 'pipx', 'poetry', 'pnpm', 'yarn', 'bun',
@@ -309,7 +318,10 @@ export async function collectInventory(env) {
309
318
  catch { /* absent */ }
310
319
  }
311
320
  const os = osPlatform() === 'darwin' ? `macOS ${release()} (${arch()})` : `${osPlatform()} ${release()} (${arch()})`;
321
+ const dotfiles = DOTFILES.filter(f => exists(join(home, f)));
312
322
  return {
323
+ source_home: home,
324
+ config_paths: dotfiles.map(f => join(home, f)),
313
325
  os,
314
326
  shell: env.SHELL ? env.SHELL.split('/').pop() : null,
315
327
  homebrew: brew ? brew.split('\n').map(s => s.trim()).filter(Boolean).slice(0, 400) : null,
@@ -319,7 +331,7 @@ export async function collectInventory(env) {
319
331
  clis: CLIS.filter(c => has(c)),
320
332
  npm_globals: npmGlobals.slice(0, 200),
321
333
  git: { ...(name ? { name } : {}), ...(email ? { email } : {}) },
322
- dotfiles: DOTFILES.filter(f => exists(join(home, f))),
334
+ dotfiles,
323
335
  };
324
336
  }
325
337
  /** Skills this computer's config copy brings to the machine: ~/.claude/skills and ~/.codex/skills (not Codex's bundled .system). */
@@ -340,7 +352,7 @@ const SUMMARY_SHAPE = '{"tools":["gh","ripgrep"],"runtimes":["Node 24","Python 3
340
352
  * The fixed task: agent-setup.ts's "Build the machine" step, extended to recreate this computer. `where` is 'local' for
341
353
  * an agent on this computer (it reaches the VM through shard) or 'vm' for the cloud Claude inside the machine VM.
342
354
  */
343
- export function setupTask(where, key, inventory) {
355
+ export function setupTask(where, key, inventory, capacity) {
344
356
  const reach = where === 'local'
345
357
  ? [
346
358
  `The machine VM's workspace key is ${key}. Run commands on it with:`,
@@ -349,6 +361,8 @@ export function setupTask(where, key, inventory) {
349
361
  ` shard files write ${key} /home/user/<path> --from - <<'EOF'`,
350
362
  ' <contents>',
351
363
  ' EOF',
364
+ ...(inventory.source_home ? [`Read local configuration only under ${JSON.stringify(inventory.source_home)}. The inventory and those files are the source of truth. Do not infer the source home or git identity from your login shell, inspect another home, or read dotfiles there.`] : []),
365
+ ...(inventory.config_paths ? [`The allowed local configuration paths are ${JSON.stringify(inventory.config_paths)}. Read only these paths (or their children for a listed directory); an empty list means there are no portable dotfiles to read. Claude/Codex configuration is copied separately by shard, not by you.`] : []),
352
366
  'On this computer you may only read: the Read and Glob tools, and read-only commands (brew list, brew leaves, ls, which, git config --global --list, <tool> --version). Nothing on this computer may change. Run every shard command on its own, not in a pipeline.',
353
367
  ]
354
368
  : [
@@ -362,8 +376,11 @@ export function setupTask(where, key, inventory) {
362
376
  'Their computer (collected by shard; names and versions only):',
363
377
  JSON.stringify(inventory, null, 1),
364
378
  '',
379
+ ...(capacity ? ['Machine disk granted by Shardflux (a requested cap is not additional space):', JSON.stringify(capacity), ''] : []),
380
+ 'Before installing, run df -h / /home/user on the VM. Check package download/installed sizes before each small batch; use --no-install-recommends with apt-get. Leave room for package configuration and the saved machine. If a tool or matching runtime will not fit, skip it with the concrete disk-space reason in your JSON, and keep the package manager healthy. Never grow the disk, change caps or upgrade the plan. Do not describe skipped tools as installed.',
381
+ '',
365
382
  'Do:',
366
- '1. Dev tools and CLIs it uses (Homebrew formulae, the CLIs present, global npm packages): install the Linux equivalents with sudo apt-get install -y, the tool\'s official Linux install script or binary release, npm -g, pipx or uv tool. Check with command -v first and skip what the VM already has. Skip GUI apps, macOS-only tools and libraries that are only dependencies.',
383
+ '1. Dev tools and CLIs it uses (Homebrew formulae, the CLIs present, global npm packages): install the Linux equivalents with sudo apt-get install -y --no-install-recommends, the tool\'s official Linux install script or binary release, npm -g, pipx or uv tool. Check with command -v first and skip what the VM already has. Skip GUI apps, macOS-only tools and libraries that are only dependencies.',
367
384
  '2. Language runtimes at the same major versions (Node, Python, Go, Rust, Ruby, Java…), with the version manager the computer uses when there is one (nvm, mise, asdf, pyenv, uv…).',
368
385
  '3. Shell: the same login shell if it is zsh or fish (install it and chsh -s for user), with the framework and prompt it uses (oh-my-zsh, starship…). Bring the aliases, functions, environment variables and PATH entries from its rc files that make sense on Linux into the VM\'s rc files, in a block marked "# from your computer (shard machine setup)", keeping what is there. Leave out macOS-only lines (brew shellenv, /opt/homebrew, /Applications, pbcopy, open, osascript), paths that only exist on that computer, and every line that sets a token, key, password or other secret.',
369
386
  '4. Git: the global identity (user.name, user.email) and global settings that are not machine specific (aliases, init.defaultBranch, pull.rebase, the global gitignore). No credential helper, no signing key.',
@@ -470,6 +487,21 @@ export function claimRun(store, apiUrl, org, ifNeeded, start) {
470
487
  });
471
488
  return result;
472
489
  }
490
+ /** An explicit tool permission refusal must not be retried through a different agent. */
491
+ class SetupPermissionRefusal extends MachineSetupError {
492
+ }
493
+ function checkClaudePermissions(result, app) {
494
+ if (!Array.isArray(result?.permission_denials) || !result.permission_denials.length)
495
+ return;
496
+ const tools = [...new Set(result.permission_denials.flatMap(d => typeof d?.tool_name === 'string' ? [d.tool_name.slice(0, 40)] : []))].slice(0, 5);
497
+ throw new SetupPermissionRefusal(`${app} stopped: permission refused${tools.length ? ` for ${tools.join(', ')}` : '; the provider omitted the tool names'}`, 'Resolve the reported permission refusal, then run shard machine setup --from-this-computer again');
498
+ }
499
+ function setupOutcome(text, app) {
500
+ const summary = parseSummary(text);
501
+ if (!summary)
502
+ throw new MachineSetupError(`${app} did not return a setup report`, 'shard machine setup --from-this-computer runs it again');
503
+ return { summary, text };
504
+ }
473
505
  /**
474
506
  * Whether the agent reached the machine VM at all: every shard command it ran failed (a sandbox without network, a
475
507
  * permission it was never given) means nothing was set up, whatever its reply says, and the next agent should try.
@@ -486,11 +518,13 @@ export class ShardReach {
486
518
  this.firstError = firstLine(output).slice(0, 160);
487
519
  }
488
520
  check(app) {
521
+ if (this.ok === 0 && this.calls === 0)
522
+ throw new MachineSetupError(`${app} did not confirm access to your machine with shard`, 'shard machine setup --from-this-computer runs it again');
489
523
  if (this.calls > 0 && this.ok === 0)
490
524
  throw new MachineSetupError(`${app} could not reach your machine with shard${this.firstError ? `: ${this.firstError}` : ''}`, 'shard machine setup --from-this-computer runs it again');
491
525
  }
492
526
  }
493
- const isShardCommand = (command) => /(^|[\s;&|('"])shard\s+(ws|workspaces|files)\s/.test(command);
527
+ const isShardCommand = (command) => /(^|[\s;&|('"])shard\s+(?:(?:--json|--wake|--no-wake)\s+|(?:--api-url|--agent-label|--wake-timeout)\s+(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*(ws|workspaces|files)\s/.test(command);
494
528
  const toolText = (content) => typeof content === 'string' ? content
495
529
  : Array.isArray(content) ? content.map(c => (c && typeof c === 'object' && typeof c.text === 'string' ? c.text : '')).join(' ') : '';
496
530
  /** A private folder for the run: the agent's working directory, and a `shard` on PATH that is this very CLI. */
@@ -539,6 +573,7 @@ async function runClaudeLocal(ctx, agent, key, task, timeoutMs, log) {
539
573
  catch { /* not an event */ }
540
574
  },
541
575
  });
576
+ checkClaudePermissions(result, 'Claude');
542
577
  if (run.timedOut)
543
578
  throw new MachineSetupError(`Claude ran past the ${timeoutText(timeoutMs)} time box`, 'shard machine setup --from-this-computer runs it again');
544
579
  if (!result)
@@ -546,7 +581,7 @@ async function runClaudeLocal(ctx, agent, key, task, timeoutMs, log) {
546
581
  if (result.is_error || result.subtype !== 'success')
547
582
  throw new MachineSetupError(`Claude stopped: ${(result.result ?? result.subtype ?? 'error').slice(0, 200)}`, 'shard machine setup --from-this-computer runs it again');
548
583
  reach.check('Claude');
549
- return { summary: parseSummary(result.result ?? ''), text: result.result ?? '' };
584
+ return setupOutcome(result.result ?? '', 'Claude');
550
585
  }
551
586
  finally {
552
587
  rmSync(dir, { recursive: true, force: true });
@@ -587,7 +622,7 @@ async function runCodexLocal(ctx, agent, key, task, timeoutMs, log) {
587
622
  if (run.code !== 0)
588
623
  throw new MachineSetupError(`Codex stopped: ${failure || firstLine(run.stderr) || `exit ${run.code ?? run.signal}`}`.slice(0, 300), 'shard machine setup --from-this-computer runs it again');
589
624
  reach.check('Codex');
590
- return { summary: parseSummary(last), text: last };
625
+ return setupOutcome(last, 'Codex');
591
626
  }
592
627
  finally {
593
628
  rmSync(dir, { recursive: true, force: true });
@@ -615,9 +650,10 @@ async function runCloud(ctx, ws, task, timeoutMs) {
615
650
  doc = JSON.parse(run.stdout.trim().split('\n').pop() ?? '{}');
616
651
  }
617
652
  catch { /* below */ }
618
- if (run.exitCode !== 0 || doc.is_error || !doc.result)
653
+ checkClaudePermissions(doc, 'Cloud Claude');
654
+ if (run.exitCode !== 0 || doc.is_error || (doc.subtype !== undefined && doc.subtype !== 'success') || !doc.result)
619
655
  throw new MachineSetupError(`Claude in the machine VM stopped: ${(doc.result ?? firstLine(run.stderr) ?? `exit ${run.exitCode}`).slice(0, 200) || `exit ${run.exitCode}`}`, 'shard machine setup --from-this-computer runs it again');
620
- return { summary: parseSummary(doc.result), text: doc.result };
656
+ return setupOutcome(doc.result, 'Cloud Claude');
621
657
  }
622
658
  finally {
623
659
  await cell.files.remove('/tmp/shard-machine-setup', { recursive: true }).catch(() => undefined);
@@ -665,17 +701,19 @@ function output(ctx, doc, human) {
665
701
  ctx.io.stdout.write(ctx.json ? `${JSON.stringify(doc)}\n` : `${human}\n`);
666
702
  return 0;
667
703
  }
668
- function statusDoc(rec, machine) {
669
- return { state: rec?.state ?? 'none', machine_version: machine.version, ...(rec ?? {}), ...(rec ? {} : { line: null }) };
670
- }
671
704
  export async function machineSetup(ctx, values, deps = {}) {
672
705
  const cloud = ctx.cloud();
673
706
  const store = new AgentStateStore(ctx.store.dir);
674
707
  const machine = await cloud.machine.get({ signal: ctx.signal });
675
708
  const org = machine.organization_id;
709
+ // One source of truth (0.20.2+): the API's machine decides, this computer's record adds a run under way or stopped.
710
+ const status = async () => {
711
+ const rec = readRecord(store, ctx.apiUrl, org), row = await readMachineRow(ctx, machine);
712
+ return { doc: statusDoc(row, rec), text: rowText(row) };
713
+ };
676
714
  if (values.status === true) {
677
- const rec = readRecord(store, ctx.apiUrl, org);
678
- return output(ctx, statusDoc(rec, machine), rec?.line ?? (machine.version > 0 ? `machine: version ${machine.version}; not set up from this computer` : 'machine: not set up yet'));
715
+ const { doc, text } = await status();
716
+ return output(ctx, doc, text);
679
717
  }
680
718
  if (values['from-this-computer'] !== true)
681
719
  throw new UsageError('shard machine setup needs --from-this-computer', 'shard machine setup --from-this-computer');
@@ -687,8 +725,10 @@ export async function machineSetup(ctx, values, deps = {}) {
687
725
  const startRec = { state: 'running', runner: null, pid: process.pid, started_at: new Date(started).toISOString(), finished_at: null, machine_version: null,
688
726
  line: workingLine('claude'), summary: null, error: null, duration_ms: null };
689
727
  const claim = claimRun(store, ctx.apiUrl, org, values['if-needed'] === true, startRec);
690
- if (!claim.claimed)
691
- return output(ctx, { ...statusDoc(claim.record, machine), skipped: true }, claim.record.state === 'running' ? `${claim.record.line} (already running)` : claim.record.line);
728
+ if (!claim.claimed) {
729
+ const { doc, text } = await status();
730
+ return output(ctx, { ...doc, skipped: true }, claim.record.state === 'running' ? `${text} (already running)` : text);
731
+ }
692
732
  const phase = phaseWriter(ctx);
693
733
  const steps = stepLog(ctx);
694
734
  let runner = null;
@@ -705,10 +745,16 @@ export async function machineSetup(ctx, values, deps = {}) {
705
745
  phase('machine', workingLine(runner));
706
746
  const inventory = await (deps.inventory ?? collectInventory)(env);
707
747
  const { state, ws } = await (deps.open ?? ((c, s) => openMachine(c, s)))(ctx, machine);
748
+ const capacity = setupCapacity(ws);
749
+ if (capacity) {
750
+ const line = `Cloud machine disk: ${capacity.disk_gib} GiB${capacity.source === 'plan' ? ' (plan limit)' : ''}; checking which tools fit…`;
751
+ phase('capacity', line);
752
+ steps.log(line);
753
+ }
708
754
  // A machine saved before (from another computer, or by hand) gets this computer's Claude and Codex config too;
709
755
  // openMachine copies it into a first machine itself.
710
756
  if (state.version > 0)
711
- await syncAgentConfig(ctx, ws).catch((error) => steps.log(`config copy: ${error instanceof Error ? error.message : String(error)}`));
757
+ await syncAgentConfig(ctx, ws);
712
758
  let outcome;
713
759
  let spent = 0;
714
760
  for (const candidate of chain) {
@@ -725,16 +771,16 @@ export async function machineSetup(ctx, values, deps = {}) {
725
771
  const began = Date.now();
726
772
  try {
727
773
  outcome = runner === 'cloud'
728
- ? await runCloud(ctx, ws, setupTask('vm', ws.key, inventory), box)
774
+ ? await runCloud(ctx, ws, setupTask('vm', ws.key, inventory, capacity), box)
729
775
  : runner === 'claude'
730
- ? await runClaudeLocal(ctx, candidate.agent, ws.key, setupTask('local', ws.key, inventory), box, steps.log)
731
- : await runCodexLocal(ctx, candidate.agent, ws.key, setupTask('local', ws.key, inventory), box, steps.log);
776
+ ? await runClaudeLocal(ctx, candidate.agent, ws.key, setupTask('local', ws.key, inventory, capacity), box, steps.log)
777
+ : await runCodexLocal(ctx, candidate.agent, ws.key, setupTask('local', ws.key, inventory, capacity), box, steps.log);
732
778
  attempts.push({ runner, ok: true, error: null, duration_ms: Date.now() - began });
733
779
  break;
734
780
  }
735
781
  catch (error) {
736
782
  // Only the agent's own failures move on to the next agent; the API, the network or an interrupt stop here.
737
- if (!(error instanceof MachineSetupError) || ctx.signal.aborted)
783
+ if (error instanceof SetupPermissionRefusal || !(error instanceof MachineSetupError) || ctx.signal.aborted)
738
784
  throw error;
739
785
  attempts.push({ runner, ok: false, error: error.message, duration_ms: Date.now() - began });
740
786
  steps.log(`agent ${runner} stopped: ${error.message}`);
@@ -751,10 +797,11 @@ export async function machineSetup(ctx, values, deps = {}) {
751
797
  const skills = countSkills(env.HOME || homedir());
752
798
  const line = readyLine(outcome.summary, skills);
753
799
  const rec = { state: 'ready', runner, pid: null, started_at: startRec.started_at, finished_at: new Date().toISOString(), machine_version: saved.version,
754
- line, summary: { ...(outcome.summary ?? { tools: [], runtimes: [], shell: null, dotfiles: [], skipped: [] }), skills, runner }, attempts, error: null, duration_ms: Date.now() - started };
800
+ line, summary: { ...outcome.summary, skills, runner }, attempts, error: null, duration_ms: Date.now() - started };
755
801
  writeRecord(store, ctx.apiUrl, org, rec);
756
802
  phase('ready', line);
757
- return output(ctx, { ...rec, workspace_key: ws.key }, line);
803
+ const row = machineRow(saved, rec);
804
+ return output(ctx, { ...statusDoc(row, rec), workspace_key: ws.key }, rowText(row));
758
805
  }
759
806
  catch (error) {
760
807
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,67 @@
1
+ import type { AgentFilesystem } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ import { type BuildQueue } from './filesystem-commands.js';
4
+ import { type SaveFailure } from './errors.js';
5
+ import { type SetupRecord } from './machine-setup.js';
6
+ export type MachineRowState = 'ready' | 'saving' | 'failed' | 'none';
7
+ /** too_large and timeout: the save's build; save_failed: any other build failure; setup_stopped: this computer's agent. */
8
+ export type MachineFailure = 'too_large' | 'timeout' | 'save_failed' | 'setup_stopped';
9
+ export interface MachineAction {
10
+ id: 'save' | 'retry' | 'setup';
11
+ title: 'Save again' | 'Retry' | 'Set up';
12
+ command: string[];
13
+ }
14
+ export interface MachineRow {
15
+ /** unknown: the check or response could not establish readiness; unavailable: the API has no machine route. */
16
+ state: MachineRowState | 'unknown' | 'unavailable';
17
+ /** The saved version in use; 0 when none is, null when the version could not be read. */
18
+ version: number | null;
19
+ /** One plain line of at most 80 characters. */
20
+ line: string;
21
+ reason?: MachineFailure;
22
+ /** The failure's full message. */
23
+ error?: string;
24
+ /** saving: the setup's agent (setup) or the save's build (save), with its version and place in the queue. */
25
+ progress?: {
26
+ phase: 'setup' | 'save';
27
+ version?: number;
28
+ queue?: BuildQueue;
29
+ };
30
+ action?: MachineAction;
31
+ }
32
+ /** `text` cut so that it and `suffix` fit one 80-character line. */
33
+ export declare function fitLine(text: string, suffix?: string): string;
34
+ /**
35
+ * A build failure as people say it: too large, out of time, or the build's own first sentence. The failure's code
36
+ * (0.21.1+, from an API that reports it) decides first: resource_limit is too large, named with the plan's disk when it
37
+ * is that limit.
38
+ */
39
+ export declare function saveFailure(message: string, failure?: SaveFailure): {
40
+ reason: MachineFailure;
41
+ why: string;
42
+ };
43
+ /** What `machineRow` reads besides the machine: whether the failed save's VM is still there, and a build's queue. */
44
+ export interface MachineRowFacts {
45
+ saveSourceExists?: boolean;
46
+ queue?: BuildQueue;
47
+ }
48
+ /** The row, from the API's machine and this computer's setup record (pure: the facts are read by `readMachineRow`). */
49
+ export declare function machineRow(machine: AgentFilesystem, rec: SetupRecord | undefined, facts?: MachineRowFacts): MachineRow;
50
+ /**
51
+ * The row for this account and computer: the machine (one API call), the setup record, and, only when they matter,
52
+ * whether a failed save's VM is still there and a saving build's queue. `machine` skips the first call when the caller
53
+ * has it.
54
+ */
55
+ export declare function readMachineRow(ctx: Ctx, machine?: AgentFilesystem): Promise<MachineRow>;
56
+ /**
57
+ * `setup --inspect`'s row: an API without machines reports unavailable; a check that fails says so on the row
58
+ * (state unknown) and never fails the report.
59
+ */
60
+ export declare function inspectMachineRow(ctx: Ctx): Promise<MachineRow | null>;
61
+ /**
62
+ * `machine setup --status` and a setup's result: this computer's record (runner, summary, attempts…) with the row's
63
+ * state and line. Saving reads as running for older apps; unknown and unavailable remain explicit.
64
+ */
65
+ export declare function statusDoc(row: MachineRow, rec: SetupRecord | undefined): Record<string, unknown>;
66
+ /** The row as the terminal shows it: its line, and for a failure or none the command that recovers it. */
67
+ export declare function rowText(row: MachineRow): string;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The account's cloud machine as one row (0.20.2+), the one source of truth that `shard machine setup --status`, `shard
3
+ * setup --inspect` (onboarding.machine) and a setup run's result report, and that the menu bar, the Linux app and the
4
+ * terminal onboarding show. The API's machine (GET /v1/machine: what is saved, saving or failed) decides; this
5
+ * computer's setup record (agents.json) adds only what the API cannot know: its agent is setting the machine up right
6
+ * now, or its last setup stopped before anything was saved.
7
+ *
8
+ * - ready: a version is saved and in use. saving: the setup's agent works, or a save builds (with its place in the
9
+ * queue). failed: the newest save failed (its reason: too large, out of time, or the build's own message), or this
10
+ * computer's setup stopped and no version was saved since it started. none: nothing saved and nothing under way.
11
+ * - A failed row has one plain line and one recovery: Save again (`shard machine save --wait`) when the save failed and
12
+ * its machine VM is still there, else Retry (`shard machine setup --from-this-computer`).
13
+ */
14
+ import { ShardfluxApiError, saveProgressText } from '@shardflux/sdk';
15
+ import { AgentStateStore } from "./agent-state.js";
16
+ import { machineSaveSource, pendingQueue } from "./filesystem-commands.js";
17
+ import { gib, planDiskLimit } from "./errors.js";
18
+ import { readRecord, readyLine, workingLine } from "./machine-setup.js";
19
+ const LIMIT = 80;
20
+ /** `text` cut so that it and `suffix` fit one 80-character line. */
21
+ export function fitLine(text, suffix = '') {
22
+ const flat = text.replace(/\s+/g, ' ').trim(), room = LIMIT - suffix.length;
23
+ return (flat.length <= room ? flat : `${flat.slice(0, room - 1).trimEnd()}…`) + suffix;
24
+ }
25
+ /**
26
+ * A build failure as people say it: too large, out of time, or the build's own first sentence. The failure's code
27
+ * (0.21.1+, from an API that reports it) decides first: resource_limit is too large, named with the plan's disk when it
28
+ * is that limit.
29
+ */
30
+ export function saveFailure(message, failure) {
31
+ const disk = planDiskLimit(failure);
32
+ if (disk)
33
+ return { reason: 'too_large', why: `over the plan’s ${gib(disk.limit)} disk` };
34
+ if (failure?.code === 'resource_limit')
35
+ return { reason: 'too_large', why: 'it’s too large' };
36
+ if (failure?.code === 'timeout')
37
+ return { reason: 'timeout', why: 'it ran out of time' };
38
+ if (/too[ _]large|exceed\w* (the |its )?(\w+ )?(max|size|limit|quota)|max_\w*bytes|disk (is )?full|no space|insufficient_disk|quota_exceeded|above the .*(GiB|MiB)/i.test(message))
39
+ return { reason: 'too_large', why: 'it’s too large' };
40
+ if (/exceeded its \d+ ?(second|s)\b|time ?out|timed out|ran past|took too long|deadline/i.test(message))
41
+ return { reason: 'timeout', why: 'it ran out of time' };
42
+ const first = message.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s/)[0]?.replace(/[.!?]$/, '') ?? '';
43
+ // The API's own fallback ("Filesystem save failed. Open its build status…") adds nothing to "not saved".
44
+ return { reason: 'save_failed', why: /^(filesystem )?save failed$/i.test(first) ? '' : first };
45
+ }
46
+ const SAVE_AGAIN = { id: 'save', title: 'Save again', command: ['shard', 'machine', 'save', '--wait'] };
47
+ const RETRY = { id: 'retry', title: 'Retry', command: ['shard', 'machine', 'setup', '--from-this-computer'] };
48
+ const SET_UP = { id: 'setup', title: 'Set up', command: ['shard', 'machine', 'setup', '--from-this-computer'] };
49
+ /** When the newest saved version was made (0 when none is). */
50
+ const savedAt = (m) => Math.max(0, ...m.history.map(h => Date.parse(h.created_at)).filter(Number.isFinite));
51
+ const inUse = (version) => (version > 0 ? ` · version ${version} stays in use` : ' · no saved version');
52
+ /** The row, from the API's machine and this computer's setup record (pure: the facts are read by `readMachineRow`). */
53
+ export function machineRow(machine, rec, facts = {}) {
54
+ const version = machine.version;
55
+ if (!Number.isSafeInteger(version) || version < 0)
56
+ return { state: 'unknown', version: null, line: 'Cloud machine readiness could not be confirmed: saved version unavailable' };
57
+ if (machine.state === 'building' && machine.pending) {
58
+ const q = facts.queue;
59
+ return { state: 'saving', version, line: fitLine(q ? `Saving machine v${machine.pending.version} · ${saveProgressText({ version: machine.pending.version, build_id: machine.pending.build_id, state: 'queued', queue: q })}` : `Saving machine v${machine.pending.version}…`, inUse(version)),
60
+ progress: { phase: 'save', version: machine.pending.version, ...(q ? { queue: q } : {}) } };
61
+ }
62
+ if (machine.state === 'building')
63
+ return { state: 'unknown', version, line: 'Cloud machine is saving; build progress is unavailable' };
64
+ if (rec?.state === 'running') {
65
+ return { state: 'saving', version, line: fitLine(rec.line || workingLine(rec.runner ?? 'claude')), progress: { phase: 'setup' } };
66
+ }
67
+ if (machine.state === 'failed') {
68
+ const message = machine.failure?.message || 'The API reported a failed save without build details.';
69
+ const { reason, why } = saveFailure(message, machine.failure);
70
+ return { state: 'failed', version, line: fitLine(`Cloud machine not saved${why ? `: ${why}` : ''}`, inUse(version)), reason, error: message,
71
+ action: facts.saveSourceExists === false ? RETRY : SAVE_AGAIN };
72
+ }
73
+ // A setup that stopped counts until a version is saved after it started (by its own save that outlived its wait, by
74
+ // Save again, or by hand).
75
+ const started = Date.parse(rec?.started_at ?? '');
76
+ if (rec?.state === 'failed' && (version === 0 || (Number.isFinite(started) && started > savedAt(machine)))) {
77
+ const error = rec.error ?? 'it didn’t finish';
78
+ return { state: 'failed', version, line: fitLine(`Cloud machine setup stopped: ${error}`, inUse(version)), reason: 'setup_stopped', error, action: RETRY };
79
+ }
80
+ if (machine.state === 'ready' && version > 0) {
81
+ const mine = rec?.state === 'ready' && rec.machine_version === version;
82
+ const line = mine ? (rec.line || (rec.summary ? readyLine(rec.summary, rec.summary.skills) : '')) : '';
83
+ return { state: 'ready', version, line: line ? fitLine(line, ` · version ${version}`) : `Cloud machine ready (version ${version})` };
84
+ }
85
+ if (machine.state !== 'empty' || version !== 0)
86
+ return { state: 'unknown', version, line: 'Cloud machine readiness could not be confirmed from this API response' };
87
+ return { state: 'none', version: 0, line: 'Cloud machine not set up yet', action: SET_UP };
88
+ }
89
+ /**
90
+ * The row for this account and computer: the machine (one API call), the setup record, and, only when they matter,
91
+ * whether a failed save's VM is still there and a saving build's queue. `machine` skips the first call when the caller
92
+ * has it.
93
+ */
94
+ export async function readMachineRow(ctx, machine) {
95
+ const cloud = ctx.cloud();
96
+ machine ??= await cloud.machine.get({ signal: ctx.signal });
97
+ const rec = readRecord(new AgentStateStore(ctx.store.dir), ctx.apiUrl, machine.organization_id);
98
+ const facts = {};
99
+ if (machine.state === 'building' && machine.pending)
100
+ facts.queue = await pendingQueue(ctx, machine);
101
+ if (machine.state === 'failed' && machine.failure)
102
+ facts.saveSourceExists = await saveSourceExists(ctx, machine);
103
+ return machineRow(machine, rec, facts);
104
+ }
105
+ /** The VM `shard machine save` saves (machineSaveSource): Save again needs it; without it the setup runs again. */
106
+ async function saveSourceExists(ctx, machine) {
107
+ try {
108
+ return !!(await machineSaveSource(ctx, machine));
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ /**
115
+ * `setup --inspect`'s row: an API without machines reports unavailable; a check that fails says so on the row
116
+ * (state unknown) and never fails the report.
117
+ */
118
+ export async function inspectMachineRow(ctx) {
119
+ try {
120
+ return await readMachineRow(ctx);
121
+ }
122
+ catch (error) {
123
+ if (error instanceof ShardfluxApiError && (error.status === 404 || error.details?.reason === 'filesystem_unavailable'))
124
+ return { state: 'unavailable', version: null, line: 'This API does not report cloud machine readiness; update the server' };
125
+ return { state: 'unknown', version: null, line: fitLine(`Couldn’t check your cloud machine: ${error instanceof Error ? error.message : String(error)}`) };
126
+ }
127
+ }
128
+ /**
129
+ * `machine setup --status` and a setup's result: this computer's record (runner, summary, attempts…) with the row's
130
+ * state and line. Saving reads as running for older apps; unknown and unavailable remain explicit.
131
+ */
132
+ export function statusDoc(row, rec) {
133
+ const state = row.state === 'saving' ? 'running' : row.state;
134
+ return { ...(rec ?? {}), state, line: state === 'none' ? null : row.line, error: row.state === 'failed' ? row.error ?? null : null,
135
+ machine_version: row.version, machine: row };
136
+ }
137
+ /** The row as the terminal shows it: its line, and for a failure or none the command that recovers it. */
138
+ export function rowText(row) {
139
+ return row.action ? `${row.line}. ${row.action.title}: ${row.action.command.join(' ')}` : row.line;
140
+ }
package/dist/main.js CHANGED
@@ -10,10 +10,9 @@
10
10
  * (step-up, password change, TOTP change) is saved at once; a saved session the API rejects (401) is removed.
11
11
  * Every credential the command loaded is redacted from what it prints on failure.
12
12
  */
13
- import { aboutWait } from "./filesystem-commands.js";
14
13
  import { homedir, hostname } from 'node:os';
15
14
  import { statSync } from 'node:fs';
16
- import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
15
+ import { SDK_VERSION, Shardflux, ShardfluxAccount, aboutSeconds, isSessionToken } from '@shardflux/sdk';
17
16
  import { CLI_VERSION, CENTER_COMMAND, GUIDE_COMMAND, ONBOARDING_COMMAND, PROJECT_COMMAND, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
18
17
  import { commandCenter } from "./command-center.js";
19
18
  import { launchProjectCommand } from "./conversation-terminal.js";
@@ -73,10 +72,8 @@ function hintFor(info, cred, file, command = '') {
73
72
  return `keep waiting with: shard operations wait ${info.operation_id}`;
74
73
  if (info.code === 'usage')
75
74
  return 'run "shard --help" or "shard <command> --help"';
76
- if (info.code === 'filesystem_save_failed') {
77
- const level = info.details?.level === 'template' ? 'template' : 'machine';
78
- return `the version in use is unchanged; fix what the message names in ${level === 'machine' ? 'shard machine' : 'the project agent'}, then run shard ${level} save --wait again`;
79
- }
75
+ if (info.code === 'filesystem_save_failed')
76
+ return info.fix;
80
77
  if (info.source === 'cell' && info.code === 'workspace_not_running')
81
78
  return 'open or resume the workspace first: shard workspaces open <key> --template <slug>';
82
79
  if (info.status === 401 && info.code === 'unauthenticated') {
@@ -118,7 +115,7 @@ function hintFor(info, cred, file, command = '') {
118
115
  if (info.code === 'rate_limited' && reasonOf(info) === 'template_build_queue_full') {
119
116
  const queued = typeof info.details?.queued === 'number' ? info.details.queued : undefined;
120
117
  const retry = info.retry_after_seconds ?? (typeof info.details?.retry_after_seconds === 'number' ? info.details.retry_after_seconds : undefined);
121
- return `${queued ?? 'Several'} build${queued === 1 ? '' : 's'} already wait for your plan's build slots; nothing was started. Run the same command again${retry ? ` in ${aboutWait(retry)}` : ' when one finishes'}`;
118
+ return `${queued ?? 'Several'} build${queued === 1 ? '' : 's'} already wait for your plan's build slots; nothing was started. Run the same command again${retry ? ` in ${aboutSeconds(retry)}` : ' when one finishes'}`;
122
119
  }
123
120
  const policyHint = spendPolicyHint(info);
124
121
  if (policyHint)
@@ -351,6 +348,8 @@ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
351
348
  // names the next step (upgrade, overage, the cap, the payment method), not a problem to report.
352
349
  const NO_NUDGE_CODES = new Set(['usage', 'terms_not_accepted', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended', 'allowance_exhausted']);
353
350
  const NO_NUDGE_REASONS = new Set([
351
+ // shard template delete with nothing saved (0.20.1+): the hint names the next step.
352
+ 'filesystem_not_found',
354
353
  'revision_mismatch',
355
354
  'tree_revision_mismatch',
356
355
  'edit_not_found',
@@ -383,6 +382,9 @@ function nudges(info, command) {
383
382
  // A plan limit whose hint names the next step (0.9.0): busy working slots, or Retained state used up.
384
383
  if (info.code === 'quota_exceeded' && quotaHint(info) !== undefined)
385
384
  return false;
385
+ // A save the same VM cannot pass (0.21.1+), such as one larger than the plan's workspace disk: the hint names the fix.
386
+ if (info.code === 'filesystem_save_failed' && info.retryable === false)
387
+ return false;
386
388
  const reason = reasonOf(info);
387
389
  return !NO_NUDGE_CODES.has(info.code) && !(reason !== undefined && NO_NUDGE_REASONS.has(reason));
388
390
  }
@@ -474,12 +476,8 @@ export async function run(argv, io, opts = {}) {
474
476
  io.stdout.write(wantsJson ? json({ cli: CLI_VERSION, sdk: SDK_VERSION }) : `shard ${CLI_VERSION} (@shardflux/sdk ${SDK_VERSION})\n`);
475
477
  return EXIT.ok;
476
478
  }
477
- // shard ./ attaches Claude Code or Codex to this terminal: without one (or in a coding agent's shell) it says how to
478
- // start the conversation instead of starting one nobody can see (0.17.1+: a usage error, before 0.17.1 its help).
479
- if (parsed.command === PROJECT_COMMAND && !tty && !parsed.global.json) {
480
- await startupHousekeeping({ io, store, signal });
481
- return report(new UsageError('shard ./ opens Claude Code or Codex in a terminal, and this shell has none.', 'ask your user to run shard ./ in their own terminal, or start the conversation detached: shard ./ --json --prompt "<task>" (shard ./ codex --json ... for Codex)'));
482
- }
479
+ // shard ./ attaches Claude Code or Codex to this terminal. Without one (or in a coding agent's shell) it opens in the
480
+ // person's chosen app (0.20.1+: a new tab of their terminal app, see launchProjectCommand), never one nobody can see.
483
481
  command = parsed.command.path.join(' ');
484
482
  let ctx;
485
483
  let updates = null;
package/dist/menubar.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { Handler } from './commands.js';
2
- export type NativeRunner = (tool: string, args: string[]) => Promise<string>;
2
+ /** `env` adds variables to this process's environment for the tool (the app's --control reads its config directory). */
3
+ export type NativeRunner = (tool: string, args: string[], env?: Record<string, string>) => Promise<string>;
3
4
  /**
4
5
  * Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
5
6
  * `nativeTools.run`. The CLI test support replaces it, so a test run never starts, stops or inspects a real app,
6
7
  * whether or not a built Shardflux.app is in the package.
7
8
  */
8
9
  export declare const nativeTools: {
9
- run: (tool: string, args: string[], signal?: AbortSignal) => Promise<string>;
10
+ run: (tool: string, args: string[], signal?: AbortSignal, env?: Record<string, string>) => Promise<string>;
10
11
  };
11
12
  export interface MenubarStatus {
12
13
  installed: boolean;
@@ -57,9 +58,9 @@ export declare class MenubarManager {
57
58
  packagedVersion(): Promise<string>;
58
59
  /** Whether the app shows any window (see windowsOpen); null when that could not be read. */
59
60
  windowsOpen(pids: number[]): Promise<boolean | null>;
60
- status(): Promise<MenubarStatus>;
61
- stop(): Promise<MenubarStatus>;
62
- login(value: 'on' | 'off'): Promise<MenubarStatus>;
61
+ status(configDir?: string): Promise<MenubarStatus>;
62
+ stop(configDir?: string): Promise<MenubarStatus>;
63
+ login(value: 'on' | 'off', configDir?: string): Promise<MenubarStatus>;
63
64
  start(configDir: string, apiUrl: string): Promise<MenubarStatus>;
64
65
  /**
65
66
  * Brings an installed app up to the one this CLI's npm package carries, without disturbing the person: nothing when