@shardflux/cli 0.13.1 → 0.13.3

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.3
4
+
5
+ - `shard ./` seeds the workspace repository with the checked-out branch and its upstream only, not every local branch, tag and stash. When that history is still above 256 MiB, the workspace fetches the branch from origin with its own Git credentials and receives only the commits origin does not have yet. The workspace branch tracks its origin branch.
6
+ - Cloud Claude sign-in merges the account into the workspace's `~/.claude.json` on its latest content, also when the platform delivers the new login to that workspace at the same moment.
7
+ - A request the edge firewall refuses (`403 forbidden`, reason `edge_refused`, or a bare `403`) comes with a hint that sends the data through a command's stdin instead.
8
+ - `shard <path> claude|codex` starts (or reattaches) that agent directly instead of opening the command center's start picker; with `--json` it starts the agent detached and prints the session.
9
+ - `shard agents list` shows an agent this computer started in the last five minutes before the API's session list has picked it up, read from the agent's workspace.
10
+ - `shard machine` prints a progress line for each long step of its first open: creating the machine VM, syncing Claude and Codex config, signing in to cloud Claude, starting the agent.
11
+ - `shard agents targets <agent>` says what it configured where: the SSH host and the file it lives in (included from `~/.ssh/config`), and the Claude Desktop and Codex app entries with their files.
12
+ - `shard machine save` and `shard template save` print one line naming the version being saved; `--wait` returns once that version is ready (or failed, exit 6 with `filesystem_save_failed`), with progress on stderr; `--timeout` bounds the wait (default 10 minutes). `shard machine` and `shard template` print one status line.
13
+ - Git history above the first copy's 256 MiB limit fails with `repository_too_large` and its next step in the hint, not `internal_error`.
14
+ - A `revision_mismatch` suggests `--expected-revision` only for the file commands that take it; elsewhere it says to run the same command again.
15
+ - Config sync reports project entries keyed by paths on this computer (Codex `config.projects.*`) as one summary line.
16
+ - The command center does not show a setup step that `.shardflux/setup.yaml` marks `required: false` as the setup failure while the template saves, and reads skipped steps (coding template 8+).
17
+
18
+ ## 0.13.2
19
+
20
+ - On macOS, `shard` starts the menu bar by itself (with launch at login) the first time it runs; `shard menubar stop` turns that off and `shard menubar start` turns it back on.
21
+
3
22
  ## 0.13.1
4
23
 
5
24
  - `npx @shardflux/cli` runs `shard` again: the package exposes one command. The Git remote helper that `shard` uses for agent branches runs from a private shim on the PATH of the git commands `shard` starts.
package/README.md CHANGED
@@ -43,17 +43,16 @@ Bulk transfers batch writes automatically. Active transfers retain their byte po
43
43
  ## Install
44
44
 
45
45
  ```sh
46
- npm install -g @shardflux/cli
46
+ npm i -g shardflux
47
47
  shard --help
48
48
  ```
49
49
 
