@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.4
4
+
5
+ - Cloud Claude sign-in and reopening the machine editor preserve existing workspace labels while updating their own labels.
6
+ - `shard billing status` on the signup trial suggests `shard billing upgrade pro` when the live catalog offers Pro (the trial is 60 days of Pro from catalog live-v10); otherwise it points to `shard billing plans`.
7
+ - Reuse an account-private setup cache across projects after the agent starts; report cache scope with `shard template --json`.
8
+ - Machine and project filesystem status shows the current platform base and whether a rebase is pending.
9
+
10
+ ## 0.13.3
11
+
12
+ - `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.
13
+ - 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.
14
+ - 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.
15
+ - `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.
16
+ - `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.
17
+ - `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.
18
+ - `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.
19
+ - `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.
20
+ - 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`.
21
+ - A `revision_mismatch` suggests `--expected-revision` only for the file commands that take it; elsewhere it says to run the same command again.
22
+ - Config sync reports project entries keyed by paths on this computer (Codex `config.projects.*`) as one summary line.
23
+ - 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 9+).
24
+
3
25
  ## 0.13.2
4
26
 
5
27
  - 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.
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.
@@ -264,9 +264,9 @@ plan only when Stripe confirms the payment, so `--wait` polls until the subscrip
264
264
  open). An organization that already has a subscription changes plans in the billing portal: the
265
265
  command prints the portal URL instead (exit 0).
266
266
 
267
- On the signup trial (60 days at Developer limits, no card), `billing status` shows when it ends and
267
+ On the signup trial (60 days of Pro, no card), `billing status` shows when it ends and
268
268
  what follows (0.6.2+): `plan trial, ends 2026-11-29 14:03:00Z, then free (trial, catalog ...)`, then
269
- `keep these limits after the trial: shard billing upgrade developer`. A subscription outranks the
269
+ `keep these limits after the trial: shard billing upgrade pro` (0.13.4+). A subscription outranks the
270
270
  trial and ends it. The trial is granted, never bought, so `billing plans` leaves it out.
271
271
 
272
272
  `billing overage` shows and changes opt-in overage (owners and billing members). With it on,
@@ -283,7 +283,7 @@ already charged this period (`spend_cap_below_charges`); a plan without overage
283
283
  ```text
284
284
  $ shard billing overage on --cap 9
285
285
  Acme: overage is on, spend cap $9.00 per billing period ($1.00 to $9.00, the plan price).
286
- Rates past the allowances: $0.03 per RAM GiB-hour, $0.12 per CPU-hour, charged on the next invoice (this period: shard usage).
286
+ Rates past the allowances: $0.015 per RAM GiB-hour, $0.04 per CPU-hour, charged on the next invoice (this period: shard usage).
287
287
  Saved (policy version 1).
288
288
  ```
289
289
 
@@ -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
@@ -828,12 +828,12 @@ amount:
828
828
  ```text
829
829
  overage accruing (usage past the allowances is charged on the next invoice)
830
830
  spend cap $9.00 (at most $9.00, the plan price)
