@shardflux/cli 0.13.2 → 0.13.4

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.
@@ -5,6 +5,7 @@ import { Readable, Duplex } from 'node:stream';
5
5
  import { ShardfluxApiError, WorkspaceTunnels } from '@shardflux/sdk';
6
6
  import { out, resolveWorkspace } from "./commands.js";
7
7
  import { AgentTargets, installedTargets } from "./agent-targets.js";
8
+ import { join, relative } from 'node:path';
8
9
  import { projectIdentity } from "./project-identity.js";
9
10
  import { projectWorkspacePath } from "./agent-repository.js";
10
11
  import { UsageError } from "./errors.js";
@@ -134,14 +135,42 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
134
135
  out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions });
135
136
  return 0;
136
137
  };
138
+ const tilde = (targets, file) => file.startsWith(`${targets.home}/`) ? `~/${relative(targets.home, file)}` : file;
139
+ const targetLabel = (e) => `[SF] ${e.project} · ${e.agent}`;
140
+ /** Where each managed connection of an entry lives: the SSH host block (and its Include), then each desktop app's file. */
141
+ function configuredTargets(targets, e) {
142
+ return [
143
+ { target: 'ssh', label: 'SSH', file: targets.sshFile, name: e.host, detail: `(included from ${tilde(targets, join(targets.home, '.ssh', 'config'))}): ssh ${e.host}` },
144
+ ...(e.targets.includes('claude-desktop') ? [{ target: 'claude-desktop', label: 'Claude Desktop', file: targets.claudeFile, name: targetLabel(e), detail: `(sshConfigs: ${targetLabel(e)}); restart Claude Desktop, then pick it in Code's environment list` }] : []),
145
+ ...(e.targets.includes('codex-app') ? [{ target: 'codex-app', label: 'Codex app', file: targets.codexFile, name: targetLabel(e), detail: `(remoteConnections: ${targetLabel(e)}); restart Codex to import it` }] : []),
146
+ ];
147
+ }
148
+ function targetFiles(targets, entries) {
149
+ return [...new Set(entries.flatMap(e => configuredTargets(targets, e).map(c => c.file)))];
150
+ }
137
151
  export const TARGET_HANDLERS = {
138
152
  'agents targets'(ctx, values, [ref]) {
139
153
  const detected = installedTargets(ctx.io.env);
140
154
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
155
+ const key = ref ? keyOf(ref) : undefined;
156
+ const before = values.remove === true ? targets.configure(undefined, ctx.apiUrl, []) : [];
141
157
  const entries = ref || values.remove === true
142
- ? targets.configure(ref ? keyOf(ref) : undefined, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app'), values.remove === true)
158
+ ? targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app'), values.remove === true)
143
159
  : [];
144
- out(ctx, `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n${entries.map(e => e.host).join('\n')}${entries.length ? '\n' : ''}`, { ...detected, entries });
160
+ const detection = `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n`;
161
+ if (values.remove === true) {
162
+ const removed = before.filter(e => !entries.some(n => n.host === e.host));
163
+ out(ctx, removed.length ? `Removed ${removed.map(e => `${e.workspace} (${e.host})`).join(', ')} from ${targetFiles(targets, removed).map(f => tilde(targets, f)).join(', ')}\n` : 'No managed targets to remove.\n', { ...detected, entries, removed });
164
+ return Promise.resolve(0);
165
+ }
166
+ const entry = key ? entries.find(e => e.workspace === key) : undefined;
167
+ if (!entry) {
168
+ out(ctx, detection, { ...detected, entries });
169
+ return Promise.resolve(0);
170
+ }
171
+ const configured = configuredTargets(targets, entry);
172
+ out(ctx, `Configured ${entry.workspace} as SSH host ${entry.host}:\n${configured.map(c => ` ${c.label.padEnd(15)} ${tilde(targets, c.file)}${c.detail ? ` ${c.detail}` : ''}`).join('\n')}\n` +
173
+ ['claude-desktop', 'codex-app'].filter(t => !entry.targets.includes(t)).map(t => ` ${(t === 'claude-desktop' ? 'Claude Desktop' : 'Codex app').padEnd(15)} not installed on this computer\n`).join(''), { ...detected, entries, configured: configured.map(({ target, file, name }) => ({ target, file, name })) });
145
174
  return Promise.resolve(0);
146
175
  },
147
176
  'agents open claude-desktop': openTarget('claude-desktop'),
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.13.2";
1
+ export declare const CLI_VERSION = "0.13.4";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.13.2';
15
+ export const CLI_VERSION = '0.13.4';
16
16
  export const GLOBAL_OPTIONS = {
17
17
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
18
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -142,7 +142,10 @@ const SECRET_PERMISSIONS = {
142
142
  const KEY_COMMANDS = [
143
143
  ...['machine', 'template'].flatMap((level) => [
144
144
  { path: [level], summary: level === 'machine' ? 'Open your machine agent, or show its saved filesystem with --json' : 'Show this project’s template filesystem', positionals: [], options: {} },
145
- { path: [level, 'save'], summary: `Save the ${level} filesystem as a new private version`, positionals: [], options: {} },
145
+ { path: [level, 'save'], summary: `Save the ${level} filesystem as a new private version`, positionals: [], options: {
146
+ wait: { type: 'boolean', description: 'Return when the new version is ready (or failed), printing progress on stderr.' },
147
+ timeout: { type: 'string', value: '<duration>', description: 'With --wait: give up waiting after this long (default 10m). The save continues server side.' },
148
+ } },
146
149
  { path: [level, 'rollback'], summary: `Restore a saved ${level} version`, positionals: [], options: { to: { type: 'string', value: '<version>', description: 'Version to restore (default previous saved version).' } } },
147
150
  ]),
148
151
  { path: ['agents', 'list'], summary: 'List coding agent sessions.', positionals: [], options: {
@@ -1136,8 +1139,8 @@ const AGENT_OPTIONS = {
1136
1139
  approvals: { type: 'boolean', description: 'Keep the agent’s permission prompts.' },
1137
1140
  };
1138
1141
  export const PROJECT_COMMAND = {
1139
- path: ['<path>'], summary: 'Open the project’s cloud agent command center',
1140
- description: 'Claude is the default; add codex to use Codex. Detach with Ctrl-] then d; /exit ends the agent session.',
1142
+ path: ['<path>'], summary: 'Open the project’s cloud agent command center, or start its agent directly with claude|codex',
1143
+ description: 'Naming the app starts (or reattaches) that agent at once; with --json it starts detached and prints the session. Detach with Ctrl-] then d; /exit ends the agent session.',
1141
1144
  positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: AGENT_OPTIONS,
1142
1145
  };
1143
1146
  export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud agent command center', positionals: [], options: {} };
@@ -4,10 +4,12 @@ export interface SetupProgress {
4
4
  id: string;
5
5
  deps_hash: string;
6
6
  state: 'queued' | 'running' | 'checking' | 'saving' | 'ready' | 'failed';
7
+ /** required is false for a step the repository marked `required: false` in .shardflux/setup.yaml. */
7
8
  tasks: {
8
9
  name: string;
9
- state: 'queued' | 'running' | 'ready' | 'failed';
10
+ state: 'queued' | 'running' | 'ready' | 'failed' | 'skipped';
10
11
  attempts: number;
12
+ required?: boolean;
11
13
  error?: {
12
14
  code: string;
13
15
  message: string;
@@ -67,7 +67,10 @@ export async function retrySetupStep(ctx, workspace, step) {
67
67
  export function setupLine(project, setup) {
68
68
  if (setup.state === 'ready')
69
69
  return undefined;
70
- const failed = setup.tasks.find(task => task.state === 'failed' && task.error?.code !== 'setup_dependency_failed');
70
+ const failures = setup.tasks.filter(task => task.state === 'failed' && task.error?.code !== 'setup_dependency_failed');
71
+ // A step marked required: false does not hold back the save; it is the
72
+ // failure shown only when the job failed and no required step did.
73
+ const failed = failures.find(task => task.required !== false) ?? (setup.state === 'failed' ? failures[0] : undefined);
71
74
  if (failed)
72
75
  return `Setting up ${project}: ${failed.name} failed: ${failed.error?.message ?? 'No error receipt'}${failed.error?.retryable ? ' · r retries once' : ''}`;
73
76
  if (setup.state === 'saving')
@@ -1,9 +1,15 @@
1
- import type { AgentCredentialMetadata, Workspace } from '@shardflux/sdk';
1
+ import type { AgentCredentialMetadata, CellClient, Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
3
  export declare function requireClaudeTerminal(ctx: Ctx): void;
4
4
  export declare function cloudClaudeLogin(ctx: Ctx): Promise<AgentCredentialMetadata | undefined>;
5
5
  /** Parse only complete provider authorize links, including the native OSC-8 link. */
6
6
  export declare function claudeLoginUrls(output: string): string[];
7
+ /**
8
+ * Put the signed-in account into the workspace's ~/.claude.json, keeping every other key. The platform's credential
9
+ * delivery writes the same file right after the upload, so a revision conflict re-reads and merges again; a file that
10
+ * already holds this account is left as it is.
11
+ */
12
+ export declare function mergeClaudeAccount(cell: Pick<CellClient, 'files'>, oauthAccount: unknown, signal?: AbortSignal): Promise<void>;
7
13
  /** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
8
14
  export declare function loginClaudeInWorkspace(ctx: Ctx, ws: Workspace): Promise<AgentCredentialMetadata>;
9
15
  export declare function ensureCloudClaude(ctx: Ctx, ws: Workspace): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  import { ShardfluxApiError } from '@shardflux/sdk';
2
3
  import { UsageError } from "./errors.js";
3
4
  import { openUrl } from "./browser.js";
@@ -42,6 +43,39 @@ function credentialFile(path, value, ctx) {
42
43
  ctx.addSecret(content_b64);
43
44
  return { path, mode: '0600', content_b64 };
44
45
  }
46
+ /**
47
+ * Put the signed-in account into the workspace's ~/.claude.json, keeping every other key. The platform's credential
48
+ * delivery writes the same file right after the upload, so a revision conflict re-reads and merges again; a file that
49
+ * already holds this account is left as it is.
50
+ */
51
+ export async function mergeClaudeAccount(cell, oauthAccount, signal) {
52
+ const path = '/home/user/.claude.json';
53
+ for (let attempt = 0; attempt < 5; attempt++) {
54
+ let document = {}, revision = 'absent';
55
+ try {
56
+ const current = await cell.files.readWithInfo(path);
57
+ document = JSON.parse(Buffer.from(current.data).toString());
58
+ if (!current.revision)
59
+ throw new UsageError('Claude account configuration has no safe content revision.');
60
+ revision = current.revision;
61
+ }
62
+ catch (error) {
63
+ if (!(error instanceof Error && 'status' in error && error.status === 404))
64
+ throw error;
65
+ }
66
+ if (isDeepStrictEqual(document.oauthAccount, oauthAccount))
67
+ return;
68
+ try {
69
+ await cell.files.patch({ path, content: JSON.stringify({ ...document, oauthAccount }), expectedRevision: revision, mode: '0600', createParents: true }, { signal });
70
+ return;
71
+ }
72
+ catch (error) {
73
+ if (!(error instanceof ShardfluxApiError && error.status === 409 && error.details?.reason === 'revision_mismatch'))
74
+ throw error;
75
+ }
76
+ }
77
+ throw new UsageError('Claude account configuration kept changing in the workspace. Run shard agents credentials login claude again.');
78
+ }
45
79
  /** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
46
80
  export async function loginClaudeInWorkspace(ctx, ws) {
47
81
  requireClaudeTerminal(ctx);
@@ -112,22 +146,10 @@ export async function loginClaudeInWorkspace(ctx, ws) {
112
146
  // persistence. An upload error requires a new cloud login, never guest refresh.
113
147
  await cell.files.remove(root, { recursive: true });
114
148
  const metadata = await api.put('claude', files, { loginId: claim.login_id });
149
+ complete = true; // stored: the login must not be canceled by a later local write failure
115
150
  const access = Object.fromEntries(['accessToken', 'expiresAt', 'scopes', 'subscriptionType', 'rateLimitTier'].filter(key => key in credential.claudeAiOauth).map(key => [key, credential.claudeAiOauth[key]]));
116
151
  await cell.files.write('/home/user/.claude/.credentials.json', JSON.stringify({ claudeAiOauth: access }), { mode: '0600', createParents: true });
117
- let document = {}, revision = 'absent';
118
- try {
119
- const current = await cell.files.readWithInfo('/home/user/.claude.json');
120
- document = JSON.parse(Buffer.from(current.data).toString());
121
- if (!current.revision)
122
- throw new UsageError('Claude account configuration has no safe content revision.');
123
- revision = current.revision;
124
- }
125
- catch (error) {
126
- if (!(error instanceof Error && 'status' in error && error.status === 404))
127
- throw error;
128
- }
129
- await cell.files.patch({ path: '/home/user/.claude.json', content: JSON.stringify({ ...document, oauthAccount: config.oauthAccount }), expectedRevision: revision, mode: '0600', createParents: true }, { signal: ctx.signal });
130
- complete = true;
152
+ await mergeClaudeAccount(cell, config.oauthAccount, ctx.signal);
131
153
  return metadata;
132
154
  }
133
155
  finally {
@@ -6,6 +6,7 @@ import { json } from "./format.js";
6
6
  import { UsageError } from "./errors.js";
7
7
  import { cellConnector, PtyOutputCursor } from "./agent-terminal.js";
8
8
  import { rememberGitAgent } from "./agent-git-commands.js";
9
+ import { rememberLaunch } from "./agent-live.js";
9
10
  import { agentName as gitAgentName } from "./agent-git.js";
10
11
  /** L9 can register target-specific handlers after detecting the installed applications. */
11
12
  export async function availableTargets(ctx) {
@@ -43,6 +44,7 @@ export const CENTER_HANDLERS = {
43
44
  const labels = { ...source.labels, shard_project: source.labels.shard_project || source.key.split('/')[0], agent: name,
44
45
  agent_pty_name: source.labels.agent_pty_name || agentName(source.key, source.labels) };
45
46
  const caps = workspaceCaps(values);
47
+ const started = Date.now();
46
48
  const result = await ctx.cloud().workspaces.fork(source.id, { key, labels, ...(caps ? { caps } : {}) }, {
47
49
  agentLabel: ctx.agentLabel, wait: values.wait === false ? false : { timeoutMs: waitTimeout(values), signal: ctx.signal },
48
50
  });
@@ -54,6 +56,8 @@ export const CENTER_HANDLERS = {
54
56
  throw new Error(`Fork ${key} has no independent agent session receipt`);
55
57
  }
56
58
  await ctx.cloud().workspaces.setRetention(result.workspace.id, { delete_after_idle_days: 7 });
59
+ if (agent)
60
+ rememberLaunch(ctx, result.workspace.id, started);
57
61
  await rememberGitAgent(ctx, result.workspace, './');
58
62
  out(ctx, `Forked ${key}: operation ${result.operation.id} ${result.operation.state}\n`, { workspace: result.workspace.data, operation: result.operation, ...(agent ? { agent } : {}) });
59
63
  return 0;
@@ -308,6 +308,9 @@ export async function showCenter(ctx, scope, options = {}) {
308
308
  }
309
309
  }
310
310
  export const commandCenter = async (ctx, values, [path, app]) => {
311
+ // `shard <path> claude|codex` names the agent: start (or reattach) it without the picker; --json starts it detached.
312
+ if (path && (app === 'claude' || app === 'codex'))
313
+ return agentAction(ctx, 'new', { ...values, path, app }, []);
311
314
  const scope = path ? projectIdentity(path) : undefined;
312
315
  if (ctx.json) {
313
316
  const data = await loadCenter(ctx);
@@ -79,7 +79,7 @@ export declare function openVolumes(values: readonly string[]): import('@shardfl
79
79
  export declare function workspaceCaps(values: Values): Caps | undefined;
80
80
  /** `--when-idle <duration>`: whole seconds from 0 (as soon as it is idle; 0.6.1) to 3600 (the API's after_seconds bounds). */
81
81
  export declare function whenIdleSeconds(text: string): number;
82
- export declare function waitTimeout(values: Values): number;
82
+ export declare function waitTimeout(values: Values, fallbackMs?: number): number;
83
83
  /**
84
84
  * `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key, across every
85
85
  * lifetime and purpose (sessions, drafts and test instances are hidden from the default list), preferring the live
package/dist/commands.js CHANGED
@@ -17,6 +17,7 @@ import { parse as parseYaml } from 'yaml';
17
17
  import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
18
18
  import { ExecSessionRunningError, ExecutionFailedError, ExecutionPendingError, InterruptedError, NotFoundError, UsageError } from "./errors.js";
19
19
  import { updateCheckDisabled } from "./update-check.js";
20
+ import { withLiveAgents } from "./agent-live.js";
20
21
  import { initTemplateYaml, recipeYaml } from "./template-yaml.js";
21
22
  import { BuildTimer, buildTimingJson, buildTimingText, progressLine } from "./timing.js";
22
23
  import { timingJson } from "./timing.js";
@@ -174,9 +175,9 @@ export function whenIdleSeconds(text) {
174
175
  throw new UsageError(`--when-idle must be between 0s and 1h (got "${text}")`);
175
176
  return s;
176
177
  }
177
- export function waitTimeout(values) {
178
+ export function waitTimeout(values, fallbackMs = DEFAULT_WAIT_MS) {
178
179
  const t = str(values.timeout);
179
- return t === undefined ? DEFAULT_WAIT_MS : parseDuration(t, 'timeout');
180
+ return t === undefined ? fallbackMs : parseDuration(t, 'timeout');
180
181
  }
181
182
  /**
182
183
  * `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key, across every
@@ -814,6 +815,9 @@ export const KEY_HANDLERS = {
814
815
  next = page.nextCursor;
815
816
  cursor = next ?? undefined;
816
817
  } while (values.all === true && cursor);
818
+ // A complete listing also shows sessions the projection has not picked up yet (agent-live.ts).
819
+ if (!next && !str(values.cursor))
820
+ rows.splice(0, rows.length, ...await withLiveAgents(ctx, rows, { ...(state ? { state } : {}), labels }));
817
821
  out(ctx, rows.length ? table(['WORKSPACE', 'APP', 'STATE', 'TITLE'], rows.map(a => [a.workspace_key, a.app, a.state, a.title])) : 'No agent sessions.\n', { data: rows, next_cursor: next });
818
822
  if (!ctx.json && next)
819
823
  ctx.io.stderr.write(`more: shard agents list --cursor ${next}\n`);
package/dist/errors.d.ts CHANGED
@@ -28,6 +28,16 @@ declare class HintedError extends Error {
28
28
  export declare class UsageError extends HintedError {
29
29
  name: string;
30
30
  }
31
+ /**
32
+ * A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
33
+ * e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
34
+ */
35
+ export declare class CodedError extends HintedError {
36
+ name: string;
37
+ readonly code: string;
38
+ readonly details: Record<string, unknown> | undefined;
39
+ constructor(code: string, message: string, hint?: string, details?: Record<string, unknown>);
40
+ }
31
41
  /** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
32
42
  export declare class AuthConfigError extends HintedError {
33
43
  name: string;
package/dist/errors.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * stderr with the API's error code, message and request id; the process exit
4
4
  * code tells scripts what kind of failure it was (README "Exit codes").
5
5
  */
6
- import { CheckoutTimeoutError, DurabilityLostError, ExecStartError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
6
+ import { CheckoutTimeoutError, DurabilityLostError, ExecStartError, FilesystemSaveFailedError, FilesystemSaveTimeoutError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
7
7
  export const EXIT = {
8
8
  ok: 0,
9
9
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -36,6 +36,20 @@ class HintedError extends Error {
36
36
  export class UsageError extends HintedError {
37
37
  name = 'UsageError';
38
38
  }
39
+ /**
40
+ * A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
41
+ * e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
42
+ */
43
+ export class CodedError extends HintedError {
44
+ name = 'CodedError';
45
+ code;
46
+ details;
47
+ constructor(code, message, hint, details) {
48
+ super(message, hint);
49
+ this.code = code;
50
+ this.details = details;
51
+ }
52
+ }
39
53
  /** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
40
54
  export class AuthConfigError extends HintedError {
41
55
  name = 'AuthConfigError';
@@ -180,6 +194,8 @@ export function describeError(err) {
180
194
  return { code: 'timeout', message: err.message, last_state: err.session.state, details: { session_id: err.session.session_id } };
181
195
  if (err instanceof UsageError)
182
196
  return { code: 'usage', message: err.message };
197
+ if (err instanceof CodedError)
198
+ return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}) };
183
199
  if (err instanceof CheckoutTimeoutError) {
184
200
  return { code: 'timeout', message: `${err.message}; the checkout stays open (pay at its URL, then check with: shard billing status)`, last_state: err.checkout.status, details: { checkout_id: err.checkout.id, status: err.checkout.status, url: err.checkout.url } };
185
201
  }
@@ -190,6 +206,11 @@ export function describeError(err) {
190
206
  return { code: 'usage', message: err.message };
191
207
  if (err instanceof TemplateBuildTimeoutError)
192
208
  return { code: 'timeout', message: `${err.message}; follow it with: shard templates builds get ${err.build.id}`, last_state: err.build.state };
209
+ // machine/template save --wait (0.13.3+): the save ended without its version, or is still building at the deadline.
210
+ if (err instanceof FilesystemSaveFailedError)
211
+ return { code: 'filesystem_save_failed', message: err.message, retryable: true, details: { level: err.state.level, version: err.version } };
212
+ if (err instanceof FilesystemSaveTimeoutError)
213
+ return { code: 'timeout', message: `${err.message}; check with shard ${err.state.level}`, last_state: err.state.state, details: { level: err.state.level, version: err.version } };
193
214
  if (err instanceof TemplateUploadError)
194
215
  return { code: 'upload_failed', message: err.message, ...(err.status ? { status: err.status } : {}), details: { sha256: err.sha256, ...(err.code ? { storage_code: err.code } : {}) } };
195
216
  if (err instanceof AuthConfigError)
@@ -221,8 +242,10 @@ export function exitCodeFor(err, interrupted = false) {
221
242
  return EXIT.usage;
222
243
  if (err instanceof AuthConfigError || err instanceof MfaPendingError)
223
244
  return EXIT.auth;
224
- if (err instanceof TemplateBuildTimeoutError)
245
+ if (err instanceof TemplateBuildTimeoutError || err instanceof FilesystemSaveTimeoutError)
225
246
  return EXIT.timeout;
247
+ if (err instanceof FilesystemSaveFailedError)
248
+ return EXIT.operationFailed;
226
249
  if (err instanceof NotFoundError)
227
250
  return EXIT.notFound;
228
251
  if (err instanceof OperationTimeoutError || err instanceof CheckoutTimeoutError || isNamed(err, 'TimeoutError'))
@@ -1,2 +1,7 @@
1
+ import type { AgentFilesystem } from '@shardflux/sdk';
1
2
  import type { Handler } from './commands.js';
3
+ /** One line per status: what is in use and what is saving or failed, its platform base and setup cache. */
4
+ export declare function filesystemLine(state: AgentFilesystem & {
5
+ cache_scope?: string;
6
+ }): string;
2
7
  export declare const FILESYSTEM_HANDLERS: Record<string, Handler>;
@@ -1,16 +1,43 @@
1
+ import { waitTimeout } from "./commands.js";
1
2
  import { UsageError } from "./errors.js";
2
3
  import { projectIdentity, normalizeRemote } from "./project-identity.js";
3
4
  import { AGENT_HANDLERS } from "./agent-commands.js";
5
+ import { readSetupProgress } from "./assisted-setup.js";
4
6
  import { syncAgentConfig } from "./agent-config-sync.js";
7
+ import { progress } from "./progress.js";
5
8
  function remote() {
6
9
  const project = projectIdentity('./');
7
10
  return { project, repoRemote: project.remote ?? normalizeRemote(`file://${project.folder}`) };
8
11
  }
12
+ /** One line per status: what is in use and what is saving or failed, its platform base and setup cache. */
13
+ export function filesystemLine(state) {
14
+ const base = state.platform_base ? `; platform ${state.platform_base.slug}@${state.platform_base.version}${state.rebase_pending ? ', rebase pending' : ''}` : '';
15
+ return usageLine(state) + base + (state.cache_scope ? `; cache: ${state.cache_scope}` : '');
16
+ }
17
+ function usageLine(state) {
18
+ const current = state.version > 0 ? `version ${state.version}` : '';
19
+ if (state.state === 'building' && state.pending)
20
+ return `${state.level}: saving version ${state.pending.version}${current ? ` (${current} stays in use until it is ready)` : ''}`;
21
+ if (state.state === 'failed' && state.failure)
22
+ return `${state.level}: version ${state.failure.version} was not saved: ${state.failure.message}${current ? ` (${current} stays in use)` : ''}`;
23
+ if (current)
24
+ return `${state.level}: ${current} is ready`;
25
+ return state.level === 'machine' ? 'machine: nothing saved yet; change it in shard machine, then run shard machine save' : 'template: nothing saved yet; setup saves it after shard ./, or run shard template save';
26
+ }
9
27
  function output(ctx, state) {
10
- ctx.io.stdout.write(ctx.json ? JSON.stringify(state) + '\n' : `${state.level}: ${state.state}; version ${state.version}\n` +
11
- (state.pending ? `Saving version ${state.pending.version}. Run shard ${state.level} --json for progress.\n` : '') +
12
- (state.failure ? `${state.failure.message}\n` : '') +
13
- (state.template ? `${state.template.slug}@${state.template.version}\n` : 'No saved version yet.\n'));
28
+ ctx.io.stdout.write(ctx.json ? JSON.stringify(state) + '\n' : `${filesystemLine(state)}\n`);
29
+ return 0;
30
+ }
31
+ /** Save output: one line naming the version being saved, or with --wait the saved version (progress on stderr). */
32
+ async function saved(ctx, values, save, level) {
33
+ if (values.wait !== true && values.timeout !== undefined)
34
+ throw new UsageError('--timeout needs --wait');
35
+ const wait = values.wait === true ? { timeoutMs: waitTimeout(values, 600_000), onChange: (state) => { if (state.state === 'building' && state.pending)
36
+ progress(ctx, `Saving ${level} version ${state.pending.version}…`); } } : undefined;
37
+ const state = await save({ signal: ctx.signal, ...(wait ? { wait } : {}) });
38
+ if (ctx.json)
39
+ return output(ctx, state);
40
+ ctx.io.stdout.write(wait ? `${level}: version ${state.version} saved\n` : state.pending ? `${filesystemLine(state)}; shard ${level} shows when it is ready\n` : `${filesystemLine(state)}\n`);
14
41
  return 0;
15
42
  }
16
43
  const rollbackTo = (value) => {
@@ -28,34 +55,47 @@ export const FILESYSTEM_HANDLERS = {
28
55
  if (ctx.json || !ctx.io.stdin.isTTY)
29
56
  return output(ctx, state);
30
57
  const key = `machine/${state.organization_id}/v${state.version}`;
58
+ const existing = await cloud.workspaces.findByKey(key, { includeDeleted: false, signal: ctx.signal });
59
+ if (!existing)
60
+ progress(ctx, `Creating your machine VM (${key}); the first open takes about a minute…`);
61
+ else if (existing.data.observed_state !== 'running')
62
+ progress(ctx, `Resuming your machine VM (${key})…`);
31
63
  const ws = await cloud.workspaces.open({ key, template: state.template ? `${state.template.slug}@${state.template.version}` : 'coding',
32
- labels: { shard_layer: 'machine', shard_machine: state.organization_id, repo_path: '/home/user', app: 'claude', agent: 'machine' },
64
+ labels: { ...existing?.labels, shard_layer: 'machine', shard_machine: state.organization_id, repo_path: '/home/user', app: 'claude', agent: 'machine' },
33
65
  wait: { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal } });
34
- if (state.version === 0)
66
+ if (state.version === 0) {
67
+ progress(ctx, 'Syncing your Claude and Codex config into the machine…');
35
68
  await syncAgentConfig(ctx, ws);
69
+ }
36
70
  return AGENT_HANDLERS['agents open'](ctx, {}, [ws.key]);
37
71
  },
38
- async 'machine save'(ctx) {
72
+ async 'machine save'(ctx, values) {
39
73
  const cloud = ctx.cloud();
40
74
  const state = await cloud.machine.get({ signal: ctx.signal });
41
75
  const ws = await cloud.workspaces.findByKey(`machine/${state.organization_id}/v${state.version}`, { signal: ctx.signal }) ??
42
76
  (state.workspace_id ? await cloud.workspaces.get(state.workspace_id) : null);
43
77
  if (!ws)
44
78
  throw new UsageError('Open shard machine, change it, then run shard machine save');
45
- return output(ctx, await cloud.machine.save({ workspaceId: ws.id }, { signal: ctx.signal }));
79
+ return saved(ctx, values, options => cloud.machine.save({ workspaceId: ws.id }, options), 'machine');
46
80
  },
47
81
  async 'machine rollback'(ctx, values) {
48
82
  return output(ctx, await ctx.cloud().machine.rollback({ to: rollbackTo(values.to) }, { signal: ctx.signal }));
49
83
  },
50
84
  async template(ctx) {
51
- return output(ctx, await ctx.cloud().templates.getProject(remote().repoRemote, { signal: ctx.signal }));
85
+ const { project, repoRemote } = remote();
86
+ const state = await ctx.cloud().templates.getProject(repoRemote, { signal: ctx.signal });
87
+ const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
88
+ const progress = ws ? await readSetupProgress(ctx, ws.data) : undefined;
89
+ const attachments = ws ? await ctx.cloud().volumes.listAttached(ws.id) : undefined;
90
+ const mounted = attachments?.data.some((a) => a.mount_path === '/mnt/shardflux-cache' && a.mounted && a.volume.name === 'shardflux-setup-cache' && a.volume.org_shared);
91
+ return output(ctx, { ...state, cache_scope: progress?.cache_scope ?? (mounted ? 'account' : 'workspace') });
52
92
  },
53
- async 'template save'(ctx) {
93
+ async 'template save'(ctx, values) {
54
94
  const { project, repoRemote } = remote();
55
95
  const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
56
96
  if (!ws)
57
97
  throw new UsageError('Run shard ./ for this project before saving its template');
58
- return output(ctx, await ctx.cloud().templates.saveProject({ repoRemote, workspaceId: ws.id }, { signal: ctx.signal }));
98
+ return saved(ctx, values, options => ctx.cloud().templates.saveProject({ repoRemote, workspaceId: ws.id }, options), 'template');
59
99
  },
60
100
  async 'template rollback'(ctx, values) {
61
101
  return output(ctx, await ctx.cloud().templates.rollbackProject({ repoRemote: remote().repoRemote, to: rollbackTo(values.to) }, { signal: ctx.signal }));
package/dist/main.js CHANGED
@@ -46,7 +46,7 @@ export function abortableSleep(signal) {
46
46
  function reasonOf(info) {
47
47
  return typeof info.details?.reason === 'string' ? info.details.reason : undefined;
48
48
  }
49
- function hintFor(info, cred, file) {
49
+ function hintFor(info, cred, file, command = '') {
50
50
  if (info.code === 'timeout' && info.operation_id)
51
51
  return `the operation continues server side; keep waiting with: shard operations wait ${info.operation_id}`;
52
52
  if (info.code === 'operation_failed' && info.retryable) {
@@ -56,6 +56,10 @@ function hintFor(info, cred, file) {
56
56
  return `keep waiting with: shard operations wait ${info.operation_id}`;
57
57
  if (info.code === 'usage')
58
58
  return 'run "shard --help" or "shard <command> --help"';
59
+ if (info.code === 'filesystem_save_failed') {
60
+ const level = info.details?.level === 'template' ? 'template' : 'machine';
61
+ 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`;
62
+ }
59
63
  if (info.source === 'cell' && info.code === 'workspace_not_running')
60
64
  return 'open or resume the workspace first: shard workspaces open <key> --template <slug>';
61
65
  if (info.status === 401 && info.code === 'unauthenticated') {
@@ -67,6 +71,10 @@ function hintFor(info, cred, file) {
67
71
  return `the API key saved in ${file} was revoked or expired; save a new one: shard setup --new-key`;
68
72
  return undefined;
69
73
  }
74
+ // The edge firewall answers before the API: the contract body (edge_refused), or a bare 403 from an edge without it.
75
+ if (reasonOf(info) === 'edge_refused' || (info.code === 'protocol_error' && info.status === 403)) {
76
+ return `the edge firewall refused the request before it reached Shardflux; send the data through a command's stdin instead, for example: shard ws exec <workspace> --stdin <local-file> -- sh -c 'cat > "$1"' sh <path>`;
77
+ }
70
78
  if (info.code === 'step_up_required')
71
79
  return STEP_UP_HINT;
72
80
  if (info.code === 'mfa_required')
@@ -94,6 +102,9 @@ function hintFor(info, cred, file) {
94
102
  const reason = info.details?.reason;
95
103
  const index = typeof info.details?.index === 'number' ? ` #${info.details.index + 1}` : '';
96
104
  if (reason === 'revision_mismatch') {
105
+ // Only file commands take --expected-revision; elsewhere (a sign-in, config sync) shard chose the revision itself.
106
+ if (!command.startsWith('files '))
107
+ return 'the file changed while shard was updating it; run the same command again';
97
108
  const current = typeof info.details?.current_revision === 'string' ? info.details.current_revision : 'unknown';
98
109
  return `the file changed since that revision (current revision: ${current}); read it again, then retry with --expected-revision ${current}`;
99
110
  }
@@ -387,7 +398,7 @@ export async function run(argv, io, opts = {}) {
387
398
  }
388
399
  // An error that knows the command to continue with (an interrupted or still running execution) brings its own hint.
389
400
  const own = ownHint(err) ?? ((err instanceof InterruptedError || err instanceof ExecutionPendingError) && err.hint ? err.hint : undefined);
390
- const hint = command === 'feedback' ? feedbackHint(info, own ?? hintFor(info, cred, store.file)) : (own ?? hintFor(info, cred, store.file));
401
+ const hint = command === 'feedback' ? feedbackHint(info, own ?? hintFor(info, cred, store.file, command)) : (own ?? hintFor(info, cred, store.file, command));
391
402
  // --timing on a failure: the failed call's timing (err.timing, else the last recorded), before the error; with
392
403
  // --json a `timing` field next to `error`.
393
404
  const timing = showTiming ? traces.forError(err) : null;
@@ -0,0 +1,6 @@
1
+ import type { Ctx } from './commands.js';
2
+ /**
3
+ * One progress line on stderr before a step that can take a while (creating a VM, syncing config, signing in,
4
+ * starting an agent), so a long first run never looks hung. Silent with --json: stdout stays one document.
5
+ */
6
+ export declare function progress(ctx: Pick<Ctx, 'json' | 'io'>, line: string): void;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * One progress line on stderr before a step that can take a while (creating a VM, syncing config, signing in,
3
+ * starting an agent), so a long first run never looks hung. Silent with --json: stdout stays one document.
4
+ */
5
+ export function progress(ctx, line) {
6
+ if (!ctx.json)
7
+ ctx.io.stderr.write(`${line}\n`);
8
+ }
@@ -0,0 +1,5 @@
1
+ import type { Workspace } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ export declare const SETUP_CACHE_PATH = "/mnt/shardflux-cache";
4
+ /** Uses ordinary fenced shared-volume operations, after the managed PTY starts. */
5
+ export declare function prepareSetupCache(ctx: Ctx, workspace: Workspace): Promise<void>;
@@ -0,0 +1,11 @@
1
+ const name = 'shardflux-setup-cache';
2
+ export const SETUP_CACHE_PATH = '/mnt/shardflux-cache';
3
+ /** Uses ordinary fenced shared-volume operations, after the managed PTY starts. */
4
+ export async function prepareSetupCache(ctx, workspace) {
5
+ const cloud = ctx.cloud();
6
+ const wait = { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal };
7
+ const { volume } = await cloud.volumes.open({ name, projectId: workspace.data.project_id, wait });
8
+ if (volume.state !== 'available' || !volume.org_shared || volume.quota_gib !== 1)
9
+ throw new Error('account setup cache is not available');
10
+ await cloud.volumes.attach(workspace.id, { volumeId: volume.id, mountPath: SETUP_CACHE_PATH, wait });
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -47,7 +47,7 @@
47
47
  "smol-toml": "1.9.0",
48
48
  "snappyjs": "0.7.0",
49
49
  "yaml": "2.9.1",
50
- "@shardflux/sdk": "^0.18.0"
50
+ "@shardflux/sdk": "^0.18.2"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@eslint/js": "10.0.1",
@@ -58,7 +58,7 @@
58
58
  "typescript-eslint": "8.70.1"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@shardflux/menubar-darwin": "^0.1.0"
61
+ "@shardflux/menubar-darwin": "^0.1.1"
62
62
  },
63
63
  "scripts": {
64
64
  "build": "node scripts/build.mjs",