@shardflux/cli 0.19.0 → 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.
@@ -0,0 +1,802 @@
1
+ /**
2
+ * `shard machine setup --from-this-computer` (0.20.0+): the person's own coding agent sets up their cloud machine from
3
+ * what is on this computer, with no question asked. The menu bar and the Linux app run it right after onboarding's
4
+ * Agents step (and from Settings); the terminal onboarding starts it in the background.
5
+ *
6
+ * - Runner: this computer's Claude Code when it is installed, signed in and new enough for a restricted run (`claude
7
+ * -p`), else its Codex (`codex exec` in a workspace-write sandbox confined to a private temporary folder), else the
8
+ * account's cloud Claude login inside the machine VM, given a non-secret inventory of this computer.
9
+ * - On this computer a local agent may only read (Read and Glob with the secret folders denied, and a fixed list of
10
+ * read-only commands) and run `shard` against the machine VM. `SHARDFLUX_MACHINE_SETUP_VM` makes every `shard` it
11
+ * runs refuse anything but exec, file writes from standard input and reads on that one VM.
12
+ * - When an agent stops for a reason the next would not hit, the next one runs (0.20.0: local Claude Code → local
13
+ * Codex → cloud Claude); Retry is offered only when every one stopped. `attempts` records each.
14
+ * - Each agent is time boxed (10 minutes by default, all together twice that); shard then copies the Claude and Codex config as every machine
15
+ * gets it, saves the machine and records the result per API and organization in agents.json, so it never runs twice
16
+ * by accident (`--if-needed`).
17
+ * - Progress goes to the launchers' SHARDFLUX_PROGRESS_FILE as one-line `{phase, line}` documents.
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import { accessSync, constants, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, chmodSync, mkdirSync, openSync, closeSync } from 'node:fs';
21
+ import { homedir, platform as osPlatform, release, tmpdir, arch } from 'node:os';
22
+ import { delimiter, join } from 'node:path';
23
+ import { AgentStateStore } from "./agent-state.js";
24
+ import { syncAgentConfig } from "./agent-config-sync.js";
25
+ import { cloudClaudeLogin } from "./claude-cloud-login.js";
26
+ import { MachineSetupError, UsageError } from "./errors.js";
27
+ import { openMachine } from "./filesystem-commands.js";
28
+ import { installDirs } from "./onboarding.js";
29
+ import { progress as stderrProgress } from "./progress.js";
30
+ import { writeProgress } from "./seed-phases.js";
31
+ import { parseDuration } from "./args.js";
32
+ /** Set for the local agent's processes: the `shard` it runs only reaches this machine VM (see `setupGuard`). */
33
+ export const SETUP_VM_ENV = 'SHARDFLUX_MACHINE_SETUP_VM';
34
+ export const DEFAULT_AGENT_TIMEOUT_MS = 10 * 60_000;
35
+ const SAVE_TIMEOUT_MS = 10 * 60_000;
36
+ /**
37
+ * Runs argv without a shell in its own process group; past the time box (or on abort) the whole group gets SIGTERM, then
38
+ * SIGKILL 5 s later. Keeps the last `maxBytes` of each stream. Never throws for a non-zero exit or a missing program.
39
+ */
40
+ export function runChild(argv, o) {
41
+ return new Promise(resolve => {
42
+ const max = o.maxBytes ?? 1024 * 1024;
43
+ let stdout = '', stderr = '', timedOut = false, partial = '', settled = false;
44
+ let child;
45
+ try {
46
+ child = spawn(argv[0], argv.slice(1), { cwd: o.cwd, env: o.env, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
47
+ }
48
+ catch (error) {
49
+ resolve({ code: null, signal: null, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false });
50
+ return;
51
+ }
52
+ const kill = (sig) => { try {
53
+ if (child.pid)
54
+ process.kill(process.platform === 'win32' ? child.pid : -child.pid, sig);
55
+ }
56
+ catch { /* already gone */ } };
57
+ let hard;
58
+ const stop = () => { kill('SIGTERM'); hard = setTimeout(() => kill('SIGKILL'), 5000); hard.unref?.(); };
59
+ const timer = setTimeout(() => { timedOut = true; stop(); }, o.timeoutMs);
60
+ const abort = () => stop();
61
+ o.signal?.addEventListener('abort', abort, { once: true });
62
+ child.stdout.on('data', (chunk) => {
63
+ const text = chunk.toString('utf8');
64
+ stdout = (stdout + text).slice(-max);
65
+ if (!o.onLine)
66
+ return;
67
+ partial += text;
68
+ const lines = partial.split('\n');
69
+ partial = lines.pop() ?? '';
70
+ for (const line of lines)
71
+ if (line.trim())
72
+ o.onLine(line);
73
+ });
74
+ child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString('utf8')).slice(-65536); });
75
+ child.stdin.on('error', () => undefined);
76
+ child.stdin.end(o.input ?? '');
77
+ const done = (code, signal) => {
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ clearTimeout(timer);
82
+ if (hard && !timedOut)
83
+ clearTimeout(hard);
84
+ o.signal?.removeEventListener('abort', abort);
85
+ if (partial.trim() && o.onLine)
86
+ o.onLine(partial);
87
+ resolve({ code, signal, stdout, stderr, timedOut });
88
+ };
89
+ child.on('error', error => { stderr += error.message; done(null, null); });
90
+ child.on('close', done);
91
+ });
92
+ }
93
+ /** The first executable `name` on PATH or in the usual install folders (a menu bar app's PATH is short). */
94
+ export function findExecutable(name, env) {
95
+ const home = env.HOME || homedir();
96
+ const dirs = [...(env.PATH ?? '').split(delimiter).filter(Boolean), ...installDirs(home), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'];
97
+ for (const dir of dirs) {
98
+ const file = join(dir, name);
99
+ try {
100
+ accessSync(file, constants.X_OK);
101
+ if (statSync(file).isFile())
102
+ return file;
103
+ }
104
+ catch { /* next */ }
105
+ }
106
+ return undefined;
107
+ }
108
+ /** Flags a restricted `claude -p` run needs (checked against `claude --help` of the installed version). */
109
+ export const CLAUDE_REQUIRED_FLAGS = ['--print', '--tools', '--allowedTools', '--disallowedTools', '--permission-mode', 'dontAsk', '--strict-mcp-config', '--setting-sources', '--output-format', 'stream-json'];
110
+ /** Flags `codex exec` needs for a sandboxed run (checked against `codex exec --help`). */
111
+ export const CODEX_REQUIRED_FLAGS = ['--sandbox', 'workspace-write', '--skip-git-repo-check', '--json', '--cd'];
112
+ function firstLine(text) { return text.trim().split('\n')[0]?.trim() ?? ''; }
113
+ export async function inspectClaude(env) {
114
+ const bin = findExecutable('claude', env);
115
+ if (!bin)
116
+ return undefined;
117
+ const opts = { env, timeoutMs: 15_000, maxBytes: 256 * 1024 };
118
+ const [version, help] = await Promise.all([runChild([bin, '--version'], opts), runChild([bin, '--help'], opts)]);
119
+ const missing = CLAUDE_REQUIRED_FLAGS.filter(flag => !help.stdout.includes(flag));
120
+ const agent = { app: 'claude', bin, version: firstLine(version.stdout), help: help.stdout, signedIn: false, usable: false };
121
+ if (version.code !== 0)
122
+ return { ...agent, reason: 'claude --version failed' };
123
+ if (missing.length)
124
+ return { ...agent, reason: `Claude Code ${agent.version} has no ${missing.join(', ')}; update it for a restricted run` };
125
+ const status = await runChild([bin, 'auth', 'status'], opts);
126
+ try {
127
+ agent.signedIn = JSON.parse(status.stdout).loggedIn === true;
128
+ }
129
+ catch {
130
+ agent.signedIn = false;
131
+ }
132
+ return agent.signedIn ? { ...agent, usable: true } : { ...agent, reason: 'Claude Code is not signed in on this computer' };
133
+ }
134
+ export async function inspectCodex(env) {
135
+ const bin = findExecutable('codex', env);
136
+ if (!bin)
137
+ return undefined;
138
+ const opts = { env, timeoutMs: 15_000, maxBytes: 256 * 1024 };
139
+ const [version, help] = await Promise.all([runChild([bin, '--version'], opts), runChild([bin, 'exec', '--help'], opts)]);
140
+ const missing = CODEX_REQUIRED_FLAGS.filter(flag => !help.stdout.includes(flag));
141
+ const agent = { app: 'codex', bin, version: firstLine(version.stdout), help: help.stdout, signedIn: false, usable: false };
142
+ if (version.code !== 0)
143
+ return { ...agent, reason: 'codex --version failed' };
144
+ if (missing.length)
145
+ return { ...agent, reason: `${agent.version} has no ${missing.join(', ')}; update Codex for a sandboxed run` };
146
+ agent.signedIn = (await runChild([bin, 'login', 'status'], opts)).code === 0;
147
+ return agent.signedIn ? { ...agent, usable: true } : { ...agent, reason: 'Codex is not signed in on this computer' };
148
+ }
149
+ /**
150
+ * The agents to try, in order: this computer's Claude Code, then its Codex, then the account's cloud Claude in the
151
+ * machine VM. When one stops for a reason the next would not hit (a usage limit, a sign-in that expired, a sandbox that
152
+ * keeps shard from the VM, a crash, its time box), the next one runs. `--agent` names exactly one.
153
+ */
154
+ export function runnerChain(claude, codex, forced) {
155
+ const skipped = [claude, codex].filter((a) => !!a && !a.usable).map(a => a.reason ?? `${a.app} is not usable`);
156
+ if (forced === 'claude' || forced === 'codex') {
157
+ const agent = forced === 'claude' ? claude : codex;
158
+ if (!agent)
159
+ throw new UsageError(`${forced === 'claude' ? 'Claude Code' : 'Codex'} is not installed on this computer`);
160
+ if (!agent.usable)
161
+ throw new UsageError(agent.reason ?? `${forced} cannot run here`);
162
+ return { chain: [{ runner: forced, agent }], skipped: [] };
163
+ }
164
+ if (forced === 'cloud')
165
+ return { chain: [{ runner: 'cloud' }], skipped };
166
+ const local = [claude, codex].filter((a) => !!a?.usable).map(agent => ({ runner: agent.app, agent }));
167
+ return { chain: [...local, { runner: 'cloud' }], skipped };
168
+ }
169
+ /** Every agent gets its own time box, and all of them together at most twice it: the apps wait 30 minutes. */
170
+ export const AGENT_BUDGET_FACTOR = 2;
171
+ const MIN_ATTEMPT_MS = 60_000;
172
+ export function attemptTimeout(perAgentMs, spentMs) {
173
+ const left = perAgentMs * AGENT_BUDGET_FACTOR - spentMs;
174
+ const box = Math.min(perAgentMs, left);
175
+ return box >= Math.min(MIN_ATTEMPT_MS, perAgentMs) ? box : undefined;
176
+ }
177
+ // ---------------------------------------------------------------------------------------------------------------------
178
+ // Tool restrictions
179
+ /** Read-only commands a local Claude may run on this computer (each a prefix rule). No cat: file reads go through Read, where the secret paths are denied. */
180
+ export const READ_ONLY_COMMANDS = [
181
+ 'brew list', 'brew leaves', 'brew info', 'brew --prefix', 'brew --version', 'ls', 'which', 'command -v', 'uname', 'sw_vers',
182
+ 'git config --global --get', 'git config --global --list', 'git config --get', 'git config --list', 'npm ls -g', 'npm list -g', 'pnpm ls -g',
183
+ 'pipx list', 'uv tool list', 'gem list', 'cargo install --list', 'gh extension list', 'asdf current', 'asdf list', 'mise ls', 'mise current',
184
+ 'pyenv versions', 'rbenv versions', 'fnm list', 'volta list',
185
+ ];
186
+ /** Tools whose `--version` (or `version`) a local Claude may print. */
187
+ export const VERSION_TOOLS = ['node', 'npm', 'pnpm', 'yarn', 'bun', 'deno', 'python3', 'python', 'pip3', 'uv', 'go', 'rustc', 'cargo', 'ruby', 'java',
188
+ 'php', 'dotnet', 'git', 'gh', 'docker', 'kubectl', 'terraform', 'aws', 'gcloud', 'zsh', 'bash', 'fish', 'tmux', 'nvim', 'vim', 'starship', 'mise', 'asdf'];
189
+ /** Never read on this computer, whatever the agent decides: keys, tokens, cloud and package-registry credentials, keychains, browser and app data. */
190
+ export const SECRET_PATHS = [
191
+ '~/.ssh/**', '~/.aws/**', '~/.azure/**', '~/.config/gcloud/**', '~/.kube/**', '~/.docker/**', '~/.gnupg/**', '~/.password-store/**',
192
+ '~/Library/**', '~/.local/share/keyrings/**', '~/.config/op/**', '~/.config/gh/**', '~/.config/hub', '~/.config/shardflux/**', '~/.netrc',
193
+ '~/.git-credentials', '~/.npmrc', '~/.yarnrc.yml', '~/.pypirc', '~/.gem/credentials', '~/.cargo/credentials', '~/.cargo/credentials.toml',
194
+ '~/.vault-token', '~/.terraform.d/**', '~/.claude.json', '~/.claude/.credentials.json', '~/.codex/auth.json', '~/.mozilla/**', '~/.config/google-chrome/**',
195
+ '//**/.env', '//**/.env.*', '//**/*.pem', '//**/*.key', '//**/*.p12', '//**/*.pfx', '//**/id_rsa*', '//**/id_ed25519*', '//**/id_ecdsa*', '//**/*credentials*', '//**/*secret*', '//**/*token*',
196
+ ];
197
+ /** `claude` argv for the restricted run (the task goes on standard input). `help` is the installed version's `--help`. */
198
+ export function claudeArgs(key, help) {
199
+ const allow = [
200
+ 'Read', 'Glob',
201
+ ...READ_ONLY_COMMANDS.map(c => `Bash(${c}:*)`),
202
+ ...VERSION_TOOLS.flatMap(t => [`Bash(${t} --version)`, `Bash(${t} -V)`, `Bash(${t} version)`]),
203
+ `Bash(shard ws exec ${key}:*)`, `Bash(shard files write ${key}:*)`, `Bash(shard files read ${key}:*)`, `Bash(shard files ls ${key}:*)`,
204
+ ];
205
+ const deny = [...SECRET_PATHS.map(p => `Read(${p})`), 'Edit', 'Write', 'NotebookEdit', 'WebFetch', 'WebSearch'];
206
+ return [
207
+ '-p', '--output-format', 'stream-json', '--verbose',
208
+ // Only these built-in tools exist in the session: no Edit, Write, web or subagents; no MCP server, hook or plugin of the user's.
209
+ '--tools', 'Read,Glob,Bash',
210
+ '--permission-mode', 'dontAsk',
211
+ ...(help.includes('--permission-prompts') ? ['--permission-prompts', 'none'] : []),
212
+ '--strict-mcp-config', '--setting-sources', 'project',
213
+ ...(help.includes('--no-session-persistence') ? ['--no-session-persistence'] : []),
214
+ '--allowedTools', ...allow,
215
+ '--disallowedTools', ...deny,
216
+ ];
217
+ }
218
+ /** `codex exec` argv: writes only in `workDir` (and temp folders), network on for `shard`, the task on standard input. */
219
+ export function codexArgs(workDir, help) {
220
+ return [
221
+ 'exec', '--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true',
222
+ '--skip-git-repo-check', '--cd', workDir, '--json',
223
+ ...(help.includes('--ephemeral') ? ['--ephemeral'] : []),
224
+ ...(help.includes('--ignore-user-config') ? ['--ignore-user-config'] : []),
225
+ ...(help.includes('--ignore-rules') ? ['--ignore-rules'] : []),
226
+ ...(help.includes('--output-last-message') ? ['--output-last-message', join(workDir, 'last-message.txt')] : []),
227
+ '-',
228
+ ];
229
+ }
230
+ /**
231
+ * While `SHARDFLUX_MACHINE_SETUP_VM` is set (the setup agent's own commands), shard only runs commands on that VM: exec,
232
+ * file writes from standard input, reads and listings. Returns the refusal, or undefined when the command may run.
233
+ */
234
+ export function setupGuard(env, command, values, positionals) {
235
+ const vm = env[SETUP_VM_ENV]?.trim();
236
+ if (!vm)
237
+ return undefined;
238
+ const allowed = ['workspaces exec', 'files write', 'files read', 'files ls', 'files stat', 'version'];
239
+ if (!allowed.includes(command))
240
+ return `machine setup runs only shard ws exec, files write and files read on ${vm}`;
241
+ if (command === 'version')
242
+ return undefined;
243
+ if (positionals[0] !== vm)
244
+ return `machine setup runs commands only on ${vm}`;
245
+ if (command === 'files write' && values.from !== undefined && values.from !== '-')
246
+ return 'machine setup writes files only from standard input (--from -)';
247
+ if (command === 'files read' && values.out !== undefined)
248
+ return 'machine setup reads files only to standard output';
249
+ return undefined;
250
+ }
251
+ const CLIS = ['gh', 'git-lfs', 'docker', 'kubectl', 'helm', 'k9s', 'kind', 'terraform', 'tofu', 'pulumi', 'aws', 'gcloud', 'az', 'doctl', 'flyctl', 'vercel',
252
+ 'netlify', 'heroku', 'supabase', 'wrangler', 'stripe', 'jq', 'yq', 'rg', 'fd', 'fzf', 'bat', 'eza', 'tree', 'htop', 'btop', 'tmux', 'nvim', 'vim', 'emacs',
253
+ 'http', 'wget', 'make', 'cmake', 'just', 'direnv', 'starship', 'zoxide', 'atuin', 'lazygit', 'delta', 'tig', 'uv', 'pipx', 'poetry', 'pnpm', 'yarn', 'bun',
254
+ 'deno', 'go', 'cargo', 'ruby', 'java', 'mvn', 'gradle', 'dotnet', 'php', 'composer', 'elixir', 'psql', 'mysql', 'redis-cli', 'sqlite3', 'mongosh', 'ffmpeg',
255
+ 'magick', 'protoc', 'buf', 'grpcurl', 'act', 'pre-commit', 'shellcheck', 'hadolint', 'ngrok', 'cloudflared', 'ansible', 'packer', 'vault', 'sops', 'age',
256
+ 'watchman', 'zsh', 'fish'];
257
+ const RUNTIMES = [['node', ['node', '--version']], ['python', ['python3', '--version']], ['go', ['go', 'version']],
258
+ ['rust', ['rustc', '--version']], ['ruby', ['ruby', '--version']], ['java', ['java', '-version']], ['bun', ['bun', '--version']],
259
+ ['deno', ['deno', '--version']], ['php', ['php', '--version']], ['dotnet', ['dotnet', '--version']]];
260
+ const VERSION_FILES = ['.nvmrc', '.node-version', '.python-version', '.ruby-version', '.tool-versions', '.config/mise/config.toml'];
261
+ const MANAGERS = [['nvm', '.nvm'], ['pyenv', '.pyenv'], ['rbenv', '.rbenv'], ['asdf', '.asdf'], ['volta', '.volta'], ['rustup', '.rustup'],
262
+ ['sdkman', '.sdkman'], ['mise', '.local/share/mise'], ['fnm', '.local/share/fnm'], ['oh-my-zsh', '.oh-my-zsh']];
263
+ const DOTFILES = ['.zshrc', '.zprofile', '.zshenv', '.bashrc', '.bash_profile', '.profile', '.bash_aliases', '.aliases', '.functions', '.exports', '.p10k.zsh',
264
+ '.gitconfig', '.gitignore_global', '.gitignore', '.config/git/config', '.config/git/ignore', '.tmux.conf', '.config/tmux/tmux.conf', '.vimrc', '.config/nvim',
265
+ '.config/starship.toml', '.inputrc', '.editorconfig', '.config/fish/config.fish', '.curlrc', '.psqlrc', '.irbrc', '.config/direnv/direnvrc'];
266
+ /** Agent CLIs and Shardflux itself are already in every VM. */
267
+ const SKIP_NPM = /^(npm|corepack|shardflux|@shardflux\/.*|@anthropic-ai\/claude-code|@openai\/codex|@agentclientprotocol\/.*|@zed-industries\/.*)$/;
268
+ function exists(path) { try {
269
+ statSync(path);
270
+ return true;
271
+ }
272
+ catch {
273
+ return false;
274
+ } }
275
+ /** What is on this computer, without contents that could hold a secret: names, versions, the git identity. */
276
+ export async function collectInventory(env) {
277
+ const home = env.HOME || homedir();
278
+ const opts = { env, timeoutMs: 5000, maxBytes: 512 * 1024 };
279
+ const has = (name) => findExecutable(name, env);
280
+ const out = async (argv) => {
281
+ const bin = has(argv[0]);
282
+ if (!bin)
283
+ return undefined;
284
+ const r = await runChild([bin, ...argv.slice(1)], opts);
285
+ return r.code === 0 ? (r.stdout.trim() || r.stderr.trim()) : undefined;
286
+ };
287
+ const [brew, npm, name, email, ...versions] = await Promise.all([
288
+ out(['brew', 'leaves', '--installed-on-request']).then(v => v ?? out(['brew', 'leaves'])),
289
+ out(['npm', 'ls', '-g', '--depth=0', '--json']),
290
+ out(['git', 'config', '--global', '--get', 'user.name']),
291
+ out(['git', 'config', '--global', '--get', 'user.email']),
292
+ ...RUNTIMES.map(([, argv]) => out(argv)),
293
+ ]);
294
+ const runtimes = {};
295
+ RUNTIMES.forEach(([label], i) => { const v = versions[i]; if (v)
296
+ runtimes[label] = firstLine(v).slice(0, 80); });
297
+ let npmGlobals = [];
298
+ try {
299
+ npmGlobals = Object.keys(JSON.parse(npm ?? '{}').dependencies ?? {}).filter(n => !SKIP_NPM.test(n));
300
+ }
301
+ catch { /* none */ }
302
+ const versionFiles = {};
303
+ for (const file of VERSION_FILES) {
304
+ try {
305
+ const text = readFileSync(join(home, file), 'utf8');
306
+ if (text.length <= 2048 && !/(token|secret|password|key)\s*=/i.test(text))
307
+ versionFiles[file] = text.trim();
308
+ }
309
+ catch { /* absent */ }
310
+ }
311
+ const os = osPlatform() === 'darwin' ? `macOS ${release()} (${arch()})` : `${osPlatform()} ${release()} (${arch()})`;
312
+ return {
313
+ os,
314
+ shell: env.SHELL ? env.SHELL.split('/').pop() : null,
315
+ homebrew: brew ? brew.split('\n').map(s => s.trim()).filter(Boolean).slice(0, 400) : null,
316
+ runtimes,
317
+ version_files: versionFiles,
318
+ version_managers: MANAGERS.filter(([, dir]) => exists(join(home, dir))).map(([m]) => m),
319
+ clis: CLIS.filter(c => has(c)),
320
+ npm_globals: npmGlobals.slice(0, 200),
321
+ git: { ...(name ? { name } : {}), ...(email ? { email } : {}) },
322
+ dotfiles: DOTFILES.filter(f => exists(join(home, f))),
323
+ };
324
+ }
325
+ /** Skills this computer's config copy brings to the machine: ~/.claude/skills and ~/.codex/skills (not Codex's bundled .system). */
326
+ export function countSkills(home) {
327
+ let n = 0;
328
+ for (const dir of [join(home, '.claude', 'skills'), join(home, '.codex', 'skills')]) {
329
+ try {
330
+ n += readdirSync(dir, { withFileTypes: true }).filter(e => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith('.')).length;
331
+ }
332
+ catch { /* none */ }
333
+ }
334
+ return n;
335
+ }
336
+ // ---------------------------------------------------------------------------------------------------------------------
337
+ // The task
338
+ const SUMMARY_SHAPE = '{"tools":["gh","ripgrep"],"runtimes":["Node 24","Python 3.13"],"shell":"zsh","dotfiles":[".zshrc",".gitconfig"],"skipped":[{"item":"Docker Desktop","reason":"GUI app"}]}';
339
+ /**
340
+ * The fixed task: agent-setup.ts's "Build the machine" step, extended to recreate this computer. `where` is 'local' for
341
+ * an agent on this computer (it reaches the VM through shard) or 'vm' for the cloud Claude inside the machine VM.
342
+ */
343
+ export function setupTask(where, key, inventory) {
344
+ const reach = where === 'local'
345
+ ? [
346
+ `The machine VM's workspace key is ${key}. Run commands on it with:`,
347
+ ` shard ws exec ${key} -- bash -lc '<commands>'`,
348
+ 'Write a file on it with its contents on standard input, never in arguments:',
349
+ ` shard files write ${key} /home/user/<path> --from - <<'EOF'`,
350
+ ' <contents>',
351
+ ' EOF',
352
+ '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
+ ]
354
+ : [
355
+ 'You are running inside that VM, as `user` (passwordless sudo): install and write files directly. The user\'s computer is not reachable; the inventory below is all you know about it.',
356
+ ];
357
+ return [
358
+ 'Set up this person\'s Shardflux cloud machine: the Ubuntu Linux VM (user `user`, home /home/user, passwordless sudo) that every new cloud coding agent of theirs starts from. Recreate their computer\'s development setup on it, so their agents find the same tools. Do it now without asking questions.',
359
+ '',
360
+ ...reach,
361
+ '',
362
+ 'Their computer (collected by shard; names and versions only):',
363
+ JSON.stringify(inventory, null, 1),
364
+ '',
365
+ '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.',
367
+ '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
+ '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
+ '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.',
370
+ '5. Editor-agnostic dotfiles: .editorconfig, .inputrc, .tmux.conf, .vimrc or the nvim config, starship.toml, .psqlrc.',
371
+ 'Do not touch Claude Code or Codex settings, skills, plugins or MCP config: shard copies them. Never read or copy secrets: ~/.ssh, ~/.aws, ~/.config/gh, ~/.netrc, ~/.npmrc, .env files, keychains, credential or token files. Do not run shard machine save: shard saves the machine when you are done. Keep to what the computer actually uses and finish within 8 minutes.',
372
+ '',
373
+ `When done, reply with only this JSON and nothing else. List only what you added or changed in the VM, by short name ("gh", "Node 24", ".tmux.conf"), never what it already had (empty lists when nothing was needed); the values here are examples: ${SUMMARY_SHAPE}`,
374
+ ].join('\n');
375
+ }
376
+ /** Short names only: "Node 24 (v24.21.0, already present)" is not something that was added, and parentheses are notes. */
377
+ const strings = (v) => Array.isArray(v)
378
+ ? v.filter((x) => typeof x === 'string' && !/already (present|installed|there|on)/i.test(x)).map(s => s.replace(/\s*\([^)]*\)?\s*$/, '').trim().slice(0, 60)).filter(Boolean).slice(0, 100)
379
+ : [];
380
+ /** The last JSON object in the agent's final message (a fenced block or bare), normalised. */
381
+ export function parseSummary(text) {
382
+ const candidates = [];
383
+ for (const m of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g))
384
+ candidates.push(m[1]);
385
+ const end = text.lastIndexOf('}');
386
+ // Every '{' before the last '}', nearest first (lastIndexOf with a negative start would find index 0 forever).
387
+ for (let start = text.lastIndexOf('{', end); start >= 0 && end > start && candidates.length < 500; start = start > 0 ? text.lastIndexOf('{', start - 1) : -1)
388
+ candidates.push(text.slice(start, end + 1));
389
+ for (const candidate of candidates.reverse()) {
390
+ try {
391
+ const doc = JSON.parse(candidate.trim());
392
+ if (!doc || typeof doc !== 'object' || !('tools' in doc || 'runtimes' in doc))
393
+ continue;
394
+ const skipped = Array.isArray(doc.skipped) ? doc.skipped.flatMap(s => s && typeof s === 'object' && typeof s.item === 'string'
395
+ ? [{ item: String(s.item).slice(0, 80), reason: (typeof s.reason === 'string' ? s.reason : '').slice(0, 120) }] : []).slice(0, 50) : [];
396
+ return { tools: strings(doc.tools), runtimes: strings(doc.runtimes), shell: typeof doc.shell === 'string' ? doc.shell.slice(0, 20) : null, dotfiles: strings(doc.dotfiles), skipped };
397
+ }
398
+ catch { /* next candidate */ }
399
+ }
400
+ return undefined;
401
+ }
402
+ /** "Cloud machine ready: gh, Node 24, Python 3.13, 12 skills": what a person recognises, at most 80 characters. */
403
+ export function readyLine(summary, skills) {
404
+ const items = [...(summary?.tools ?? []).slice(0, 3), ...(summary?.runtimes ?? []).slice(0, 3)];
405
+ const more = (summary?.tools.length ?? 0) + (summary?.runtimes.length ?? 0) - items.length;
406
+ const tail = [...(more > 0 ? [`${more} more`] : []), ...(skills > 0 ? [`${skills} skill${skills === 1 ? '' : 's'}`] : [])];
407
+ let line = `Cloud machine ready: ${[...items, ...tail].join(', ')}`;
408
+ while (line.length > 80 && items.length > 1) {
409
+ items.pop();
410
+ line = `Cloud machine ready: ${[...items, ...tail].join(', ')}`;
411
+ }
412
+ return items.length || tail.length ? line.slice(0, 80) : 'Cloud machine ready';
413
+ }
414
+ export function workingLine(runner) {
415
+ return runner === 'codex' ? 'Your Codex is setting up your cloud machine…' : 'Your Claude is setting up your cloud machine…';
416
+ }
417
+ export function failedLine(message) {
418
+ const short = message.replace(/\s+/g, ' ').trim();
419
+ const line = `Cloud machine setup stopped: ${short}`;
420
+ return line.length <= 80 ? line : `${line.slice(0, 79)}…`;
421
+ }
422
+ const recordKey = (apiUrl, org) => `${apiUrl} ${org}`;
423
+ function alive(pid) {
424
+ if (!pid)
425
+ return false;
426
+ try {
427
+ process.kill(pid, 0);
428
+ return true;
429
+ }
430
+ catch (error) {
431
+ return error.code === 'EPERM';
432
+ }
433
+ }
434
+ export function readRecord(store, apiUrl, org) {
435
+ const all = store.read().machine_setup;
436
+ const rec = all && typeof all === 'object' ? all[recordKey(apiUrl, org)] : undefined;
437
+ if (!rec || typeof rec !== 'object')
438
+ return undefined;
439
+ // A run whose process is gone (a restart, a kill) did not finish: it reads as failed, so Retry is offered.
440
+ if (rec.state === 'running' && !alive(rec.pid))
441
+ return { ...rec, state: 'failed', error: rec.error ?? 'The setup was interrupted', line: failedLine('it was interrupted') };
442
+ return rec;
443
+ }
444
+ function writeRecord(store, apiUrl, org, rec) {
445
+ store.update(doc => {
446
+ const all = (doc.machine_setup && typeof doc.machine_setup === 'object' ? doc.machine_setup : {});
447
+ all[recordKey(apiUrl, org)] = rec;
448
+ doc.machine_setup = all;
449
+ });
450
+ }
451
+ /**
452
+ * Claims the run under agents.json's lock: a live run is never doubled; with `ifNeeded` a machine already set up from
453
+ * this computer is left alone.
454
+ */
455
+ export function claimRun(store, apiUrl, org, ifNeeded, start) {
456
+ let result = { claimed: true };
457
+ store.update(doc => {
458
+ const all = (doc.machine_setup && typeof doc.machine_setup === 'object' ? doc.machine_setup : {});
459
+ const current = all[recordKey(apiUrl, org)];
460
+ if (current?.state === 'running' && alive(current.pid) && current.pid !== process.pid) {
461
+ result = { claimed: false, record: current };
462
+ return;
463
+ }
464
+ if (ifNeeded && current?.state === 'ready') {
465
+ result = { claimed: false, record: current };
466
+ return;
467
+ }
468
+ all[recordKey(apiUrl, org)] = start;
469
+ doc.machine_setup = all;
470
+ });
471
+ return result;
472
+ }
473
+ /**
474
+ * Whether the agent reached the machine VM at all: every shard command it ran failed (a sandbox without network, a
475
+ * permission it was never given) means nothing was set up, whatever its reply says, and the next agent should try.
476
+ */
477
+ export class ShardReach {
478
+ calls = 0;
479
+ ok = 0;
480
+ firstError = '';
481
+ record(succeeded, output = '') {
482
+ this.calls++;
483
+ if (succeeded)
484
+ this.ok++;
485
+ else if (!this.firstError)
486
+ this.firstError = firstLine(output).slice(0, 160);
487
+ }
488
+ check(app) {
489
+ if (this.calls > 0 && this.ok === 0)
490
+ 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
+ }
492
+ }
493
+ const isShardCommand = (command) => /(^|[\s;&|('"])shard\s+(ws|workspaces|files)\s/.test(command);
494
+ const toolText = (content) => typeof content === 'string' ? content
495
+ : Array.isArray(content) ? content.map(c => (c && typeof c === 'object' && typeof c.text === 'string' ? c.text : '')).join(' ') : '';
496
+ /** A private folder for the run: the agent's working directory, and a `shard` on PATH that is this very CLI. */
497
+ function workFolder(ctx) {
498
+ const dir = mkdtempSync(join(tmpdir(), 'shard-machine-setup-'));
499
+ chmodSync(dir, 0o700);
500
+ const bin = join(dir, 'bin');
501
+ mkdirSync(bin, { mode: 0o700 });
502
+ if (ctx.runtime?.script) {
503
+ writeFileSync(join(bin, 'shard'), `#!/bin/sh\nexec ${JSON.stringify(ctx.runtime.node)} ${JSON.stringify(ctx.runtime.script)} "$@"\n`, { mode: 0o700 });
504
+ }
505
+ return { dir, bin };
506
+ }
507
+ function agentEnv(ctx, key, bin) {
508
+ const env = { ...process.env, ...ctx.io.env };
509
+ for (const name of ['SHARDFLUX_PROGRESS_FILE', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CODEX_SANDBOX', 'CODEX_THREAD_ID'])
510
+ delete env[name];
511
+ return { ...env, PATH: `${bin}${delimiter}${env.PATH ?? ''}`, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, SHARDFLUX_NO_UPDATE_CHECK: '1', [SETUP_VM_ENV]: key };
512
+ }
513
+ function timeoutText(ms) { return ms % 60_000 === 0 ? `${ms / 60_000} min` : `${Math.round(ms / 1000)} s`; }
514
+ async function runClaudeLocal(ctx, agent, key, task, timeoutMs, log) {
515
+ const { dir, bin } = workFolder(ctx);
516
+ try {
517
+ let result;
518
+ const reach = new ShardReach(), shardCalls = new Set();
519
+ const run = await runChild([agent.bin, ...claudeArgs(key, agent.help)], {
520
+ cwd: dir, env: agentEnv(ctx, key, bin), input: task, timeoutMs, signal: ctx.signal,
521
+ onLine: line => {
522
+ try {
523
+ const event = JSON.parse(line);
524
+ if (event.type === 'result')
525
+ result = event;
526
+ for (const part of event.message?.content ?? []) {
527
+ if (part.type === 'tool_use') {
528
+ log(`${part.name}: ${(part.input?.command ?? part.input?.file_path ?? '').slice(0, 200)}`);
529
+ if (part.name === 'Bash' && part.id && isShardCommand(part.input?.command ?? ''))
530
+ shardCalls.add(part.id);
531
+ }
532
+ if (part.type === 'tool_result' && part.tool_use_id && shardCalls.has(part.tool_use_id)) {
533
+ reach.record(part.is_error !== true, toolText(part.content));
534
+ if (part.is_error === true)
535
+ log(`shard failed: ${toolText(part.content).slice(0, 200)}`);
536
+ }
537
+ }
538
+ }
539
+ catch { /* not an event */ }
540
+ },
541
+ });
542
+ if (run.timedOut)
543
+ throw new MachineSetupError(`Claude ran past the ${timeoutText(timeoutMs)} time box`, 'shard machine setup --from-this-computer runs it again');
544
+ if (!result)
545
+ throw new MachineSetupError(`Claude Code exited ${run.code ?? run.signal} without a result: ${firstLine(run.stderr) || 'no output'}`.slice(0, 300), 'shard machine setup --from-this-computer runs it again');
546
+ if (result.is_error || result.subtype !== 'success')
547
+ throw new MachineSetupError(`Claude stopped: ${(result.result ?? result.subtype ?? 'error').slice(0, 200)}`, 'shard machine setup --from-this-computer runs it again');
548
+ reach.check('Claude');
549
+ return { summary: parseSummary(result.result ?? ''), text: result.result ?? '' };
550
+ }
551
+ finally {
552
+ rmSync(dir, { recursive: true, force: true });
553
+ }
554
+ }
555
+ async function runCodexLocal(ctx, agent, key, task, timeoutMs, log) {
556
+ const { dir, bin } = workFolder(ctx);
557
+ try {
558
+ let last = '', failure = '';
559
+ const reach = new ShardReach();
560
+ const run = await runChild([agent.bin, ...codexArgs(dir, agent.help)], {
561
+ cwd: dir, env: agentEnv(ctx, key, bin), input: task, timeoutMs, signal: ctx.signal,
562
+ onLine: line => {
563
+ try {
564
+ const event = JSON.parse(line);
565
+ // turn.failed carries why (a usage limit, a sign-in): the one line the person sees.
566
+ if (event.type === 'turn.failed' || event.type === 'error')
567
+ failure = event.error?.message ?? event.message ?? failure;
568
+ if (event.item?.type === 'agent_message' && event.item.text)
569
+ last = event.item.text;
570
+ if (event.item?.type === 'command_execution' && event.item.command && event.type === 'item.started')
571
+ log(`exec: ${event.item.command.slice(0, 200)}`);
572
+ if (event.item?.type === 'command_execution' && event.type === 'item.completed' && isShardCommand(event.item.command ?? '')) {
573
+ reach.record(event.item.exit_code === 0, event.item.aggregated_output ?? '');
574
+ if (event.item.exit_code !== 0)
575
+ log(`shard failed: ${firstLine(event.item.aggregated_output ?? '').slice(0, 200)}`);
576
+ }
577
+ }
578
+ catch { /* not an event */ }
579
+ },
580
+ });
581
+ if (run.timedOut)
582
+ throw new MachineSetupError(`Codex ran past the ${timeoutText(timeoutMs)} time box`, 'shard machine setup --from-this-computer runs it again');
583
+ try {
584
+ last = readFileSync(join(dir, 'last-message.txt'), 'utf8') || last;
585
+ }
586
+ catch { /* the events had it */ }
587
+ if (run.code !== 0)
588
+ 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
+ reach.check('Codex');
590
+ return { summary: parseSummary(last), text: last };
591
+ }
592
+ finally {
593
+ rmSync(dir, { recursive: true, force: true });
594
+ }
595
+ }
596
+ /** The account's cloud Claude login, inside the machine VM: the same task with the inventory, full rights in the VM. */
597
+ async function runCloud(ctx, ws, task, timeoutMs) {
598
+ if (!await cloudClaudeLogin(ctx))
599
+ throw new MachineSetupError('Your cloud Claude is not signed in', 'shard agents credentials login claude, then shard machine setup --from-this-computer');
600
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
601
+ const file = '/tmp/shard-machine-setup/task.md';
602
+ try {
603
+ await cell.files.write(file, task, { createParents: true, mode: '0600' });
604
+ let run;
605
+ try {
606
+ run = await cell.exec.run(['bash', '-lc', `cd /home/user && claude -p --dangerously-skip-permissions --output-format json < ${file}`], { cwd: '/home/user', timeoutMs, maxOutputBytes: 4 * 1024 * 1024, signal: ctx.signal });
607
+ }
608
+ catch (error) {
609
+ if (error instanceof Error && /time(d)? ?out/i.test(error.message))
610
+ throw new MachineSetupError(`Claude ran past the ${timeoutText(timeoutMs)} time box`, 'shard machine setup --from-this-computer runs it again');
611
+ throw error;
612
+ }
613
+ let doc = {};
614
+ try {
615
+ doc = JSON.parse(run.stdout.trim().split('\n').pop() ?? '{}');
616
+ }
617
+ catch { /* below */ }
618
+ if (run.exitCode !== 0 || doc.is_error || !doc.result)
619
+ 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 };
621
+ }
622
+ finally {
623
+ await cell.files.remove('/tmp/shard-machine-setup', { recursive: true }).catch(() => undefined);
624
+ cell.close();
625
+ }
626
+ }
627
+ // ---------------------------------------------------------------------------------------------------------------------
628
+ // The command
629
+ /** One line for the launcher (SHARDFLUX_PROGRESS_FILE) and stderr. */
630
+ function phaseWriter(ctx) {
631
+ const file = ctx.io.env.SHARDFLUX_PROGRESS_FILE;
632
+ let last = '';
633
+ return (phase, line) => {
634
+ if (line === last)
635
+ return;
636
+ last = line;
637
+ if (file)
638
+ writeProgress(file, { phase, line });
639
+ stderrProgress(ctx, line);
640
+ };
641
+ }
642
+ /** A log of the agent's steps next to agents.json (machine-setup.log), for support; one line per tool call. */
643
+ function stepLog(ctx) {
644
+ let fd;
645
+ try {
646
+ fd = openSync(join(ctx.store.dir, 'machine-setup.log'), 'w', 0o600);
647
+ }
648
+ catch {
649
+ fd = undefined;
650
+ }
651
+ return {
652
+ log: line => { if (fd !== undefined)
653
+ try {
654
+ writeFileSync(fd, `${new Date().toISOString()} ${line.replace(/\n/g, ' ')}\n`);
655
+ }
656
+ catch { /* best effort */ } },
657
+ close: () => { if (fd !== undefined)
658
+ try {
659
+ closeSync(fd);
660
+ }
661
+ catch { /* closed */ } },
662
+ };
663
+ }
664
+ function output(ctx, doc, human) {
665
+ ctx.io.stdout.write(ctx.json ? `${JSON.stringify(doc)}\n` : `${human}\n`);
666
+ return 0;
667
+ }
668
+ function statusDoc(rec, machine) {
669
+ return { state: rec?.state ?? 'none', machine_version: machine.version, ...(rec ?? {}), ...(rec ? {} : { line: null }) };
670
+ }
671
+ export async function machineSetup(ctx, values, deps = {}) {
672
+ const cloud = ctx.cloud();
673
+ const store = new AgentStateStore(ctx.store.dir);
674
+ const machine = await cloud.machine.get({ signal: ctx.signal });
675
+ const org = machine.organization_id;
676
+ 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'));
679
+ }
680
+ if (values['from-this-computer'] !== true)
681
+ throw new UsageError('shard machine setup needs --from-this-computer', 'shard machine setup --from-this-computer');
682
+ const forced = typeof values.agent === 'string' ? values.agent : undefined;
683
+ if (forced !== undefined && !['claude', 'codex', 'cloud'].includes(forced))
684
+ throw new UsageError('--agent must be claude, codex or cloud');
685
+ const timeoutMs = typeof values.timeout === 'string' ? parseDuration(values.timeout, 'timeout') : DEFAULT_AGENT_TIMEOUT_MS;
686
+ const started = Date.now();
687
+ const startRec = { state: 'running', runner: null, pid: process.pid, started_at: new Date(started).toISOString(), finished_at: null, machine_version: null,
688
+ line: workingLine('claude'), summary: null, error: null, duration_ms: null };
689
+ 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);
692
+ const phase = phaseWriter(ctx);
693
+ const steps = stepLog(ctx);
694
+ let runner = null;
695
+ const attempts = [];
696
+ try {
697
+ const env = { ...process.env, ...ctx.io.env };
698
+ const [claude, codex] = forced === 'cloud' ? [undefined, undefined] : await Promise.all([(deps.inspectClaude ?? inspectClaude)(env), (deps.inspectCodex ?? inspectCodex)(env)]);
699
+ const { chain, skipped } = runnerChain(claude, codex, forced);
700
+ runner = chain[0].runner;
701
+ for (const reason of skipped)
702
+ steps.log(`skipped: ${reason}`);
703
+ steps.log(`agents: ${chain.map(c => `${c.runner}${c.agent ? ` (${c.agent.bin}, ${c.agent.version})` : ''}`).join(' → ')}`);
704
+ writeRecord(store, ctx.apiUrl, org, { ...startRec, runner, line: workingLine(runner) });
705
+ phase('machine', workingLine(runner));
706
+ const inventory = await (deps.inventory ?? collectInventory)(env);
707
+ const { state, ws } = await (deps.open ?? ((c, s) => openMachine(c, s)))(ctx, machine);
708
+ // A machine saved before (from another computer, or by hand) gets this computer's Claude and Codex config too;
709
+ // openMachine copies it into a first machine itself.
710
+ if (state.version > 0)
711
+ await syncAgentConfig(ctx, ws).catch((error) => steps.log(`config copy: ${error instanceof Error ? error.message : String(error)}`));
712
+ let outcome;
713
+ let spent = 0;
714
+ for (const candidate of chain) {
715
+ const box = attemptTimeout(timeoutMs, spent);
716
+ if (box === undefined) {
717
+ steps.log(`agent ${candidate.runner} not tried: the setup's time is used up`);
718
+ break;
719
+ }
720
+ runner = candidate.runner;
721
+ if (attempts.length)
722
+ writeRecord(store, ctx.apiUrl, org, { ...startRec, runner, line: workingLine(runner), attempts });
723
+ phase('agent', workingLine(runner));
724
+ steps.log(`agent: ${runner} (time box ${timeoutText(box)})`);
725
+ const began = Date.now();
726
+ try {
727
+ outcome = runner === 'cloud'
728
+ ? await runCloud(ctx, ws, setupTask('vm', ws.key, inventory), box)
729
+ : 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);
732
+ attempts.push({ runner, ok: true, error: null, duration_ms: Date.now() - began });
733
+ break;
734
+ }
735
+ catch (error) {
736
+ // 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)
738
+ throw error;
739
+ attempts.push({ runner, ok: false, error: error.message, duration_ms: Date.now() - began });
740
+ steps.log(`agent ${runner} stopped: ${error.message}`);
741
+ }
742
+ finally {
743
+ spent += Date.now() - began;
744
+ }
745
+ }
746
+ if (!outcome)
747
+ throw new MachineSetupError(attempts.map(a => a.error).filter(Boolean).join('; ') || 'no agent could set up the machine', 'shard machine setup --from-this-computer runs it again');
748
+ steps.log(`result: ${outcome.text.slice(0, 2000)}`);
749
+ phase('save', 'Saving your cloud machine…');
750
+ const saved = await cloud.machine.save({ workspaceId: ws.id }, { signal: ctx.signal, wait: { timeoutMs: SAVE_TIMEOUT_MS } });
751
+ const skills = countSkills(env.HOME || homedir());
752
+ const line = readyLine(outcome.summary, skills);
753
+ 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 };
755
+ writeRecord(store, ctx.apiUrl, org, rec);
756
+ phase('ready', line);
757
+ return output(ctx, { ...rec, workspace_key: ws.key }, line);
758
+ }
759
+ catch (error) {
760
+ const message = error instanceof Error ? error.message : String(error);
761
+ const rec = { ...startRec, state: 'failed', runner, pid: null, finished_at: new Date().toISOString(), line: failedLine(message), attempts, error: message.slice(0, 500), duration_ms: Date.now() - started };
762
+ try {
763
+ writeRecord(store, ctx.apiUrl, org, rec);
764
+ }
765
+ catch { /* the error below still reports it */ }
766
+ phase('failed', rec.line);
767
+ throw error;
768
+ }
769
+ finally {
770
+ steps.close();
771
+ }
772
+ }
773
+ /**
774
+ * Starts `shard machine setup --from-this-computer --if-needed --json` detached (the terminal onboarding, right after
775
+ * its Agents step): its output goes to machine-setup.out in the config directory. Returns false without a runtime.
776
+ */
777
+ export function startMachineSetupInBackground(ctx) {
778
+ if (!ctx.runtime?.script)
779
+ return false;
780
+ try {
781
+ mkdirSync(ctx.store.dir, { recursive: true, mode: 0o700 });
782
+ const out = openSync(join(ctx.store.dir, 'machine-setup.out'), 'w', 0o600);
783
+ try {
784
+ const child = spawn(ctx.runtime.node, [ctx.runtime.script, 'machine', 'setup', '--from-this-computer', '--if-needed', '--json'], {
785
+ detached: true, stdio: ['ignore', out, out],
786
+ env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, SHARDFLUX_NO_UPDATE_CHECK: '1' }
787
+ });
788
+ child.on('error', () => undefined);
789
+ child.unref();
790
+ }
791
+ finally {
792
+ closeSync(out);
793
+ }
794
+ return true;
795
+ }
796
+ catch {
797
+ return false;
798
+ }
799
+ }
800
+ export const MACHINE_SETUP_HANDLERS = {
801
+ 'machine setup': (ctx, values) => machineSetup(ctx, values),
802
+ };