@shardflux/cli 0.19.1 → 0.22.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 (115) hide show
  1. package/CHANGELOG.md +278 -1
  2. package/README.md +225 -73
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +20 -15
  6. package/dist/account-pool.js +51 -97
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-files.d.ts +19 -0
  16. package/dist/agent-config-files.js +70 -1
  17. package/dist/agent-config-return.d.ts +194 -0
  18. package/dist/agent-config-return.js +724 -0
  19. package/dist/agent-config-sync.d.ts +10 -2
  20. package/dist/agent-config-sync.js +45 -51
  21. package/dist/agent-credentials.js +7 -3
  22. package/dist/agent-repository.d.ts +16 -3
  23. package/dist/agent-repository.js +149 -74
  24. package/dist/agent-setup.d.ts +14 -3
  25. package/dist/agent-setup.js +78 -36
  26. package/dist/agent-target-commands.js +24 -13
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +104 -55
  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-forward.d.ts +2 -0
  35. package/dist/cli-proxy-forward.js +9 -2
  36. package/dist/cli-proxy-hub.d.ts +69 -25
  37. package/dist/cli-proxy-hub.js +231 -54
  38. package/dist/cli-proxy.d.ts +70 -1
  39. package/dist/cli-proxy.js +298 -32
  40. package/dist/client-settings.d.ts +39 -0
  41. package/dist/client-settings.js +91 -0
  42. package/dist/codex-cloud.d.ts +5 -0
  43. package/dist/codex-cloud.js +88 -17
  44. package/dist/command-center-actions.js +5 -1
  45. package/dist/commands.js +171 -22
  46. package/dist/conversation-commands.d.ts +5 -0
  47. package/dist/conversation-commands.js +8 -5
  48. package/dist/conversation-terminal.d.ts +10 -2
  49. package/dist/conversation-terminal.js +81 -15
  50. package/dist/conversations.d.ts +77 -1
  51. package/dist/conversations.js +257 -23
  52. package/dist/device-bridge.d.ts +1 -1
  53. package/dist/device-bridge.js +6 -4
  54. package/dist/device-helper.js +52 -4
  55. package/dist/errors.d.ts +34 -1
  56. package/dist/errors.js +56 -5
  57. package/dist/filesystem-commands.d.ts +22 -3
  58. package/dist/filesystem-commands.js +132 -16
  59. package/dist/format.d.ts +2 -1
  60. package/dist/format.js +10 -1
  61. package/dist/handlers.js +4 -1
  62. package/dist/initial-prompt.d.ts +8 -0
  63. package/dist/initial-prompt.js +43 -1
  64. package/dist/large-media.d.ts +14 -0
  65. package/dist/large-media.js +100 -0
  66. package/dist/linux-terminal.d.ts +5 -3
  67. package/dist/linux-terminal.js +6 -20
  68. package/dist/local-sessions.d.ts +26 -2
  69. package/dist/local-sessions.js +113 -24
  70. package/dist/machine-setup.d.ts +190 -0
  71. package/dist/machine-setup.js +849 -0
  72. package/dist/machine-status.d.ts +67 -0
  73. package/dist/machine-status.js +140 -0
  74. package/dist/main.js +23 -13
  75. package/dist/move-worktrees-restore.py +144 -0
  76. package/dist/move-worktrees.d.ts +63 -0
  77. package/dist/move-worktrees.js +201 -0
  78. package/dist/move.d.ts +10 -1
  79. package/dist/move.js +116 -16
  80. package/dist/onboarding.d.ts +11 -7
  81. package/dist/onboarding.js +59 -27
  82. package/dist/prepared-repository.d.ts +202 -0
  83. package/dist/prepared-repository.js +860 -0
  84. package/dist/prepared-repository.py +1306 -0
  85. package/dist/project-preparation.d.ts +48 -0
  86. package/dist/project-preparation.js +304 -0
  87. package/dist/project-sync-manifest.d.ts +4 -0
  88. package/dist/project-sync-manifest.js +5 -1
  89. package/dist/project-sync-transfer.js +1 -1
  90. package/dist/project-sync.d.ts +19 -4
  91. package/dist/project-sync.js +31 -8
  92. package/dist/project-template-setup.d.ts +33 -1
  93. package/dist/project-template-setup.js +59 -2
  94. package/dist/secret-ask.d.ts +72 -0
  95. package/dist/secret-ask.js +351 -0
  96. package/dist/seed-phases.d.ts +13 -3
  97. package/dist/seed-phases.js +21 -3
  98. package/dist/setup-cache.d.ts +7 -0
  99. package/dist/setup-cache.js +32 -0
  100. package/dist/t3-launch.js +1 -4
  101. package/dist/t3-mode.d.ts +8 -2
  102. package/dist/t3-mode.js +19 -4
  103. package/dist/t3-phone.d.ts +4 -2
  104. package/dist/t3-phone.js +12 -4
  105. package/dist/terminal-apps.d.ts +157 -0
  106. package/dist/terminal-apps.js +561 -0
  107. package/dist/terminal-onboarding.d.ts +2 -0
  108. package/dist/terminal-onboarding.js +11 -1
  109. package/dist/this-computer.d.ts +11 -0
  110. package/dist/this-computer.js +36 -0
  111. package/dist/thread-on-computer.js +4 -1
  112. package/dist/update.js +11 -3
  113. package/dist/worktree-twin.d.ts +10 -2
  114. package/dist/worktree-twin.js +27 -9
  115. package/package.json +5 -5