50
- Or run it without installing: `npx @shardflux/cli --help`. The unscoped
51
- [`shardflux`](https://www.npmjs.com/package/shardflux) package installs the same CLI as
52
- `shardflux` and `shard`; install one package or the other globally, not both.
50
+ The [`shardflux`](https://www.npmjs.com/package/shardflux) package installs the `shard` command.
51
+ Use `shard` for your projects, machine, templates and account.
53
52
 
54
53
  ## Quick start
55
54
 
56
- Start your coding agent on a local project (0.13.0+):
55
+ Start your coding agent on a local project (0.13.2+):
57
56
 
58
57
  ```sh
59
58
  shard setup
@@ -63,7 +62,8 @@ shard ./
63
62
 
64
63
  Choose Claude Code or Codex. Claude asks for one cloud sign-in per Shardflux account; Codex uses your copied login.
65
64
  The agent is usable immediately while setup prepares the project in the background. `shard` opens the command
66
- center for every project; `shard ./` returns to this project. See [Cloud agents](https://docs.shardflux.dev/guides/cloud-agents).
65
+ center for every project; `shard ./` returns to this project. On macOS the menu bar starts automatically the first
66
+ time `shard` runs, with launch at login enabled; `shard menubar stop` turns it off. See [Cloud agents](https://docs.shardflux.dev/guides/cloud-agents).
67
67
 
68
68
  For application workspace commands, `shard signup` creates an account and saves a project key, or set
69
69
  `SHARDFLUX_API_KEY` to a key from the console and run `shard login`. `shard --help` lists every command.
@@ -351,7 +351,7 @@ and the session renewals of step-up and password changes).
351
351
  `shard` tells you when a newer version exists, on stderr after the command's output:
352
352
 
353
353
  ```text
354
- @shardflux/cli 0.5.0 is outdated: 0.6.0 is available. Update: npm install -g @shardflux/cli@latest
354
+ @shardflux/cli 0.5.0 is outdated: 0.6.0 is available. Update: npm i -g shardflux
355
355
  ```
356
356
 
357
357
  or that the API no longer supports it (`... is no longer supported by the Shardflux API (minimum
@@ -1114,12 +1114,12 @@ Use `shard ws computer paste-text <key> - --paste-chord shift+Insert --screensho
1114
1114
 
1115
1115
  Template services accept `initialized_cpu` with `compatibility`, `reset`, and `timeout_seconds`. Use `cpu-service-v1` for immutable initialization, or `cpu-service-fresh-inputs-v1` with `fresh_env_names` for values supplied at each open. Declare an unprivileged user and command readiness. Each open runs the reset script and its owned startup hooks before returning. Initialization-dependent and secret inputs use ordinary startup.
1116
1116
 
1117
- ### Waiting agent reachability (0.13.0+)
1117
+ ### Waiting agent reachability (0.13.2+)
1118
1118
 
1119
1119
  Set `--reachable 30m` when opening a workspace to choose how long waiting agents stay reachable through connections they opened. See [reachability settings](https://docs.shardflux.dev/limits#cloud-agent-reachability). Incoming replies wake the parked workspace, which answers them and parks again. These wakes count as parked time; agent work counts as awake time.
1120
1120
 
1121
1121
 
1122
- ## Cloud agents (0.13.0+)
1122
+ ## Cloud agents (0.13.2+)
1123
1123
 
1124
1124
  Run `shard ./` in your project to start Claude Code or Codex, usable immediately while setup runs in the background.
1125
1125
  Your **machine** carries credentials, tools, dotfiles, logins and agent config; the project's **template** carries
@@ -1127,6 +1127,9 @@ toolchains, dependencies, services and project secrets. Both are private filesys
1127
1127
  as ordinary files. Browser logins and Claude/Codex config belong to the machine. Set up tools and secrets normally inside the VM;
1128
1128
  your agent uses the same save/rollback commands there. Every agent starts from both. Save or roll back with `shard machine save|rollback` and
1129
1129
  `shard template save|rollback`; changed files reach running agents live, software at the next start.
1130
+ `shard machine save --wait` and `shard template save --wait` return once the new version is ready (0.13.3+).
1131
+ `shard ./ codex` (or `claude`) starts that agent directly; `shard agents targets <project>/<agent>` lists each
1132
+ connection it configured and the file it is in (0.13.3+).
1130
1133
 
1131
1134
  `shard agents fork` or `f` in the command center copies the whole running state into an independent workspace and
1132
1135
  conversation. Open in your terminal, Claude Desktop or the Codex app, and the terminal Claude conversation in
@@ -3,6 +3,6 @@ import type { Ctx, Handler } from './commands.js';
3
3
  /** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
4
4
  export declare function disambiguatedSlug(slug: string, identity: string): string;
5
5
  export declare function projectTemplate(ctx: Ctx, remote: string | null): Promise<string>;
6
- export declare function agentPty(cell: CellClient, app: 'claude' | 'codex', agent: string, approvals: boolean, term: string, signal?: AbortSignal, project?: string, setupHash?: string, cwd?: string, ptyName?: string): Promise<PtySession>;
6
+ export declare function agentPty(cell: CellClient, app: 'claude' | 'codex', agent: string, approvals: boolean, term: string, signal?: AbortSignal, project?: string, setupHash?: string, cwd?: string, ptyName?: string, onLaunch?: () => void): Promise<PtySession>;
7
7
  export declare const launchProject: Handler;
8
8
  export declare const AGENT_HANDLERS: Record<string, Handler>;
@@ -15,7 +15,9 @@ import { startAgentConfigTunnels, syncAgentConfig } from "./agent-config-sync.js
15
15
  import { rememberGitAgent } from "./agent-git-commands.js";
16
16
  import { projectWorkspacePath, seedAgentRepository, workspaceRepositoryPath } from "./agent-repository.js";
17
17
  import { agentName } from "./agent-git.js";
18
- import { cloudClaudeLogin, ensureCloudClaude, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
18
+ import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
19
+ import { progress } from "./progress.js";
20
+ import { rememberLaunch } from "./agent-live.js";
19
21
  const active = (session) => session.state === 'running' || session.state === 'starting';
20
22
  /** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
21
23
  export function disambiguatedSlug(slug, identity) {
@@ -37,7 +39,7 @@ export async function projectTemplate(ctx, remote) {
37
39
  throw error;
38
40
  }
39
41
  }
40
- export async function agentPty(cell, app, agent, approvals, term, signal, project, setupHash, cwd, ptyName) {
42
+ export async function agentPty(cell, app, agent, approvals, term, signal, project, setupHash, cwd, ptyName, onLaunch) {
41
43
  const id = `shard-${ptyName || agent}-${app}`;
42
44
  // Arrival and changed locks queue the same durable guest job even when the
43
45
  // native agent is already alive. Setup never becomes its first user turn.
@@ -55,6 +57,7 @@ export async function agentPty(cell, app, agent, approvals, term, signal, projec
55
57
  if (!missing(error))
56
58
  throw error;
57
59
  }
60
+ onLaunch?.();
58
61
  const launch = await cell.exec.run(['sf-agent', 'run', app, '--name', agent, '--detach', ...(ptyName ? ['--pty-name', ptyName] : []), ...(approvals ? ['--approvals'] : []), ...(setupHash ? ['--setup', '--setup-deps-hash', setupHash] : [])], {
59
62
  env: { TERM: term, ...(project ? { SF_PROJECT_NAME: project } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
60
63
  });
@@ -84,7 +87,8 @@ async function terminal(ctx, ws, app, agent, project, values, setupHash, cwd = w
84
87
  const tunnels = attach ? await startAgentConfigTunnels(ctx, ws) : null;
85
88
  try {
86
89
  const credentialFill = await configureCredentialFill(ctx, ws);
87
- const session = await agentPty(cell, app, agent, values.approvals === true, ctx.io.env.TERM || 'xterm-256color', ctx.signal, ws.labels.shard_project || ws.key.split('/')[0], setupHash, cwd, ws.labels.agent_pty_name);
90
+ // A cold launch is remembered before it starts, so the listing can show it before the projection does (agent-live.ts).
91
+ const session = await agentPty(cell, app, agent, values.approvals === true, ctx.io.env.TERM || 'xterm-256color', ctx.signal, ws.labels.shard_project || ws.key.split('/')[0], setupHash, cwd, ws.labels.agent_pty_name, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); });
88
92
  store.save(project, app);
89
93
  if (!attach) {
90
94
  ctx.io.stdout.write(json({ workspace: ws.data, session }));
@@ -212,8 +216,10 @@ export const AGENT_HANDLERS = {
212
216
  ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
213
217
  const projectKey = typeof values.project === 'string' ? values.project : ws.labels.repo_remote || ws.labels.shard_project || ws.key;
214
218
  const app = values.app ?? new AgentStateStore(ctx.store.dir).app(projectKey, ws.labels.app === 'codex' ? 'codex' : 'claude');
215
- if (app === 'claude')
216
- await ensureCloudClaude(ctx, ws);
219
+ if (app === 'claude' && !await cloudClaudeLogin(ctx)) {
220
+ progress(ctx, 'Signing in to cloud Claude (once per account)…');
221
+ await loginClaudeInWorkspace(ctx, ws);
222
+ }
217
223
  if (!ctx.json && values.detach !== true)
218
224
  requireTerminal(ctx);
219
225
  if (ws.labels.shard_layer !== 'machine' && !(await savedMachine(ctx))?.template)
@@ -1,5 +1,6 @@
1
1
  import { readFile, readdir, realpath, stat } from 'node:fs/promises';
2
2
  import { join, relative, resolve } from 'node:path';
3
+ import { LOCAL_PATH_REASON } from "./agent-config-report.js";
3
4
  export const object = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
4
5
  export const text = (v) => typeof v === 'string' ? v : undefined;
5
6
  export const absent = (e) => e.code === 'ENOENT';
@@ -190,7 +191,7 @@ export function rewritePaths(value, home, remoteHome, files, issues, item) {
190
191
  const result = {};
191
192
  for (const [key, v] of Object.entries(value)) {
192
193
  if (key.startsWith('/') || key.startsWith('~/')) {
193
- issues.push({ item: `${item}.${key}`, reason: 'local project path is not synced by agent config' });
194
+ issues.push({ item: `${item}.${key}`, reason: LOCAL_PATH_REASON });
194
195
  continue;
195
196
  }
196
197
  const mapped = rewritePaths(v, home, remoteHome, files, issues, `${item}.${key}`);
@@ -0,0 +1,8 @@
1
+ import type { ConfigIssue } from './agent-config-files.js';
2
+ export declare const LOCAL_PATH_REASON = "local project path is not synced by agent config";
3
+ /**
4
+ * The human report of a config sync's skipped items. Per-project entries keyed by a laptop path (Codex
5
+ * `config.projects./Users/...`, Claude `projects./Users/...`) only describe this computer; they become one summary
6
+ * line per sync, and every other skipped item keeps its own line.
7
+ */
8
+ export declare function configIssueLines(issues: ConfigIssue[]): string[];
@@ -0,0 +1,21 @@
1
+ export const LOCAL_PATH_REASON = 'local project path is not synced by agent config';
2
+ /**
3
+ * The human report of a config sync's skipped items. Per-project entries keyed by a laptop path (Codex
4
+ * `config.projects./Users/...`, Claude `projects./Users/...`) only describe this computer; they become one summary
5
+ * line per sync, and every other skipped item keeps its own line.
6
+ */
7
+ export function configIssueLines(issues) {
8
+ const local = new Map();
9
+ const lines = [];
10
+ for (const issue of issues) {
11
+ const at = issue.reason === LOCAL_PATH_REASON ? issue.item.search(/\.(?:\/|~\/)/) : -1;
12
+ if (at > 0)
13
+ local.set(issue.item.slice(0, at), (local.get(issue.item.slice(0, at)) ?? 0) + 1);
14
+ else
15
+ lines.push(`Config skipped ${issue.item}: ${issue.reason}`);
16
+ }
17
+ const total = [...local.values()].reduce((a, b) => a + b, 0);
18
+ if (total)
19
+ lines.unshift(`Config kept ${total} project ${total === 1 ? 'entry' : 'entries'} for paths on this computer out of the VM (${[...local.keys()].join(', ')})`);
20
+ return lines;
21
+ }
@@ -6,6 +6,7 @@ import { AgentStateStore } from "./agent-state.js";
6
6
  import { UsageError } from "./errors.js";
7
7
  import { collectPlugins, collectUserFiles, object, readObject, readOptional, rewritePaths } from "./agent-config-files.js";
8
8
  import { digest, prepareMcp } from "./agent-config-mcp.js";
9
+ import { configIssueLines } from "./agent-config-report.js";
9
10
  const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
10
11
  const markerPath = '.config/shardflux/agent-config.json';
11
12
  const safePath = (path) => !path.split('/').some(p => !p || p === '.' || p === '..') &&
@@ -302,8 +303,8 @@ export async function syncAgentConfig(ctx, ws) {
302
303
  store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = manifest; doc.agent_config = all; });
303
304
  const result = { ...empty, copied: applied.copied, removed: applied.removed, skipped: issues, mcp, synced_at };
304
305
  if (!ctx.json) {
305
- for (const issue of issues)
306
- ctx.io.stderr.write(`Config skipped ${issue.item}: ${issue.reason}\n`);
306
+ for (const line of configIssueLines(issues))
307
+ ctx.io.stderr.write(`${line}\n`);
307
308
  for (const server of mcp)
308
309
  if (server.status !== 'copied')
309
310
  ctx.io.stderr.write(`MCP ${server.app}/${server.name}: ${server.status}${server.reason ? ` (${server.reason})` : ''}\n`);
@@ -0,0 +1,29 @@
1
+ import type { Agent } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ /** One record of `sf-agent list`: the guest's own session table, PID-reconciled on every read. */
4
+ interface GuestSession {
5
+ session_id: string;
6
+ app: 'claude' | 'codex';
7
+ state: Agent['state'];
8
+ since_unix_ms: number;
9
+ title: string;
10
+ remote_control_url: string;
11
+ }
12
+ /** Parses `sf-agent list` stdout; anything malformed yields no sessions rather than a wrong row. */
13
+ export declare function guestSessions(stdout: string): GuestSession[];
14
+ /** How long after a launch the listing may still miss it: the projection refreshes about once a minute. */
15
+ export declare const LAUNCH_WINDOW_MS: number;
16
+ /** Remembers that this computer started an agent in a workspace (a cold launch or a fork), for withLiveAgents. */
17
+ export declare function rememberLaunch(ctx: Pick<Ctx, 'store'>, workspaceId: string, now?: number): void;
18
+ /**
19
+ * GET /v1/agents is the cell's projection of each host's inventory, refreshed about once a minute, so an agent this
20
+ * computer started moments ago is not listed yet. For each running workspace where this computer started an agent in
21
+ * the last five minutes, after the newest session the listing has for it, read the guest's own table (`sf-agent list`,
22
+ * one short exec) and merge it: a guest row replaces the projected row with the same id. Nothing else is asked, so
23
+ * listing never keeps an idle workspace awake. A workspace whose guest cannot answer keeps its projected rows.
24
+ */
25
+ export declare function withLiveAgents(ctx: Ctx, agents: Agent[], filter: {
26
+ state?: string;
27
+ labels: Record<string, string>;
28
+ }, now?: number): Promise<Agent[]>;
29
+ export {};
@@ -0,0 +1,82 @@
1
+ import { AgentStateStore } from "./agent-state.js";
2
+ const STATES = new Set(['working', 'waiting', 'exited']);
3
+ /** Parses `sf-agent list` stdout; anything malformed yields no sessions rather than a wrong row. */
4
+ export function guestSessions(stdout) {
5
+ let doc;
6
+ try {
7
+ doc = JSON.parse(stdout);
8
+ }
9
+ catch {
10
+ return [];
11
+ }
12
+ if (!doc || typeof doc !== 'object')
13
+ return [];
14
+ const { ok, sessions } = doc;
15
+ if (ok !== true || !Array.isArray(sessions))
16
+ return [];
17
+ return sessions.filter((value) => {
18
+ if (!value || typeof value !== 'object')
19
+ return false;
20
+ const s = value;
21
+ return typeof s.session_id === 'string' && s.session_id.length > 0 && (s.app === 'claude' || s.app === 'codex') && typeof s.state === 'string' && STATES.has(s.state) &&
22
+ Number.isSafeInteger(s.since_unix_ms) && s.since_unix_ms > 0 && typeof s.title === 'string' && typeof s.remote_control_url === 'string';
23
+ });
24
+ }
25
+ /** How long after a launch the listing may still miss it: the projection refreshes about once a minute. */
26
+ export const LAUNCH_WINDOW_MS = 5 * 60_000;
27
+ /** Remembers that this computer started an agent in a workspace (a cold launch or a fork), for withLiveAgents. */
28
+ export function rememberLaunch(ctx, workspaceId, now = Date.now()) {
29
+ new AgentStateStore(ctx.store.dir).update(doc => {
30
+ const launches = { ...(doc.agent_launches && typeof doc.agent_launches === 'object' ? doc.agent_launches : {}) };
31
+ for (const [id, at] of Object.entries(launches))
32
+ if (typeof at !== 'number' || now - at > LAUNCH_WINDOW_MS)
33
+ delete launches[id];
34
+ launches[workspaceId] = now;
35
+ doc.agent_launches = launches;
36
+ });
37
+ }
38
+ const running = (w) => !w.deleted_at && w.observed_state === 'running';
39
+ /**
40
+ * GET /v1/agents is the cell's projection of each host's inventory, refreshed about once a minute, so an agent this
41
+ * computer started moments ago is not listed yet. For each running workspace where this computer started an agent in
42
+ * the last five minutes, after the newest session the listing has for it, read the guest's own table (`sf-agent list`,
43
+ * one short exec) and merge it: a guest row replaces the projected row with the same id. Nothing else is asked, so
44
+ * listing never keeps an idle workspace awake. A workspace whose guest cannot answer keeps its projected rows.
45
+ */
46
+ export async function withLiveAgents(ctx, agents, filter, now = Date.now()) {
47
+ const launches = new AgentStateStore(ctx.store.dir).read().agent_launches;
48
+ const recent = Object.entries(launches ?? {}).filter(([id, at]) => typeof at === 'number' && now - at < LAUNCH_WINDOW_MS &&
49
+ agents.filter(a => a.workspace_id === id).every(a => Date.parse(a.since) < at));
50
+ if (!recent.length)
51
+ return agents;
52
+ const live = await Promise.all(recent.map(async ([id]) => {
53
+ try {
54
+ const ws = await ctx.cloud().workspaces.get(id);
55
+ const w = ws.data;
56
+ if (!running(w) || !Object.entries(filter.labels).every(([k, v]) => w.labels?.[k] === v))
57
+ return [];
58
+ const cell = ws.cell({ agentLabel: ctx.agentLabel });
59
+ const result = await cell.exec.run(['sf-agent', 'list'], { timeoutMs: 10_000, maxOutputBytes: 1 << 20, signal: ctx.signal });
60
+ if (result.exitCode !== 0)
61
+ return [];
62
+ return guestSessions(result.stdout).map(s => ({
63
+ id: `${w.id}:${s.session_id}`, workspace_id: w.id, workspace_key: w.workspace_key, app: s.app, state: s.state,
64
+ since: new Date(s.since_unix_ms).toISOString(), title: s.title, remote_control_url: s.remote_control_url, labels: w.labels,
65
+ }));
66
+ }
67
+ catch (error) {
68
+ // A deleted workspace, or a guest that cannot answer, has nothing to add.
69
+ if (ctx.signal.aborted)
70
+ throw error;
71
+ return [];
72
+ }
73
+ }));
74
+ const rows = live.flat().filter(row => !filter.state || row.state === filter.state);
75
+ if (!rows.length)
76
+ return agents;
77
+ const merged = new Map(agents.map(a => [a.id, a]));
78
+ for (const row of rows)
79
+ merged.set(row.id, { ...merged.get(row.id), ...row });
80
+ // The API's order: workspace, then session.
81
+ return [...merged.values()].sort((a, b) => a.workspace_id.localeCompare(b.workspace_id) || a.id.localeCompare(b.id));
82
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
+ import { CodedError } from './errors.js';
4
+ import type { SyncCell } from './project-sync-transfer.js';
3
5
  export declare function projectWorkspacePath(project: {
4
6
  slug: string;
5
7
  }): string;
@@ -11,3 +13,13 @@ export declare function seedAgentRepository(ctx: Ctx, ws: Workspace, folder: str
11
13
  root?: string;
12
14
  populate?: boolean;
13
15
  }): Promise<void>;
16
+ /** History above this goes through the project's origin instead of the bounded files path. */
17
+ export declare const HISTORY_BUNDLE_LIMIT: number;
18
+ /** A history the first copy cannot carry (repository_too_large, 0.13.3+): the facts, and the next step as the hint. */
19
+ export declare function tooLarge(message: string, hint: string, branch: string, origin: string | null): CodedError;
20
+ /**
21
+ * Imports the checked-out branch's history into an empty workspace repository. Only HEAD, its branch and that branch's
22
+ * upstream are bundled: other branches, worktrees and stashes stay on this computer. When that is still above `limit`,
23
+ * the workspace fetches `origin` itself (with its own Git credentials) and receives only the commits origin lacks.
24
+ */
25
+ export declare function importProjectHistory(cell: SyncCell, folder: string, root: string, head: string, branch: string, origin: string | null, signal?: AbortSignal, limit?: number): Promise<void>;
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { join, resolve } from 'node:path';
5
+ import { CodedError } from "./errors.js";
5
6
  import { AgentStateStore } from "./agent-state.js";
6
7
  import { projectIdentity } from "./project-identity.js";
7
8
  import { localManifest, projectGit } from "./project-sync-manifest.js";
@@ -34,6 +35,87 @@ export async function seedAgentRepository(ctx, ws, folder, options = {}) {
34
35
  await initializeProjectGit(cell, project.folder, root, ctx.signal, options.populate, retry, message => { ctx.io.stderr.write(`${message}\n`); });
35
36
  state.update(doc => { const seeds = (doc.git_seeding ?? {}); delete seeds[ws.id]; doc.git_seeding = seeds; });
36
37
  }
38
+ /** History above this goes through the project's origin instead of the bounded files path. */
39
+ export const HISTORY_BUNDLE_LIMIT = 256 << 20;
40
+ async function optionalGit(folder, args, signal) {
41
+ try {
42
+ return (await projectGit(folder, args, signal)).trim();
43
+ }
44
+ catch (error) {
45
+ if (signal?.aborted)
46
+ throw error;
47
+ return '';
48
+ }
49
+ }
50
+ const tail = (text) => text.trim().split('\n').slice(-3).join(' ').slice(-400);
51
+ // Fetches one origin branch (or origin's HEAD) into an empty workspace repository and prints its commit.
52
+ const FETCH_ORIGIN = 'set -eu; git init -q "$1"; case "$3" in HEAD) dst=refs/shard-sync/origin ;; *) dst="refs/remotes/origin/${3#refs/heads/}" ;; esac; ' +
53
+ 'git -C "$1" -c core.hooksPath=/dev/null -c credential.interactive=false fetch -q --no-tags "$2" "+$3:$dst" >&2; git -C "$1" rev-parse --verify "$dst^{commit}"';
54
+ // Applies an optional bundle of HEAD (plus its branch and upstream) and points the workspace HEAD at the laptop's.
55
+ const APPLY_HISTORY = 'set -eu; git init -q "$1"; if [ -n "$2" ]; then git -C "$1" -c core.hooksPath=/dev/null fetch -q --update-head-ok "$2" HEAD:refs/shard-sync/initial ' +
56
+ '"+refs/heads/*:refs/heads/*" "+refs/tags/*:refs/tags/*" "+refs/remotes/*:refs/remotes/*"; fi; ' +
57
+ 'if [ -n "$4" ]; then git -C "$1" symbolic-ref HEAD "$4"; else git -C "$1" update-ref --no-deref HEAD "$3"; fi; git -C "$1" update-ref HEAD "$3"; git -C "$1" read-tree HEAD; ' +
58
+ 'for ref in refs/shard-sync/initial refs/shard-sync/origin; do if git -C "$1" rev-parse -q --verify "$ref" >/dev/null; then git -C "$1" update-ref -d "$ref"; fi; done';
59
+ /** A history the first copy cannot carry (repository_too_large, 0.13.3+): the facts, and the next step as the hint. */
60
+ export function tooLarge(message, hint, branch, origin) {
61
+ return new CodedError('repository_too_large', `${message}.`, hint, { limit_bytes: HISTORY_BUNDLE_LIMIT, branch, ...(origin ? { origin } : {}) });
62
+ }
63
+ /**
64
+ * Imports the checked-out branch's history into an empty workspace repository. Only HEAD, its branch and that branch's
65
+ * upstream are bundled: other branches, worktrees and stashes stay on this computer. When that is still above `limit`,
66
+ * the workspace fetches `origin` itself (with its own Git credentials) and receives only the commits origin lacks.
67
+ */
68
+ export async function importProjectHistory(cell, folder, root, head, branch, origin, signal, limit = HISTORY_BUNDLE_LIMIT) {
69
+ const upstream = branch ? await optionalGit(folder, ['rev-parse', '--symbolic-full-name', `${branch.slice(11)}@{upstream}`], signal) : '';
70
+ const tracked = upstream.startsWith('refs/remotes/') ? upstream : '';
71
+ const tips = ['HEAD', ...(branch ? [branch] : []), ...(tracked ? [tracked] : [])];
72
+ const dir = await mkdtemp(join(tmpdir(), 'shard-sync-git-'));
73
+ const local = join(dir, 'history.bundle'), remote = `/tmp/shard-sync-${randomUUID()}.bundle`;
74
+ try {
75
+ await projectGit(folder, ['bundle', 'create', local, ...tips], signal);
76
+ let bundle = true;
77
+ if ((await stat(local)).size > limit) {
78
+ const name = branch ? branch.slice(11) : 'HEAD';
79
+ if (!origin)
80
+ throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and the project has no network origin the workspace can clone`, 'add an origin remote, then run shard again', name, origin);
81
+ const ref = upstream.startsWith('refs/remotes/origin/') ? `refs/heads/${upstream.slice(20)}` : 'HEAD';
82
+ const fetched = await cell.exec.run(['sh', '-c', FETCH_ORIGIN, 'shard-sync', root, origin, ref], { timeoutMs: 600_000, maxOutputBytes: 8192, env: { GIT_TERMINAL_PROMPT: '0' }, signal });
83
+ const base = fetched.stdout.trim();
84
+ if (fetched.exitCode !== 0 || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(base)) {
85
+ throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and the workspace could not fetch ${origin}: ${tail(fetched.stderr)}`, 'give the machine access to the repository (GitHub: run gh auth login in shard machine), then run shard again', name, origin);
86
+ }
87
+ // Commits origin already has are not sent again; a base this computer does not know falls back to its upstream.
88
+ const exclude = await optionalGit(folder, ['cat-file', '-t', base], signal) === 'commit' ? base : tracked;
89
+ if (!exclude)
90
+ throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and shares no known commit with ${origin}`, 'push the branch, then run shard again', name, origin);
91
+ let onOrigin = true;
92
+ try {
93
+ await projectGit(folder, ['merge-base', '--is-ancestor', head, exclude], signal);
94
+ }
95
+ catch (error) {
96
+ if (signal?.aborted || error.code !== 1)
97
+ throw error;
98
+ onOrigin = false;
99
+ }
100
+ bundle = !onOrigin;
101
+ if (bundle) {
102
+ await rm(local, { force: true });
103
+ await projectGit(folder, ['bundle', 'create', local, ...tips.filter(tip => tip !== tracked), '--not', exclude], signal);
104
+ if ((await stat(local)).size > limit)
105
+ throw tooLarge(`Commits of ${name} that ${origin} does not have exceed the 256 MiB sync limit`, 'push them, then run shard again', name, origin);
106
+ }
107
+ }
108
+ if (bundle)
109
+ await writeSyncBlob(cell, remote, await readFile(local));
110
+ const result = await cell.exec.run(['sh', '-c', APPLY_HISTORY, 'shard-sync', root, bundle ? remote : '', head, branch], { timeoutMs: 60_000, maxOutputBytes: 4096, signal });
111
+ if (result.exitCode !== 0)
112
+ throw new Error(`initial Git bundle failed: ${result.stderr.trim()}`);
113
+ }
114
+ finally {
115
+ await rm(dir, { recursive: true, force: true });
116
+ await cell.files.remove(remote).catch(() => undefined);
117
+ }
118
+ }
37
119
  async function initializeProjectGit(cell, folder, root, signal, populate = false, retry = false, report = () => undefined) {
38
120
  let top;
39
121
  try {
@@ -69,22 +151,8 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
69
151
  throw error;
70
152
  }
71
153
  if (head && importHistory) {
72
- const dir = await mkdtemp(join(tmpdir(), 'shard-sync-git-'));
73
- const local = join(dir, 'history.bundle'), remote = `/tmp/shard-sync-${randomUUID()}.bundle`;
74
- try {
75
- await projectGit(folder, ['bundle', 'create', local, '--all'], signal);
76
- if ((await stat(local)).size > 256 << 20)
77
- throw new Error('Git history bundle exceeds the 256 MiB sync limit');
78
- await writeSyncBlob(cell, remote, await readFile(local));
79
- const script = 'set -eu; git init -q "$1"; git -C "$1" -c core.hooksPath=/dev/null fetch -q --update-head-ok "$2" HEAD:refs/shard-sync/initial "+refs/heads/*:refs/heads/*" "+refs/tags/*:refs/tags/*"; if [ -n "$4" ]; then git -C "$1" symbolic-ref HEAD "$4"; else git -C "$1" update-ref --no-deref HEAD "$3"; fi; git -C "$1" update-ref HEAD "$3"; git -C "$1" read-tree HEAD; git -C "$1" update-ref -d refs/shard-sync/initial';
80
- const result = await cell.exec.run(['sh', '-c', script, 'shard-sync', root, remote, head, branch], { timeoutMs: 60_000, maxOutputBytes: 4096, signal });
81
- if (result.exitCode !== 0)
82
- throw new Error(`initial Git bundle failed: ${result.stderr.trim()}`);
83
- }
84
- finally {
85
- await rm(dir, { recursive: true, force: true });
86
- await cell.files.remove(remote).catch(() => undefined);
87
- }
154
+ const remote = projectIdentity(folder).remote;
155
+ await importProjectHistory(cell, folder, root, head, branch, remote && !remote.startsWith('file:') ? remote : null, signal);
88
156
  }
89
157
  else if (importHistory) {
90
158
  const result = await cell.exec.run(['git', 'init', '-q', ...(branch.startsWith('refs/heads/') ? ['--initial-branch', branch.slice(11)] : []), root], { timeoutMs: 5000, signal });
@@ -111,6 +179,14 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
111
179
  const result = await cell.exec.run(['git', '-C', root, 'config', 'remote.origin.url', origin], { timeoutMs: 5000, signal });
112
180
  if (result.exitCode !== 0)
113
181
  throw new Error('could not copy Git origin');
182
+ // The imported branch keeps tracking its origin branch, as on this computer.
183
+ const upstream = importHistory && branch ? await optionalGit(folder, ['rev-parse', '--symbolic-full-name', `${branch.slice(11)}@{upstream}`], signal) : '';
184
+ if (upstream.startsWith('refs/remotes/origin/'))
185
+ for (const [key, value] of [[`branch.${branch.slice(11)}.remote`, 'origin'], [`branch.${branch.slice(11)}.merge`, `refs/heads/${upstream.slice(20)}`]]) {
186
+ const set = await cell.exec.run(['git', '-C', root, 'config', key, value], { timeoutMs: 5000, signal });
187
+ if (set.exitCode !== 0)
188
+ throw new Error('could not copy Git upstream');
189
+ }
114
190
  }
115
191
  // Ignore files must exist before the first remote scan; otherwise a
116
192
  // template's dependency outputs would look like agent-created source.
@@ -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.1";
1
+ export declare const CLI_VERSION = "0.13.3";
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.1';
15
+ export const CLI_VERSION = '0.13.3';
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;
@@ -6,7 +6,7 @@ import { agentAction, availableTargets } from "./command-center-actions.js";
6
6
  import { centerRows, loadCenter } from "./command-center-model.js";
7
7
  import { AnsiRenderer, renderCenter } from "./command-center-renderer.js";
8
8
  import { CenterKeys } from "./command-center-keys.js";
9
- import { menubarHint } from "./menubar.js";
9
+ import { ensureMenubar } from "./menubar.js";
10
10
  import { MenubarManager } from "./menubar.js";
11
11
  import { homedir } from 'node:os';
12
12
  import { retrySetupStep } from "./assisted-setup.js";
@@ -17,7 +17,7 @@ export async function showCenter(ctx, scope, options = {}) {
17
17
  const { HANDLERS } = await import("./handlers.js");
18
18
  const extra = [['m', 'merge'], ['k', 'keep'], ['d', 'discard']].filter(([, command]) => HANDLERS[`agents ${command}`]);
19
19
  const view = { rows: [], selected: undefined, filter: '', message: 'Loading agents…', extraKeys: extra.map(([key, command]) => `${key} ${command}`) };
20
- const hint = await menubarHint(ctx.store.dir, new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal }));
20
+ const hint = await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal }));
21
21
  if (hint)