831
- overage charges $2.40 of $9.00 (26.67%), $6.60 left; billed on the next invoice
831
+ overage charges $1.10 of $9.00 (12.22%), $7.90 left; billed on the next invoice
832
832
  cap reached 2026-09-29 15:02:46Z (projected at this period's average use)
833
833
 
834
- OVERAGE UNIT PAST ALLOWANCE BILLED RATE AMOUNT
835
- ram_gib_hours gib_hours 70 60 $0.03 $1.80
836
- cpu_hours hours 5 5 $0.12 $0.60
834
+ OVERAGE UNIT PAST ALLOWANCE BILLED RATE AMOUNT
835
+ ram_gib_hours gib_hours 70 60 $0.015 $0.90
836
+ cpu_hours hours 5 5 $0.04 $0.20
837
837
  ```
838
838
 
839
839
  `--json` prints the API's summary, with `spend_cap` and `exhausted_reason`. Owners and billing members turn overage on
@@ -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
@@ -1403,12 +1403,15 @@ export const ACCOUNT_HANDLERS = {
1403
1403
  const account = ctx.account();
1404
1404
  const org = await resolveOrg(ctx, account, values);
1405
1405
  const s = await account.billing.subscription(org.id);
1406
- // The trial has Developer limits: Developer keeps them (a subscription outranks the trial and ends it).
1406
+ // The trial has Pro's limits and allowances (catalog live-v10): Pro keeps them (a subscription outranks the trial and ends it).
1407
+ // Name Pro only while the live catalog sells it; a catalog without Pro gets the general hint.
1408
+ const trialToPro = !s.subscription && s.purchase_available && s.plan?.plan_key === 'trial'
1409
+ && (await fetchBillingCatalog({ baseUrl: ctx.apiUrl, fetch: ctx.accountOptions().fetch ?? fetch }).catch(() => null))?.plans?.some((p) => p.plan_key === 'pro') === true;
1407
1410
  const next = s.subscription
1408
1411
  ? 'manage it: shard billing portal'
1409
1412
  : s.purchase_available
1410
- ? s.plan?.plan_key === 'trial'
1411
- ? 'keep these limits after the trial: shard billing upgrade developer'
1413
+ ? trialToPro
1414
+ ? 'keep these limits after the trial: shard billing upgrade pro'
1412
1415
  : 'buy a plan: shard billing plans, then shard billing upgrade <plan>'
1413
1416
  : '';
1414
1417
  out(ctx, `${billingStatusText(s, org)}${next ? `${next}\n` : ''}`, s);
@@ -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, deferSetup?: boolean): Promise<PtySession>;
7
7
  export declare const launchProject: Handler;
8
8
  export declare const AGENT_HANDLERS: Record<string, Handler>;
@@ -1,3 +1,4 @@
1
+ import { prepareSetupCache } from "./setup-cache.js";
1
2
  import { agentReachability } from "./agent-reachability.js";
2
3
  import { configureCredentialFill, watchCredentialPrompts } from "./credential-prompts.js";
3
4
  import { createHash } from 'node:crypto';
@@ -15,7 +16,9 @@ import { startAgentConfigTunnels, syncAgentConfig } from "./agent-config-sync.js
15
16
  import { rememberGitAgent } from "./agent-git-commands.js";
16
17
  import { projectWorkspacePath, seedAgentRepository, workspaceRepositoryPath } from "./agent-repository.js";
17
18
  import { agentName } from "./agent-git.js";
18
- import { cloudClaudeLogin, ensureCloudClaude, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
19
+ import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
20
+ import { progress } from "./progress.js";
21
+ import { rememberLaunch } from "./agent-live.js";
19
22
  const active = (session) => session.state === 'running' || session.state === 'starting';
20
23
  /** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
21
24
  export function disambiguatedSlug(slug, identity) {
@@ -37,11 +40,11 @@ export async function projectTemplate(ctx, remote) {
37
40
  throw error;
38
41
  }
39
42
  }
40
- export async function agentPty(cell, app, agent, approvals, term, signal, project, setupHash, cwd, ptyName) {
43
+ export async function agentPty(cell, app, agent, approvals, term, signal, project, setupHash, cwd, ptyName, onLaunch, deferSetup = false) {
41
44
  const id = `shard-${ptyName || agent}-${app}`;
42
45
  // Arrival and changed locks queue the same durable guest job even when the
43
46
  // native agent is already alive. Setup never becomes its first user turn.
44
- if (setupHash) {
47
+ if (setupHash && !deferSetup) {
45
48
  const queued = await cell.exec.run(['sf-agent', 'setup-start', '--deps-hash', setupHash], { timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}) });
46
49
  if (queued.exitCode !== 0)
47
50
  throw new Error(`project setup could not be queued: ${queued.stderr.trim() || `exit ${queued.exitCode}`}`);
@@ -55,8 +58,9 @@ export async function agentPty(cell, app, agent, approvals, term, signal, projec
55
58
  if (!missing(error))
56
59
  throw error;
57
60
  }
61
+ onLaunch?.();
58
62
  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
- env: { TERM: term, ...(project ? { SF_PROJECT_NAME: project } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
63
+ env: { TERM: term, ...(project ? { SF_PROJECT_NAME: project } : {}), ...(setupHash && deferSetup ? { SF_SETUP_DEFERRED: '1' } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
60
64
  });
61
65
  if (launch.exitCode !== 0)
62
66
  throw new Error(`agent launch failed: ${launch.stderr.trim() || `exit ${launch.exitCode}`}`);
@@ -84,14 +88,26 @@ async function terminal(ctx, ws, app, agent, project, values, setupHash, cwd = w
84
88
  const tunnels = attach ? await startAgentConfigTunnels(ctx, ws) : null;
85
89
  try {
86
90
  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);
91
+ // A cold launch is remembered before it starts, so the listing can show it before the projection does (agent-live.ts).
92
+ 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}…`); }, true);
93
+ // The real agent is running before any cache provisioning or mount wait.
94
+ const preparation = prepareSetupCache(ctx, ws).catch((error) => {
95
+ ctx.io.stderr.write(`Account setup cache unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
96
+ }).then(async () => {
97
+ if (!setupHash)
98
+ return;
99
+ const queued = await cell.exec.run(['sf-agent', 'setup-start', '--deps-hash', setupHash], { cwd, timeoutMs: 30_000, maxOutputBytes: 4096, signal: ctx.signal });
100
+ if (queued.exitCode !== 0)
101
+ throw new Error(`project setup could not be queued: ${queued.stderr.trim() || `exit ${queued.exitCode}`}`);
102
+ });
88
103
  store.save(project, app);
89
104
  if (!attach) {
105
+ await preparation;
90
106
  ctx.io.stdout.write(json({ workspace: ws.data, session }));
91
107
  return 0;
92
108
  }
93
109
  ctx.io.stderr.write(`Shardflux · ${ws.key}\nDetach: Ctrl-] then d\n`);
94
- return await attachTerminal({
110
+ const attached = attachTerminal({
95
111
  ...(credentialFill ? { credentialPrompts: (ask, signal) => watchCredentialPrompts(ctx, ws, ask, signal) } : {}),
96
112
  io: ctx.io, signal: ctx.signal, connect: cellConnector(cell, session.session_id),
97
113
  wake: (signal) => ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal }),
@@ -104,8 +120,11 @@ async function terminal(ctx, ws, app, agent, project, values, setupHash, cwd = w
104
120
  done();
105
121
  }),
106
122
  });
123
+ const [code] = await Promise.all([attached, preparation]);
124
+ return code;
107
125
  }
108
126
  finally {
127
+ cell.close();
109
128
  await tunnels?.stop();
110
129
  }
111
130
  }
@@ -212,8 +231,10 @@ export const AGENT_HANDLERS = {
212
231
  ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
213
232
  const projectKey = typeof values.project === 'string' ? values.project : ws.labels.repo_remote || ws.labels.shard_project || ws.key;
214
233
  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);
234
+ if (app === 'claude' && !await cloudClaudeLogin(ctx)) {
235
+ progress(ctx, 'Signing in to cloud Claude (once per account)…');
236
+ await loginClaudeInWorkspace(ctx, ws);
237
+ }
217
238
  if (!ctx.json && values.detach !== true)
218
239
  requireTerminal(ctx);
219
240
  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`);
@@ -76,8 +76,15 @@ export const AGENT_CREDENTIAL_HANDLERS = {
76
76
  }
77
77
  requireClaudeTerminal(ctx);
78
78
  const project = projectIdentity('./');
79
- const ws = typeof values.workspace === 'string' ? await resolveWorkspace(ctx, values.workspace)
80
- : await ctx.cloud().workspaces.open({ key: `${project.slug}/main`, template: 'coding', labels: projectLabels(project, 'main', 'claude'), wait: { signal: ctx.signal, timeoutMs: ctx.wakeTimeoutMs } });
79
+ let ws;
80
+ if (typeof values.workspace === 'string')
81
+ ws = await resolveWorkspace(ctx, values.workspace);
82
+ else {
83
+ const cloud = ctx.cloud();
84
+ const key = `${project.slug}/main`;
85
+ const current = await cloud.workspaces.findByKey(key, { includeDeleted: false, signal: ctx.signal });
86
+ ws = await cloud.workspaces.open({ key, template: 'coding', labels: { ...current?.labels, ...projectLabels(project, 'main', 'claude') }, wait: { signal: ctx.signal, timeoutMs: ctx.wakeTimeoutMs } });
87
+ }
81
88
  output(ctx, await loginClaudeInWorkspace(ctx, ws));
82
89
  return 0;
83
90
  },
@@ -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.