@@ -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
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { homedir, hostname } from 'node:os';
14
14
  import { statSync } from 'node:fs';
15
- import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
15
+ import { SDK_VERSION, Shardflux, ShardfluxAccount, aboutSeconds, isSessionToken } from '@shardflux/sdk';
16
16
  import { CLI_VERSION, CENTER_COMMAND, GUIDE_COMMAND, ONBOARDING_COMMAND, PROJECT_COMMAND, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
17
17
  import { commandCenter } from "./command-center.js";
18
18
  import { launchProjectCommand } from "./conversation-terminal.js";
@@ -34,6 +34,7 @@ import { NPM_CLIENTS, npmLatestAll, npmRegistry, withRegistryLatest } from "./re
34
34
  import { AuthConfigError, EXIT, ExecutionPendingError, InterruptedError, UsageError, describeError, exitCodeFor, ownHint, redact } from "./errors.js";
35
35
  import { errorText, feedbackCommand, json, time } from "./format.js";
36
36
  import { HANDLERS } from "./handlers.js";
37
+ import { SETUP_VM_ENV, setupGuard } from "./machine-setup.js";
37
38
  import { makeFetch } from "./http.js";
38
39
  import { Input } from "./input.js";
39
40
  import { TimingRecorder, notices, timingJson, timingText } from "./timing.js";
@@ -71,10 +72,8 @@ function hintFor(info, cred, file, command = '') {
71
72
  return `keep waiting with: shard operations wait ${info.operation_id}`;
72
73
  if (info.code === 'usage')
73
74
  return 'run "shard --help" or "shard <command> --help"';
74
- if (info.code === 'filesystem_save_failed') {
75
- const level = info.details?.level === 'template' ? 'template' : 'machine';
76
- 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`;
77
- }
75
+ if (info.code === 'filesystem_save_failed')
76
+ return info.fix;
78
77
  if (info.source === 'cell' && info.code === 'workspace_not_running')
79
78
  return 'open or resume the workspace first: shard workspaces open <key> --template <slug>';
80
79
  if (info.status === 401 && info.code === 'unauthenticated') {
@@ -112,6 +111,12 @@ function hintFor(info, cred, file, command = '') {
112
111
  if (quota)
113
112
  return quota;
114
113
  }
114
+ // 0.20.0+: builds past the plan's slots queue; only a full queue is refused (429, Retry-After).
115
+ if (info.code === 'rate_limited' && reasonOf(info) === 'template_build_queue_full') {
116
+ const queued = typeof info.details?.queued === 'number' ? info.details.queued : undefined;
117
+ const retry = info.retry_after_seconds ?? (typeof info.details?.retry_after_seconds === 'number' ? info.details.retry_after_seconds : undefined);
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'}`;
119
+ }
115
120
  const policyHint = spendPolicyHint(info);
116
121
  if (policyHint)
117
122
  return policyHint;
@@ -343,6 +348,8 @@ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
343
348
  // names the next step (upgrade, overage, the cap, the payment method), not a problem to report.
344
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']);
345
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',
346
353
  'revision_mismatch',
347
354
  'tree_revision_mismatch',
348
355
  'edit_not_found',
@@ -375,6 +382,9 @@ function nudges(info, command) {
375
382
  // A plan limit whose hint names the next step (0.9.0): busy working slots, or Retained state used up.
376
383
  if (info.code === 'quota_exceeded' && quotaHint(info) !== undefined)
377
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;
378
388
  const reason = reasonOf(info);
379
389
  return !NO_NUDGE_CODES.has(info.code) && !(reason !== undefined && NO_NUDGE_REASONS.has(reason));
380
390
  }
@@ -466,12 +476,8 @@ export async function run(argv, io, opts = {}) {
466
476
  io.stdout.write(wantsJson ? json({ cli: CLI_VERSION, sdk: SDK_VERSION }) : `shard ${CLI_VERSION} (@shardflux/sdk ${SDK_VERSION})\n`);
467
477
  return EXIT.ok;
468
478
  }
469
- // shard ./ attaches Claude Code or Codex to this terminal: without one (or in a coding agent's shell) it says how to
470
- // start the conversation instead of starting one nobody can see (0.17.1+: a usage error, before 0.17.1 its help).
471
- if (parsed.command === PROJECT_COMMAND && !tty && !parsed.global.json) {
472
- await startupHousekeeping({ io, store, signal });
473
- 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)'));
474
- }
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.
475
481
  command = parsed.command.path.join(' ');
476
482
  let ctx;
477
483
  let updates = null;
@@ -678,7 +684,7 @@ export async function run(argv, io, opts = {}) {
678
684
  // `shard update` brings the app in itself; `shard update --check` (the menu bar of an app before 0.3.2 runs it every
679
685
  // 6 hours) is how such an app learns of a newer one with no action from the person.
680
686
  const updating = command === 'update' && parsed.values.check !== true;
681
- if (opts.runtime && !updating && !['menubar', 'device-helper'].includes(parsed.command.path[0])) {
687
+ if (opts.runtime && !updating && !io.env[SETUP_VM_ENV] && !['menubar', 'device-helper'].includes(parsed.command.path[0])) {
682
688
  refreshMenubarInBackground({ dir: store.dir, home: io.env.HOME || homedir(), runtime: opts.runtime, env: { ...process.env, ...io.env } });
683
689
  }
684
690
  // The update check runs next to the command (not for `version`, which reports it itself).
@@ -712,11 +718,15 @@ export async function run(argv, io, opts = {}) {
712
718
  io.stdout.write(setupGuideText(agentSetupGuide(ctx)));
713
719
  return EXIT.ok;
714
720
  }
721
+ // The machine setup agent's own shard (0.20.0+): only exec, writes from standard input and reads on its machine VM.
722
+ const refused = setupGuard(io.env, command, parsed.values, parsed.positionals);
723
+ if (refused)
724
+ return report(new UsageError(refused));
715
725
  const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? (ctx) => runDeviceHelper(ctx, { service: parsed.values.service === true }) : command === 'menubar refresh' ? runMenubarRefresh : parsed.command === PROJECT_COMMAND ? launchProjectCommand : parsed.command === ONBOARDING_COMMAND ? async (ctx, values) => await tryLinuxDesktop(ctx) ? 0 : terminalOnboarding(ctx, values, { startHelper }) : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
716
726
  if (!handler)
717
727
  return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
718
728
  // Long-running helpers other programs start (an editor, launchd, the menu bar) skip the per-command housekeeping.
719
- if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh' && command !== 'cli-proxy forward') {
729
+ if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh' && command !== 'cli-proxy forward' && !io.env[SETUP_VM_ENV]) {
720
730
  // An updated shard restarts this computer's CLIProxyAPI forwarder on itself (a no-op unless the version changed).
721
731
  await refreshForwarderAgent(io.env, ctx.store.dir).catch(() => false);
722
732
  await housekeeping(ctx);
@@ -0,0 +1,144 @@
1
+ """Restore an owned migration into an EMPTY folder; never overwrite continued work.
2
+
3
+ The immutable archives retain original Git metadata. Only location pointers in the
4
+ restored copy change. Can be run again with a new empty root for recovery.
5
+ """
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path, PurePosixPath
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tarfile
14
+
15
+ manifest = Path(sys.argv[1]).resolve()
16
+ plan = json.loads(manifest.read_text())
17
+ root = Path(sys.argv[2]).absolute()
18
+ if root.is_symlink() or (root.exists() and any(root.iterdir())):
19
+ raise ValueError("Restore requires a new empty folder; existing work was not replaced")
20
+ root.mkdir(parents=True, exist_ok=True)
21
+
22
+ def destination(target):
23
+ parts = PurePosixPath(target).parts
24
+ if target != "." and (target.startswith("/") or any(p in ("", "..") for p in parts)):
25
+ raise ValueError("Unsafe worktree destination")
26
+ return root if target == "." else root.joinpath(*parts)
27
+
28
+ targets = {t["source"]: destination(t["target"]) for t in plan["trees"]}
29
+ expected = plan["bytes"] + plan["files"] * 1024
30
+ if shutil.disk_usage(root).free < expected:
31
+ raise ValueError(f"Restore requires at least {expected} free bytes on the target filesystem")
32
+
33
+ def parents_safe(path, base):
34
+ parent = path.parent
35
+ while parent != base:
36
+ if parent.is_symlink():
37
+ raise ValueError("Archive parent is a symlink")
38
+ if parent == parent.parent:
39
+ raise ValueError("Archive escaped its root")
40
+ parent = parent.parent
41
+ if base.is_symlink():
42
+ raise ValueError("Archive root is a symlink")
43
+
44
+ for i, tree in enumerate(plan["trees"]):
45
+ base = targets[tree["source"]]
46
+ base.mkdir(parents=True, exist_ok=True)
47
+ directories = []
48
+ with tarfile.open(manifest.parent / f"{i}.tar", "r:") as archive:
49
+ for member in archive:
50
+ parts = PurePosixPath(member.name).parts
51
+ if member.name in (".", "./"):
52
+ continue
53
+ if member.name.startswith("/") or any(p in ("", "..") for p in parts):
54
+ raise ValueError("Unsafe archive member")
55
+ path = base.joinpath(*parts)
56
+ parents_safe(path, base)
57
+ if member.isdir():
58
+ if path.is_symlink():
59
+ raise ValueError("Archive directory is a symlink")
60
+ path.mkdir(parents=True, exist_ok=True)
61
+ directories.append((path, member.mode & 0o777, member.mtime))
62
+ elif member.issym():
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ path.symlink_to(member.linkname)
65
+ elif member.isreg() or member.islnk():
66
+ # Expand hard links: Git index/content is preserved, no link may escape.
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ with archive.extractfile(member) as src, path.open("xb") as out:
69
+ shutil.copyfileobj(src, out, 1024 * 1024)
70
+ path.chmod(member.mode & 0o777)
71
+ os.utime(path, (member.mtime, member.mtime))
72
+ else:
73
+ raise ValueError("Special archive member is not supported")
74
+ # Read-only directory modes must be applied after writing their contents.
75
+ for path, mode, mtime in reversed(directories):
76
+ path.chmod(mode)
77
+ os.utime(path, (mtime, mtime))
78
+ for entry in tree["files"]:
79
+ path = base / entry["path"]
80
+ if "link" in entry:
81
+ if not path.is_symlink() or os.readlink(path) != entry["link"]:
82
+ raise ValueError(f"Symlink verification failed: {path}")
83
+ digest = hashlib.sha256(("symlink\0" + entry["link"]).encode()).hexdigest()
84
+ else:
85
+ if path.is_symlink() or not path.is_file() or path.stat().st_size != entry["size"]:
86
+ raise ValueError(f"File size/type verification failed: {path}")
87
+ sha = hashlib.sha256()
88
+ with path.open("rb") as src:
89
+ for chunk in iter(lambda: src.read(1024 * 1024), b""):
90
+ sha.update(chunk)
91
+ digest = sha.hexdigest()
92
+ if path.stat().st_mode & 0o777 != entry["mode"]:
93
+ raise ValueError(f"File mode verification failed: {path}")
94
+ if digest != entry["sha256"]:
95
+ raise ValueError(f"File hash verification failed: {path}")
96
+
97
+ common = targets[plan["main"]] / ".git"
98
+ env = {**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"}
99
+ def git(tree, *args):
100
+ return subprocess.run(["git", "-C", str(tree), "-c", "core.hooksPath=/dev/null", *args],
101
+ env=env, check=True, stdout=subprocess.PIPE).stdout
102
+
103
+ # Repair forward/back pointers without running checkout/reset or refreshing an index.
104
+ for tree in plan["trees"]:
105
+ dest = targets[tree["source"]]
106
+ if tree["gitDir"] != plan["commonDir"]:
107
+ gitdir = common / Path(tree["gitDir"]).relative_to(plan["commonDir"])
108
+ (dest / ".git").write_text(f"gitdir: {gitdir}\n")
109
+ (gitdir / "gitdir").write_text(f"{dest / '.git'}\n")
110
+ (gitdir / "commondir").write_text(os.path.relpath(common, gitdir) + "\n")
111
+ worktree_config = gitdir / "config.worktree"
112
+ if worktree_config.exists():
113
+ subprocess.run(["git", "config", "--file", str(worktree_config), "core.worktree", str(dest)], check=True)
114
+ git(targets[plan["main"]], "config", "core.worktree", str(targets[plan["main"]]))
115
+ # External registered worktrees live in this private recovery area, outside the
116
+ # selected checkout's tracked content. Hide only that explicit location.
117
+ exclude = common / "info/exclude"
118
+ exclude.parent.mkdir(parents=True, exist_ok=True)
119
+ with exclude.open("a") as out:
120
+ out.write("\n/.shard/preserved-worktrees/\n")
121
+
122
+ selected = targets[plan["selected"]]
123
+ refs = git(selected, "for-each-ref", "--format=%(objectname) %(refname)").decode()
124
+ if refs != plan["refs"]:
125
+ raise ValueError("Local refs did not survive migration")
126
+ for tree in plan["trees"]:
127
+ dest = targets[tree["source"]]
128
+ if git(dest, "rev-parse", "HEAD").decode().strip() != tree["head"]:
129
+ raise ValueError(f"HEAD did not survive migration: {dest}")
130
+ status = git(dest, "status", "--porcelain=v1", "-z", "--untracked-files=all").decode()
131
+ if status != tree["status"]:
132
+ raise ValueError(f"Git status changed during migration: {dest}")
133
+
134
+ # The platform tracks this conversation by its own branch; the original branch
135
+ # and raw HEAD remain in refs/archives. Index and working bytes stay untouched.
136
+ branch = plan["branch"]
137
+ git(selected, "check-ref-format", branch)
138
+ if any(line.endswith(" " + branch) for line in refs.splitlines()):
139
+ raise ValueError("Conversation branch collides with a preserved local ref")
140
+ if "--defer-conversation-branch" not in sys.argv[3:]:
141
+ git(selected, "update-ref", branch, git(selected, "rev-parse", "HEAD").decode().strip())
142
+ git(selected, "symbolic-ref", "HEAD", branch)
143
+ print(json.dumps({"worktrees": len(plan["trees"]), "files": plan["files"], "bytes": plan["bytes"],
144
+ "root": str(root), "refs": len(refs.splitlines()), "status": "matched"}))
@@ -0,0 +1,63 @@
1
+ import type { SyncCell } from './project-sync-transfer.js';
2
+ export interface MigrationFile {
3
+ path: string;
4
+ size: number;
5
+ mode: number;
6
+ sha256: string;
7
+ link?: string;
8
+ }
9
+ export interface MigrationTree {
10
+ source: string;
11
+ spellings: string[];
12
+ target: string;
13
+ head: string;
14
+ branch: string;
15
+ gitDir: string;
16
+ status: string;
17
+ files: MigrationFile[];
18
+ exclude: string[];
19
+ }
20
+ export interface WorktreeMigration {
21
+ version: 1;
22
+ selected: string;
23
+ commonDir: string;
24
+ main: string;
25
+ refs: string;
26
+ trees: MigrationTree[];
27
+ bytes: number;
28
+ files: number;
29
+ referenced: string[];
30
+ }
31
+ /** Git is the source of membership; sibling folders and symlink targets never expand the upload scope. */
32
+ export declare function inspectWorktreeMigration(folder: string, transcript: Buffer, signal?: AbortSignal): Promise<WorktreeMigration | null>;
33
+ /** Size/hash pass only after preserve-all is selected (ignored caches may be large). */
34
+ export declare function captureWorktreeMigration(plan: WorktreeMigration, signal?: AbortSignal): Promise<void>;
35
+ export declare function migrationScope(plan: WorktreeMigration): {
36
+ worktrees: {
37
+ path: string;
38
+ branch: string;
39
+ target: string;
40
+ }[];
41
+ referenced: string[];
42
+ local_refs: string[];
43
+ bytes: number;
44
+ files: number;
45
+ scope: string;
46
+ size_basis: string;
47
+ };
48
+ /** User refs a normal move leaves behind. Shardflux's own refs/shard/* (twin snapshots, land and agent refs) are rebuilt, never user work. */
49
+ export declare function omittedLocalRefs(plan: WorktreeMigration): string[];
50
+ /** Longest paths first, before replacing a parent. Message text and signed thinking keep the existing rewrite contract. */
51
+ export declare function rewriteWorktreeTranscript(bytes: Buffer, plan: WorktreeMigration, root: string): Buffer;
52
+ export interface MigrationResult {
53
+ snapshot: string;
54
+ bytes: number;
55
+ worktrees: number;
56
+ files: number;
57
+ }
58
+ /** Called instead of regular seeding: the guest starts only after its refs, index, bytes and statuses are checked. */
59
+ export declare function preserveWorktreeMigration(cell: SyncCell, plan: WorktreeMigration, c: {
60
+ id: string;
61
+ branch: string;
62
+ worktree_path: string;
63
+ }, signal?: AbortSignal): Promise<MigrationResult>;