22
22
  view.menubarHint = hint;
23
23
  const renderer = new AnsiRenderer(), keys = new CenterKeys();
@@ -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,5 @@
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. */
4
+ export declare function filesystemLine(state: AgentFilesystem): string;
2
5
  export declare const FILESYSTEM_HANDLERS: Record<string, Handler>;
@@ -1,16 +1,38 @@
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";
4
5
  import { syncAgentConfig } from "./agent-config-sync.js";
6
+ import { progress } from "./progress.js";
5
7
  function remote() {
6
8
  const project = projectIdentity('./');
7
9
  return { project, repoRemote: project.remote ?? normalizeRemote(`file://${project.folder}`) };
8
10
  }
11
+ /** One line per status: what is in use and what is saving or failed. */
12
+ export function filesystemLine(state) {
13
+ const current = state.version > 0 ? `version ${state.version}` : '';
14
+ if (state.state === 'building' && state.pending)
15
+ return `${state.level}: saving version ${state.pending.version}${current ? ` (${current} stays in use until it is ready)` : ''}`;
16
+ if (state.state === 'failed' && state.failure)
17
+ return `${state.level}: version ${state.failure.version} was not saved: ${state.failure.message}${current ? ` (${current} stays in use)` : ''}`;
18
+ if (current)
19
+ return `${state.level}: ${current} is ready`;
20
+ 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';
21
+ }
9
22
  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'));
