@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 +19 -0
- package/README.md +12 -9
- package/dist/agent-commands.d.ts +1 -1
- package/dist/agent-commands.js +11 -5
- package/dist/agent-config-files.js +2 -1
- package/dist/agent-config-report.d.ts +8 -0
- package/dist/agent-config-report.js +21 -0
- package/dist/agent-config-sync.js +3 -2
- package/dist/agent-live.d.ts +29 -0
- package/dist/agent-live.js +82 -0
- package/dist/agent-repository.d.ts +12 -0
- package/dist/agent-repository.js +92 -16
- package/dist/agent-target-commands.js +31 -2
- package/dist/args.d.ts +1 -1
- package/dist/args.js +7 -4
- package/dist/assisted-setup.d.ts +3 -1
- package/dist/assisted-setup.js +4 -1
- package/dist/claude-cloud-login.d.ts +7 -1
- package/dist/claude-cloud-login.js +36 -14
- package/dist/command-center-actions.js +4 -0
- package/dist/command-center.js +5 -2
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js +6 -2
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +25 -2
- package/dist/filesystem-commands.d.ts +3 -0
- package/dist/filesystem-commands.js +38 -9
- package/dist/main.js +13 -2
- package/dist/menubar.d.ts +3 -2
- package/dist/menubar.js +19 -7
- package/dist/progress.d.ts +6 -0
- package/dist/progress.js +8 -0
- package/package.json +3 -3
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
|
|
46
|
+
npm i -g shardflux
|
|
47
47
|
shard --help
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
package/dist/agent-commands.d.ts
CHANGED
|
@@ -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>;
|
package/dist/agent-commands.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
306
|
-
ctx.io.stderr.write(
|
|
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>;
|
package/dist/agent-repository.js
CHANGED
|
@@ -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
|
|
73
|
-
|
|
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(
|
|
158
|
+
? targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app'), values.remove === true)
|
|
143
159
|
: [];
|
|
144
|
-
|
|
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
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.
|
|
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: '
|
|
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: {} };
|
package/dist/assisted-setup.d.ts
CHANGED
|
@@ -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;
|
package/dist/assisted-setup.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/dist/command-center.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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);
|
package/dist/commands.d.ts
CHANGED
|
@@ -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 ?
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
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().
|
|
157
|
+
if (store.read().menubar_stopped === true)
|
|
157
158
|
return undefined;
|
|
158
159
|
try {
|
|
159
|
-
|
|
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;
|
package/dist/progress.js
ADDED
|
@@ -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.
|
|
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.
|
|
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.
|
|
61
|
+
"@shardflux/menubar-darwin": "^0.1.1"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
64
|
"build": "node scripts/build.mjs",
|