23
+ ctx.io.stdout.write(ctx.json ? JSON.stringify(state) + '\n' : `${filesystemLine(state)}\n`);
24
+ return 0;
25
+ }
26
+ /** Save output: one line naming the version being saved, or with --wait the saved version (progress on stderr). */
27
+ async function saved(ctx, values, save, level) {
28
+ if (values.wait !== true && values.timeout !== undefined)
29
+ throw new UsageError('--timeout needs --wait');
30
+ const wait = values.wait === true ? { timeoutMs: waitTimeout(values, 600_000), onChange: (state) => { if (state.state === 'building' && state.pending)
31
+ progress(ctx, `Saving ${level} version ${state.pending.version}…`); } } : undefined;
32
+ const state = await save({ signal: ctx.signal, ...(wait ? { wait } : {}) });
33
+ if (ctx.json)
34
+ return output(ctx, state);
35
+ 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
36
  return 0;
15
37
  }
16
38
  const rollbackTo = (value) => {
@@ -28,21 +50,28 @@ export const FILESYSTEM_HANDLERS = {
28
50
  if (ctx.json || !ctx.io.stdin.isTTY)
29
51
  return output(ctx, state);
30
52
  const key = `machine/${state.organization_id}/v${state.version}`;
53
+ const existing = await cloud.workspaces.findByKey(key, { includeDeleted: false, signal: ctx.signal });
54
+ if (!existing)
55
+ progress(ctx, `Creating your machine VM (${key}); the first open takes about a minute…`);
56
+ else if (existing.data.observed_state !== 'running')
57
+ progress(ctx, `Resuming your machine VM (${key})…`);
31
58
  const ws = await cloud.workspaces.open({ key, template: state.template ? `${state.template.slug}@${state.template.version}` : 'coding',
32
59
  labels: { shard_layer: 'machine', shard_machine: state.organization_id, repo_path: '/home/user', app: 'claude', agent: 'machine' },
33
60
  wait: { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal } });
34
- if (state.version === 0)
61
+ if (state.version === 0) {
62
+ progress(ctx, 'Syncing your Claude and Codex config into the machine…');
35
63
  await syncAgentConfig(ctx, ws);
64
+ }
36
65
  return AGENT_HANDLERS['agents open'](ctx, {}, [ws.key]);
37
66
  },
38
- async 'machine save'(ctx) {
67
+ async 'machine save'(ctx, values) {
39
68
  const cloud = ctx.cloud();
40
69
  const state = await cloud.machine.get({ signal: ctx.signal });
41
70
  const ws = await cloud.workspaces.findByKey(`machine/${state.organization_id}/v${state.version}`, { signal: ctx.signal }) ??
42
71
  (state.workspace_id ? await cloud.workspaces.get(state.workspace_id) : null);
43
72
  if (!ws)
44
73
  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 }));
74
+ return saved(ctx, values, options => cloud.machine.save({ workspaceId: ws.id }, options), 'machine');
46
75
  },
47
76
  async 'machine rollback'(ctx, values) {
48
77
  return output(ctx, await ctx.cloud().machine.rollback({ to: rollbackTo(values.to) }, { signal: ctx.signal }));
@@ -50,12 +79,12 @@ export const FILESYSTEM_HANDLERS = {
50
79
  async template(ctx) {
51
80
  return output(ctx, await ctx.cloud().templates.getProject(remote().repoRemote, { signal: ctx.signal }));
52
81
  },
53
- async 'template save'(ctx) {
82
+ async 'template save'(ctx, values) {
54
83
  const { project, repoRemote } = remote();
55
84
  const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
56
85
  if (!ws)
57
86
  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 }));
87
+ return saved(ctx, values, options => ctx.cloud().templates.saveProject({ repoRemote, workspaceId: ws.id }, options), 'template');
59
88
  },
60
89
  async 'template rollback'(ctx, values) {
61
90
  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;
package/dist/menubar.d.ts CHANGED
@@ -26,7 +26,8 @@ export declare class MenubarManager {
26
26
  login(value: 'on' | 'off'): Promise<MenubarStatus>;
27
27
  start(configDir: string, apiUrl: string): Promise<MenubarStatus>;
28
28
  }
29
- /** Only the command center consumes this hint. It reads status, never resolves or installs the optional package. */
30
- export declare function menubarHint(dir: string, manager?: MenubarManager): Promise<string | undefined>;
29
+ /** On macOS, `shard` brings the menu bar up by itself (with launch at login) unless the user ran `shard menubar stop`.
30
+ * It never fails the command center: a missing optional package or a refused start just leaves it off. */
31
+ export declare function ensureMenubar(dir: string, apiUrl: string, manager?: MenubarManager, platform?: NodeJS.Platform): Promise<string | undefined>;
31
32
  export declare const MENUBAR_HANDLERS: Record<string, Handler>;
32
33
  export {};
package/dist/menubar.js CHANGED
@@ -148,22 +148,28 @@ export class MenubarManager {
148
148
  throw new UsageError(`Shardflux did not start. Check ${join(configDir, 'menubar.log')}.`);
149
149
  }
150
150
  }
151
- /** Only the command center consumes this hint. It reads status, never resolves or installs the optional package. */
152
- export async function menubarHint(dir, manager = new MenubarManager()) {
153
- if (process.platform !== 'darwin')
151
+ /** On macOS, `shard` brings the menu bar up by itself (with launch at login) unless the user ran `shard menubar stop`.
152
+ * It never fails the command center: a missing optional package or a refused start just leaves it off. */
153
+ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(), platform = process.platform) {
154
+ if (platform !== 'darwin')
154
155
  return undefined;
155
156
  const store = new AgentStateStore(dir);
156
- if (store.read().menubar_hint_shown === true)
157
+ if (store.read().menubar_stopped === true)
157
158
  return undefined;
158
159
  try {
159
- if ((await manager.status()).running)
160
+ const status = await manager.status();
161
+ if (status.running)
160
162
  return undefined;
163
+ const started = await manager.start(dir, apiUrl);
164
+ if (!started.running)
165
+ return undefined;
166
+ if (started.login !== 'on')
167
+ await manager.login('on').catch(() => undefined);
168
+ return 'Menu bar started · shard menubar stop to turn it off';
161
169
  }
162
170
  catch {
163
171
  return undefined;
164
172
  }
165
- store.update(doc => { doc.menubar_hint_shown = true; });
166
- return 'Menu bar: shard menubar start';
167
173
  }
168
174
  export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].map(action => {
169
175
  const handler = async (ctx, values) => {
@@ -171,6 +177,12 @@ export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].m
171
177
  throw new UsageError('--login must be on or off.');
172
178
  const manager = new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal });
173
179
  let status = action === 'start' ? await manager.start(ctx.store.dir, ctx.apiUrl) : action === 'stop' ? await manager.stop() : await manager.status();
180
+ // An explicit stop keeps `shard` from starting the menu bar again; an explicit start re-enables it.
181
+ if (action !== 'status')
182
+ new AgentStateStore(ctx.store.dir).update(doc => { if (action === 'stop')
183
+ doc.menubar_stopped = true;
184
+ else
185
+ delete doc.menubar_stopped; });
174
186
  if (values.login === 'on' || values.login === 'off')
175
187
  status = await manager.login(values.login);
176
188
  ctx.io.stdout.write(ctx.json ? json(status) : `Menu bar: ${status.running ? 'running' : 'stopped'}${status.installed ? ` · app ${status.version}` : ' · not installed'} · login ${status.login}\n`);
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
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.1"
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",