@shardflux/cli 0.15.0 → 0.15.2

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.2
4
+
5
+ - Updates show up the moment they are published: the latest version comes from npm's `latest` tag (what
6
+ `npm i -g shardflux` installs), checked at most every 12 hours with its ETag. The API still says which versions it
7
+ supports, and can hold back a release. Offline, nothing is printed. `shard update --check` and the menu bar's update
8
+ check use the same versions.
9
+ - The menu bar keeps up with npm by itself: after `npm i -g shardflux`, every `shard` command compares the installed app
10
+ with the one the package carries (two Info.plist reads, no network) and, when that one is newer, brings it in in the
11
+ background: a stopped app is replaced on disk, a running one is left to update itself (menu bar 0.3.2+) or, for
12
+ earlier apps, restarted on the new version when none of its windows is open. `shard update` swaps in an app the
13
+ package already carries without another install. App versions compare as semver (0.3.10 after 0.3.9).
14
+ - Installs menu bar 0.3.2 (`@shardflux/menubar-darwin ^0.3.2`).
15
+
16
+ ## 0.15.1
17
+
18
+ - `shard setup`, `shard signup` and `shard api-keys create` make keys with every workspace tool, `computer` included, so the saved key can watch and screenshot a workspace's desktop (`shard ws computer stream`, `screenshot`), as the first-run demo does. `shard setup` replaces a saved key from an earlier setup (every tool but `computer`) with one that has every tool; the old key keeps working until revoked. `--tool` lists `computer`.
19
+
20
+ - `shard agents open --in auto` opens a Claude conversation where you work: in Claude Desktop when it is installed (its own session, `claude://code/<session>`), else Claude Code in this terminal. With `--json` it prints `{target: "claude-desktop", url, app_bundle_id}` or `{target: "terminal", command}`. claude.ai opens with `--in web` (or a saved web target). Codex conversations open in the Codex app as before.
21
+
22
+ - `shard setup` and `shard signup` print "Starting your Codex remote device…" while they wait for it.
23
+
3
24
  ## 0.15.0
4
25
 
5
26
  - The onboarding activity report includes the conversation's turn state for Claude and Codex, including while its VM is suspended.
package/README.md CHANGED
@@ -258,10 +258,12 @@ shard auth totp recovery-codes
258
258
  "&lt;your name&gt;'s organization" (`--org-name` to choose); with several it lists them;
259
259
  2. the project: `--project`, the saved default, or the organization's only one; with none it creates
260
260
  "Default" (`--project-name`);
261
- 3. an API key with every workspace tool (`--tool` repeatable to narrow it), named
261
+ 3. an API key with every workspace tool, `computer` included (0.15.1+; `--tool` repeatable to narrow it), named
262
262
  `shard-cli <hostname>` (`--key-name`), saved in the credentials file and checked against the API.
263
263
 
264
- It is idempotent: a saved key that still works is kept (`--new-key` makes a new one). It prints what
264
+ It is idempotent: a saved key that still works is kept (`--new-key` makes a new one). A saved key from an earlier
265
+ `shard setup` (every tool but `computer`) is replaced with one that has every tool (0.15.1+); the old key keeps working
266
+ until you revoke it. It prints what
265
267
  it found or created and the next command. The key is never printed; `shard env` prints it for tools
266
268
  that read `SHARDFLUX_API_KEY`:
267
269
 
@@ -1261,7 +1263,7 @@ shard agents new --app claude --path ./ --prompt "What is wrong on this page?" -
1261
1263
 
1262
1264
  `--image <file|->` (0.15.0+, repeatable) attaches up to five PNG, JPEG, GIF or WebP images of up to 10 MiB each to that first message. They are written into the conversation VM under `/home/user/.shardflux/attachments/` and named in the message: Claude Code attaches them as images and Codex opens them with its image viewer. `--image -` reads one image, or PNG images back to back, from standard input; use a file for `--prompt-file` when images come from standard input.
1263
1265
 
1264
- Use `shard agents open <workspace> --in auto --json` to resolve the remembered app destination. The result includes a `url` or `app_bundle_id` when that app route is available; the caller opens it. Explicit `--in terminal` retains terminal attachment. The menu uses this resolver for new and existing conversations, without provider URLs in Swift.
1266
+ Use `shard agents open <workspace> --in auto --json` to resolve where a conversation opens: a saved target first; then, for Claude (0.15.1+), the conversation itself in Claude Desktop when it is installed (`{"target":"claude-desktop","url":"claude://code/session_…","app_bundle_id":…}`), else Claude Code in the terminal (`{"target":"terminal","command":["shard","agents","open","<workspace>"]}`; without `--json` it attaches in this terminal). Codex opens its Cloud thread in the Codex app. claude.ai opens with `--in web`. The caller opens the `url` or app, or runs the command in a terminal. Explicit `--in terminal` retains terminal attachment. The menu uses this resolver for new and existing conversations, without provider URLs in Swift.
1265
1267
 
1266
1268
  ## Codex Cloud (0.15.0+, unreleased)
1267
1269
 
@@ -58,7 +58,7 @@ export const ACCOUNT_COMMANDS = [
58
58
  name: { type: 'string', value: '<display name>', description: 'Your name as others see it (setup names the organization after it).' },
59
59
  codex: { type: 'boolean', description: 'Use the Codex login on this machine (default; --no-codex signs up with --email).' },
60
60
  setup: { type: 'boolean', description: 'Run shard setup afterwards (default; --no-setup skips it).' },
61
- tool: { type: 'string', multiple: true, value: '<tool>', description: 'Workspace tool the saved API key may use (repeatable; exec, files, pty, process, git, browser). Default: all.' },
61
+ tool: { type: 'string', multiple: true, value: '<tool>', description: 'Workspace tool the saved API key may use (repeatable; exec, files, pty, process, git, browser, computer). Default: all.' },
62
62
  ...ACCEPT_TERMS,
63
63
  ...SECOND_FACTOR,
64
64
  },
@@ -210,7 +210,7 @@ export const ACCOUNT_COMMANDS = [
210
210
  'org-name': { type: 'string', value: '<name>', description: 'Name of the organization to create when you have none.' },
211
211
  'project-name': { type: 'string', value: '<name>', description: 'Name of the project to create when the organization has none (default Default).' },
212
212
  'key-name': { type: 'string', value: '<name>', description: 'Name of the API key (default "shard-cli <hostname>").' },
213
- tool: { type: 'string', multiple: true, value: '<tool>', description: 'Workspace tool the key may use (repeatable; exec, files, pty, process, git, browser). Default: all.' },
213
+ tool: { type: 'string', multiple: true, value: '<tool>', description: 'Workspace tool the key may use (repeatable; exec, files, pty, process, git, browser, computer). Default: all.' },
214
214
  'new-key': { type: 'boolean', description: 'Create and save a new key even when the saved one works (the old key stays valid until revoked).' },
215
215
  codex: { type: 'boolean', description: 'Set up the account Codex Cloud executor with this machine’s Codex login (default when signed in); --no-codex skips it.' },
216
216
  },
@@ -281,7 +281,7 @@ export const ACCOUNT_COMMANDS = [
281
281
  description: 'Owners and admins grant workspace tools; billing members create keys without tools. The secret cannot be shown again.',
282
282
  positionals: [{ name: 'name', required: true }],
283
283
  options: {
284
- tool: { type: 'string', multiple: true, value: '<tool>', description: 'Workspace tool the key may use (repeatable). Default: all six.' },
284
+ tool: { type: 'string', multiple: true, value: '<tool>', description: 'Workspace tool the key may use (repeatable; exec, files, pty, process, git, browser, computer). Default: all.' },
285
285
  'read-only': { type: 'boolean', description: 'No workspace tools (read-only API use).' },
286
286
  expires: { type: 'string', value: '<30d|90d|365d|never>', description: 'Expiry (default never).' },
287
287
  save: { type: 'boolean', description: 'Save it in the credentials file for the workspace commands instead of printing it.' },
@@ -1,5 +1,7 @@
1
1
  import type { Handler } from './commands.js';
2
2
  export declare const DEFAULT_API_URL = "https://api.shardflux.dev";
3
+ /** What `shard setup` gave a key before 0.15.1: every tool but `computer`. Such a saved key is replaced with a full one. */
4
+ export declare function outdatedDefaultKey(permissions: readonly string[]): boolean;
3
5
  /**
4
6
  * The organization name setup creates: "<display name or email local part>'s organization", within the API's rules
5
7
  * for names (1-200 characters, no control characters or line breaks).
@@ -27,7 +27,15 @@ import { apiKeyTable, attachmentTable, auditTable, authSessionTable, billingStat
27
27
  export const DEFAULT_API_URL = 'https://api.shardflux.dev';
28
28
  const TOTP = /^\d{6}$/;
29
29
  const ROLES = ['owner', 'admin', 'member', 'billing'];
30
- const TOOL_NAMES = API_KEY_TOOL_PERMISSIONS;
30
+ /**
31
+ * Every tool permission a key can carry, `computer` included (0.15.1+): the SDK's list before 0.21.1 left it out, so a
32
+ * key made by `shard setup` could not watch or use its workspace's desktop (computer stream 403).
33
+ */
34
+ const TOOL_NAMES = [...new Set([...API_KEY_TOOL_PERMISSIONS, 'computer'])];
35
+ /** What `shard setup` gave a key before 0.15.1: every tool but `computer`. Such a saved key is replaced with a full one. */
36
+ export function outdatedDefaultKey(permissions) {
37
+ return !permissions.includes('computer') && TOOL_NAMES.every((t) => t === 'computer' || permissions.includes(t));
38
+ }
31
39
  const DEFAULT_CHECKOUT_WAIT_MS = 15 * 60_000;
32
40
  const CHECKOUT_POLL_MS = 2_000;
33
41
  const NEXT_WORKSPACE = 'shard ws open demo --template python-node-browser';
@@ -307,7 +315,7 @@ async function checkKey(ctx, token) {
307
315
  const cloud = new Shardflux({ ...ctx.accountOptions(), apiKey: token });
308
316
  const me = await cloud.me();
309
317
  if (me.type === 'api_key' && me.api_key)
310
- return { ok: true, organizationId: me.api_key.organization_id, projectId: me.api_key.project_id };
318
+ return { ok: true, organizationId: me.api_key.organization_id, projectId: me.api_key.project_id, toolPermissions: me.api_key.tool_permissions ?? [] };
311
319
  return { ok: false, reason: 'not a project API key' };
312
320
  }
313
321
  catch (err) {
@@ -622,7 +630,12 @@ async function runSetup(ctx, values, account) {
622
630
  else
623
631
  throw new UsageError(`${org.name} has ${projects.length} projects; pick one with --project <id|slug>:\n${choices(projects)}\n`, 'or save a default first: shard projects use <id|slug>');
624
632
  }
625
- const keep = keyCheck?.ok === true && profile.api_key?.project_id === project.id;
633
+ // A key from an earlier `shard setup` (every tool but computer) is replaced unless --tool asks for a set: the desktop
634
+ // stream and screenshots need `computer`. The old key stays valid until revoked.
635
+ const outdated = keyCheck?.ok === true && strs(values.tool).length === 0 && values['read-only'] !== true && outdatedDefaultKey(keyCheck.toolPermissions);
636
+ if (outdated && !ctx.json)
637
+ ctx.io.stderr.write(`note: the saved API key ${profile.api_key.key_id} cannot use computer use; creating one with every tool.\n`);
638
+ const keep = keyCheck?.ok === true && profile.api_key?.project_id === project.id && !outdated;
626
639
  let key;
627
640
  let permissions = tools;
628
641
  if (keep)
@@ -655,6 +668,8 @@ async function runSetup(ctx, values, account) {
655
668
  const cloud = new Shardflux({ apiKey: key.token, baseUrl: ctx.apiUrl });
656
669
  await syncAgentCredentials(cloud, { home: credentialHome, redact: value => ctx.addSecret(value) });
657
670
  await cloud.codex.enable({ signal: ctx.signal });
671
+ if (!ctx.json)
672
+ ctx.io.stderr.write('Starting your Codex remote device…\n');
658
673
  codex = { ...await cloud.codex.waitConnector({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal }) };
659
674
  }
660
675
  const envNote = ctx.io.env.SHARDFLUX_API_KEY?.trim() ? 'note: SHARDFLUX_API_KEY is set in this environment and wins over the saved key; unset it to use the saved key.\n' : '';
@@ -11,7 +11,7 @@ import { prepareProjectTemplate } from "./project-template-setup.js";
11
11
  import { projectIdentity, projectLabels } from "./project-identity.js";
12
12
  import { ensureProjectGit } from "./project-git.js";
13
13
  import { json } from "./format.js";
14
- import { resolveWorkspace } from "./commands.js";
14
+ import { out, resolveWorkspace } from "./commands.js";
15
15
  import { savedMachine } from "./filesystem-state.js";
16
16
  import { startAgentConfigTunnels, syncAgentConfig } from "./agent-config-sync.js";
17
17
  import { rememberGitAgent } from "./agent-git-commands.js";
@@ -22,7 +22,7 @@ import { progress } from "./progress.js";
22
22
  import { rememberLaunch } from "./agent-live.js";
23
23
  import { prepareAgentMachine } from "./agent-machine.js";
24
24
  import { installedTargets } from "./agent-targets.js";
25
- import { openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
25
+ import { openClaudeDesktopSession, openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
26
26
  import { submitInitialPrompt, prepareInitialCodexProject } from "./initial-prompt.js";
27
27
  import { conversationWorkspace, conversationsUnavailable, getConversation, listConversations, resumeConversation } from "./conversations.js";
28
28
  import { conversationTerminal } from "./conversation-terminal.js";
@@ -272,12 +272,26 @@ export const AGENT_HANDLERS = {
272
272
  delete next.in;
273
273
  return AGENT_HANDLERS['agents open'](ctx, next, [ws.key]);
274
274
  }
275
- const target = saved && detected.targets.includes(saved) ? saved : 'web';
276
- const { HANDLERS } = await import("./handlers.js");
277
- const handler = HANDLERS[`agents open ${target}`];
278
- if (!handler)
279
- throw new UsageError('Choose an installed open target with shard agents targets.');
280
- return handler(ctx, { ...values, in: target }, [ws.key]);
275
+ if (saved && detected.targets.includes(saved)) {
276
+ const { HANDLERS } = await import("./handlers.js");
277
+ const handler = HANDLERS[`agents open ${saved}`];
278
+ if (!handler)
279
+ throw new UsageError('Choose an installed open target with shard agents targets.');
280
+ return handler(ctx, { ...values, in: saved }, [ws.key]);
281
+ }
282
+ // Claude opens where people work (0.15.1+): the conversation in Claude Desktop when it is installed, else Claude
283
+ // Code in a terminal. The browser (claude.ai) only when it is the chosen target (--in web).
284
+ if (detected.clients.includes('claude-desktop')) {
285
+ return openClaudeDesktopSession(ctx, ws);
286
+ }
287
+ if (ctx.json) {
288
+ // An app runs this in the person's terminal (the menu bar opens Terminal with it).
289
+ out(ctx, `shard agents open ${ws.key}\n`, { target: 'terminal', workspace: ws.data, command: ['shard', 'agents', 'open', ws.key] });
290
+ return 0;
291
+ }
292
+ const next = { ...values };
293
+ delete next.in;
294
+ return AGENT_HANDLERS['agents open'](ctx, next, [ws.key]);
281
295
  }
282
296
  if (values.in !== undefined && values.in !== 'terminal') {
283
297
  const { HANDLERS } = await import("./handlers.js");
@@ -8,6 +8,20 @@ export declare function installDeviceKey(cell: CellClient, key: string, signal?:
8
8
  /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
9
9
  export declare function prepareSsh(ws: Workspace, ctx: Ctx, targets: AgentTargets, host: string): Promise<CellClient>;
10
10
  export declare function proxyStream(io: Io, ended?: () => void, outputGate?: Promise<unknown>): Duplex;
11
+ /**
12
+ * The Remote Control URL (https://claude.ai/code/session_…) of the workspace's live Claude session. A Claude process
13
+ * this computer just started can accept its first prompt before Remote Control registers, so an open of our recent
14
+ * launch probes the guest for up to 30 s; history browsing stays passive.
15
+ */
16
+ export declare function remoteControlUrl(ctx: Ctx, ws: Workspace): Promise<string>;
17
+ /** Claude Desktop's own link to a Remote Control session: claude://code/<session id> opens it in its Code tab. */
18
+ export declare function claudeDesktopLink(remoteControl: string): string;
19
+ export declare const CLAUDE_DESKTOP_BUNDLE = "com.anthropic.claudefordesktop";
20
+ /**
21
+ * Opens the conversation itself (its live Remote Control session) in Claude Desktop (0.15.1+), the way `--in auto`
22
+ * opens a Claude conversation when Claude Desktop is installed. --json prints {target, url, app_bundle_id} for apps.
23
+ */
24
+ export declare function openClaudeDesktopSession(ctx: Ctx, ws: Workspace): Promise<number>;
11
25
  export declare function openDesktopWorkspace(ctx: Ctx, ws: Workspace, target: DesktopTarget, t3Version?: string, prewarmed?: boolean): Promise<number>;
12
26
  export declare function queueT3Prewarm(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
13
27
  export declare const TARGET_HANDLERS: Record<string, Handler>;
@@ -132,6 +132,78 @@ export function proxyStream(io, ended, outputGate) {
132
132
  source.once('error', (error) => stream.destroy(error));
133
133
  return stream;
134
134
  }
135
+ /**
136
+ * The Remote Control URL (https://claude.ai/code/session_…) of the workspace's live Claude session. A Claude process
137
+ * this computer just started can accept its first prompt before Remote Control registers, so an open of our recent
138
+ * launch probes the guest for up to 30 s; history browsing stays passive.
139
+ */
140
+ export async function remoteControlUrl(ctx, ws) {
141
+ let cursor;
142
+ let url;
143
+ do {
144
+ const page = await ctx.cloud().agents.list({ limit: 200, ...(cursor ? { cursor } : {}) });
145
+ url = page.data.find(a => a.workspace_id === ws.id && a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
146
+ cursor = page.nextCursor ?? undefined;
147
+ } while (!url && cursor && !ctx.signal.aborted);
148
+ // A new Claude process can accept its first prompt before Remote Control registers.
149
+ // Only an explicit open of our recent launch probes the guest; history browsing stays passive.
150
+ const launchedAt = new AgentStateStore(ctx.store.dir).read().agent_launches?.[ws.id];
151
+ if (!url && typeof launchedAt === 'number' && Date.now() - launchedAt < LAUNCH_WINDOW_MS && ws.ready) {
152
+ const waitMs = Math.min(ctx.wakeTimeoutMs, RECENT_LAUNCH_URL_WAIT_MS);
153
+ const deadline = Date.now() + waitMs;
154
+ const cell = ws.cell({ agentLabel: ctx.agentLabel });
155
+ while (!url && Date.now() < deadline) {
156
+ const result = await cell.exec.run(['sf-agent', 'list'], {
157
+ timeoutMs: Math.min(10_000, Math.max(1, deadline - Date.now())), signal: ctx.signal, maxOutputBytes: 1 << 20,
158
+ });
159
+ if (result.exitCode !== 0)
160
+ break;
161
+ url = guestSessions(result.stdout).find(a => a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
162
+ if (!url && Date.now() < deadline)
163
+ await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal: ctx.signal });
164
+ }
165
+ if (!url)
166
+ throw new Error(`Claude in ${ws.key} did not publish a Remote Control URL within ${Math.ceil(waitMs / 1000)} s; run shard agents open ${ws.key} again`);
167
+ }
168
+ if (!url)
169
+ throw new UsageError(`${ws.key} has no active Remote Control URL; start Claude with Remote Control first`);
170
+ const parsed = new URL(url);
171
+ if (parsed.protocol !== 'https:' || parsed.hostname !== 'claude.ai' || !parsed.pathname.startsWith('/code/') || parsed.username || parsed.password)
172
+ throw new Error('agent returned an invalid Claude Remote Control URL');
173
+ return url;
174
+ }
175
+ /** Claude Desktop's own link to a Remote Control session: claude://code/<session id> opens it in its Code tab. */
176
+ export function claudeDesktopLink(remoteControl) {
177
+ const id = new URL(remoteControl).pathname.split('/')[2] ?? '';
178
+ if (!/^(cse|session)_[A-Za-z0-9_-]+$/.test(id))
179
+ throw new Error('agent returned an invalid Claude Remote Control URL');
180
+ return `claude://code/${id}`;
181
+ }
182
+ export const CLAUDE_DESKTOP_BUNDLE = 'com.anthropic.claudefordesktop';
183
+ /**
184
+ * Opens the conversation itself (its live Remote Control session) in Claude Desktop (0.15.1+), the way `--in auto`
185
+ * opens a Claude conversation when Claude Desktop is installed. --json prints {target, url, app_bundle_id} for apps.
186
+ */
187
+ export async function openClaudeDesktopSession(ctx, ws) {
188
+ await ensureCloudClaude(ctx, ws);
189
+ const session = await remoteControlUrl(ctx, ws);
190
+ const url = claudeDesktopLink(session);
191
+ if (!ctx.json)
192
+ await openLink(url, ctx.io.env);
193
+ out(ctx, `${url}\n`, { target: 'claude-desktop', workspace: ws.data, url, remote_control_url: session, app_bundle_id: CLAUDE_DESKTOP_BUNDLE });
194
+ return 0;
195
+ }
196
+ async function openLink(url, env) {
197
+ const child = spawn('open', [url], { env, stdio: 'ignore' });
198
+ await new Promise((resolve, reject) => {
199
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Claude Desktop did not open in time')); }, 10_000);
200
+ child.once('error', e => { clearTimeout(timer); reject(e); });
201
+ child.once('exit', code => { clearTimeout(timer); if (code === 0)
202
+ resolve();
203
+ else
204
+ reject(new Error(`could not open ${url}`)); });
205
+ });
206
+ }
135
207
  const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
136
208
  const detected = installedTargets(ctx.io.env);
137
209
  if (target !== 'web' && !detected.targets.includes(target))
@@ -142,38 +214,7 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
142
214
  if (target !== 'codex-app')
143
215
  await ensureCloudClaude(ctx, ws);
144
216
  if (target === 'web') {
145
- let cursor;
146
- let url;
147
- do {
148
- const page = await ctx.cloud().agents.list({ limit: 200, ...(cursor ? { cursor } : {}) });
149
- url = page.data.find(a => a.workspace_id === ws.id && a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
150
- cursor = page.nextCursor ?? undefined;
151
- } while (!url && cursor && !ctx.signal.aborted);
152
- // A new Claude process can accept its first prompt before Remote Control registers.
153
- // Only an explicit open of our recent launch probes the guest; history browsing stays passive.
154
- const launchedAt = new AgentStateStore(ctx.store.dir).read().agent_launches?.[ws.id];
155
- if (!url && typeof launchedAt === 'number' && Date.now() - launchedAt < LAUNCH_WINDOW_MS && ws.ready) {
156
- const waitMs = Math.min(ctx.wakeTimeoutMs, RECENT_LAUNCH_URL_WAIT_MS);
157
- const deadline = Date.now() + waitMs;
158
- const cell = ws.cell({ agentLabel: ctx.agentLabel });
159
- while (!url && Date.now() < deadline) {
160
- const result = await cell.exec.run(['sf-agent', 'list'], {
161
- timeoutMs: Math.min(10_000, Math.max(1, deadline - Date.now())), signal: ctx.signal, maxOutputBytes: 1 << 20,
162
- });
163
- if (result.exitCode !== 0)
164
- break;
165
- url = guestSessions(result.stdout).find(a => a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
166
- if (!url && Date.now() < deadline)
167
- await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal: ctx.signal });
168
- }
169
- if (!url)
170
- throw new Error(`Claude in ${ws.key} did not publish a Remote Control URL within ${Math.ceil(waitMs / 1000)} s; run shard agents open ${ws.key} again`);
171
- }
172
- if (!url)
173
- throw new UsageError(`${ws.key} has no active Remote Control URL; start Claude with Remote Control first`);
174
- const parsed = new URL(url);
175
- if (parsed.protocol !== 'https:' || parsed.hostname !== 'claude.ai' || !parsed.pathname.startsWith('/code/') || parsed.username || parsed.password)
176
- throw new Error('agent returned an invalid Claude Remote Control URL');
217
+ const url = await remoteControlUrl(ctx, ws);
177
218
  if (!ctx.json && !openUrl(url, ctx.io.env))
178
219
  throw new Error(`could not open ${url}`);
179
220
  out(ctx, `${url}\n`, { target, workspace: ws.data, url });
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.15.0";
1
+ export declare const CLI_VERSION = "0.15.2";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.15.0';
15
+ export const CLI_VERSION = '0.15.2';
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).' },
@@ -1207,7 +1207,7 @@ const AGENT_COMMANDS = [
1207
1207
  },
1208
1208
  })),
1209
1209
  ...['status', 'enable', 'disable'].map(action => ({ path: ['agents', 'codex', action], summary: `${action} the account's billed Codex Cloud connector`, positionals: [], options: {} })),
1210
- { path: ['agents', 'open'], summary: 'Open a conversation (Codex Cloud or the native Claude session)', description: 'Codex opens its existing Cloud thread without waking its VM. Claude attaches its native session.', positionals: [{ name: 'agent', required: true }], options: { ...AGENT_OPTIONS, app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' }, in: { type: 'string', value: '<terminal|auto|claude-desktop|codex-app|web|t3>', description: 'Where to open a legacy session.' } } },
1210
+ { path: ['agents', 'open'], summary: 'Open a conversation (Codex Cloud or the native Claude session)', description: 'Codex opens its existing Cloud thread without waking its VM. Claude attaches its native session.', positionals: [{ name: 'agent', required: true }], options: { ...AGENT_OPTIONS, app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' }, in: { type: 'string', value: '<terminal|auto|claude-desktop|codex-app|web|t3>', description: 'Where to open it. auto (0.15.1+): the saved target, else a Claude conversation in Claude Desktop when it is installed, else in this terminal; web opens claude.ai.' } } },
1211
1211
  { path: ['agents', 'new'], summary: 'Start a conversation in a fresh VM; Codex opens a genuine Cloud thread', positionals: [{ name: 'agent' }], options: {
1212
1212
  ...AGENT_OPTIONS,
1213
1213
  computer: COMPUTER_OPTION,
@@ -1407,6 +1407,9 @@ export function parseCommandLine(argv, options = {}) {
1407
1407
  // `acp --login` is the ACP terminal sign-in an editor runs (ACP appends the method's args to `shard acp`).
1408
1408
  if (argv.length === 2 && argv[0] === 'acp' && argv[1] === '--login')
1409
1409
  return { kind: 'run', command: { path: ['acp'], summary: '', positionals: [], options: {} }, values: { login: true }, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1410
+ // `menubar refresh` (0.15.2+, not in help): the background step each command may start (menubar.ts).
1411
+ if (argv.length === 2 && argv[0] === 'menubar' && argv[1] === 'refresh')
1412
+ return { kind: 'run', command: { path: ['menubar', 'refresh'], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1410
1413
  if (argv.length === 1 && (argv[0] === 'acp' || argv[0] === 'device-helper'))
1411
1414
  return { kind: 'run', command: { path: [argv[0]], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1412
1415
  const terminator = argv.indexOf('--');
@@ -66,7 +66,7 @@ export interface Ctx {
66
66
  distribution: Distribution;
67
67
  /** The Node.js binary and script of this process; null when run in-process (tests, embedders). */
68
68
  runtime: import('./update.js').Runtime | null;
69
- /** GET /v1/client-versions (unauthenticated): the latest version of every client. */
69
+ /** Every client's versions: GET /v1/client-versions (minimum_supported, hold) with `latest` from npm (0.15.2+). */
70
70
  clientVersions(): Promise<unknown>;
71
71
  /** This machine's name (default API key name of `shard setup`). */
72
72
  hostname(): string;
package/dist/format.js CHANGED
@@ -813,10 +813,12 @@ export function keyStatus(k, now = Date.now()) {
813
813
  return 'expired';
814
814
  return 'active';
815
815
  }
816
+ /** Every workspace tool a key can carry (`computer` since the API's computer use). */
817
+ const ALL_TOOLS = ['exec', 'files', 'pty', 'process', 'git', 'browser', 'computer'];
816
818
  export function toolsText(tools) {
817
819
  if (tools.length === 0)
818
820
  return '(none: read-only)';
819
- return tools.length === 6 ? 'all' : tools.join(',');
821
+ return ALL_TOOLS.every((t) => tools.includes(t)) ? 'all' : tools.join(',');
820
822
  }
821
823
  export function apiKeyTable(rows, savedId) {
822
824
  return table(['', 'ID', 'KEY ID', 'NAME', 'TOOLS', 'STATUS', 'CREATED', 'EXPIRES', 'LAST USED'], rows.map((k) => [mark(k.id === savedId), k.id, k.key_id, k.name, toolsText(k.tool_permissions), keyStatus(k), time(k.created_at), time(k.expires_at), time(k.last_used_at)]));
package/dist/main.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * (step-up, password change, TOTP change) is saved at once; a saved session the API rejects (401) is removed.
11
11
  * Every credential the command loaded is redacted from what it prints on failure.
12
12
  */
13
- import { hostname } from 'node:os';
13
+ import { homedir, hostname } from 'node:os';
14
14
  import { statSync } from 'node:fs';
15
15
  import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
16
16
  import { CLI_VERSION, CENTER_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
@@ -23,6 +23,8 @@ import { runDeviceHelper } from "./device-helper.js";
23
23
  import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
24
24
  import { CredentialStore, configDir } from "./credentials.js";
25
25
  import { recordInstall } from "./update.js";
26
+ import { refreshMenubarInBackground, runMenubarRefresh } from "./menubar.js";
27
+ import { NPM_CLIENTS, npmLatestAll, npmRegistry, withRegistryLatest } from "./registry.js";
26
28
  import { AuthConfigError, EXIT, ExecutionPendingError, InterruptedError, UsageError, describeError, exitCodeFor, ownHint, redact } from "./errors.js";
27
29
  import { errorText, feedbackCommand, json, time } from "./format.js";
28
30
  import { HANDLERS } from "./handlers.js";
@@ -540,12 +542,20 @@ export async function run(argv, io, opts = {}) {
540
542
  distribution,
541
543
  runtime: opts.runtime ?? null,
542
544
  async clientVersions() {
543
- const res = await base(new URL('/v1/client-versions', `${apiUrl.replace(/\/+$/, '')}/`), {
544
- headers: { accept: 'application/json', 'user-agent': userAgent }, signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
545
- });
546
- if (!res.ok)
547
- throw new UsageError(`Could not read the latest versions from ${apiUrl} (HTTP ${res.status}).`);
548
- return res.json();
545
+ // The latest versions come from npm (0.15.2+); the API adds minimum_supported and hold. Either one is enough.
546
+ const catalog = (async () => {
547
+ const res = await base(new URL('/v1/client-versions', `${apiUrl.replace(/\/+$/, '')}/`), {
548
+ headers: { accept: 'application/json', 'user-agent': userAgent }, signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
549
+ });
550
+ if (!res.ok)
551
+ throw new UsageError(`Could not read the latest versions from ${apiUrl} (HTTP ${res.status}).`);
552
+ const body = await res.json();
553
+ return body;
554
+ })();
555
+ const [api, latest] = await Promise.all([catalog.then((body) => ({ body }), (error) => ({ error })), npmLatestAll(NPM_CLIENTS, { fetch: base, registry: npmRegistry(io.env), userAgent, signal })]);
556
+ if ('error' in api && Object.keys(latest).length === 0)
557
+ throw api.error;
558
+ return withRegistryLatest('body' in api ? api.body : null, latest);
549
559
  },
550
560
  hostname: () => {
551
561
  try {
@@ -624,7 +634,7 @@ export async function run(argv, io, opts = {}) {
624
634
  secrets.push(value);
625
635
  },
626
636
  async versionStatus(force) {
627
- const check = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
637
+ const check = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent, registry: npmRegistry(io.env) });
628
638
  if (!force && updateCheckDisabled(io.env)) {
629
639
  const cached = check.peek();
630
640
  return cached ? updateView(cached.status, cached.checkedAt) : null;
@@ -634,10 +644,18 @@ export async function run(argv, io, opts = {}) {
634
644
  return r ? updateView(r.status, r.checkedAt) : null;
635
645
  },
636
646
  };
647
+ // After `npm i -g shardflux` the package may carry a newer menu bar app: brought in by a detached process (no
648
+ // network; an Info.plist compare decides whether to start it).
649
+ // `shard update` brings the app in itself; `shard update --check` (the menu bar of an app before 0.3.2 runs it every
650
+ // 6 hours) is how such an app learns of a newer one with no action from the person.
651
+ const updating = command === 'update' && parsed.values.check !== true;
652
+ if (opts.runtime && !updating && !['menubar', 'device-helper'].includes(parsed.command.path[0])) {
653
+ refreshMenubarInBackground({ dir: store.dir, home: io.env.HOME || homedir(), runtime: opts.runtime, env: { ...process.env, ...io.env } });
654
+ }
637
655
  // The update check runs next to the command (not for `version`, which reports it itself).
638
- if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0])) {
656
+ if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh') {
639
657
  try {
640
- updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
658
+ updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent, registry: npmRegistry(io.env) });
641
659
  updates.start();
642
660
  }
643
661
  catch {
@@ -648,10 +666,10 @@ export async function run(argv, io, opts = {}) {
648
666
  catch (err) {
649
667
  return report(err);
650
668
  }
651
- const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
669
+ const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : command === 'menubar refresh' ? runMenubarRefresh : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
652
670
  if (!handler)
653
671
  return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
654
- if (!['acp', 'device-helper'].includes(parsed.command.path[0]))
672
+ if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh')
655
673
  await housekeeping(ctx);
656
674
  const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
657
675
  // On Ctrl-C give the command a moment to clean up (exec sends a cancel), then stop regardless.
package/dist/menubar.d.ts CHANGED
@@ -17,6 +17,24 @@ export interface MenubarStatus {
17
17
  }
18
18
  type NativeStatus = Omit<MenubarStatus, 'installed' | 'version'>;
19
19
  export declare function compareBundleVersions(left: string, right: string): number;
20
+ /**
21
+ * Marketing versions (CFBundleShortVersionString) compare as semver: 0.3.10 > 0.3.9, 0.4.0-rc.1 < 0.4.0. A version that
22
+ * is not semver compares as a numeric bundle version.
23
+ */
24
+ export declare function compareAppVersions(left: string, right: string): number;
25
+ /**
26
+ * The first app that swaps itself in when the npm package next to the CLI carries a newer one (it knows when no window
27
+ * is open and nothing is in progress). The CLI leaves a running app of this version or later to do that itself.
28
+ */
29
+ export declare const SELF_UPDATING_APP = "0.3.2";
30
+ /** CFBundleShortVersionString of a Shardflux app from its XML Info.plist, read without any tool (null: not readable). */
31
+ export declare function plistVersion(app: string): string | null;
32
+ /**
33
+ * Whether any of these processes shows a window: the menu, the launcher, Settings, onboarding, an approval. The status
34
+ * item itself (a strip at the top of the screen on the status layer, owned by the app before macOS 26) does not count.
35
+ * Null when the probe could not answer: then nothing may be replaced.
36
+ */
37
+ export declare function windowsOpen(text: string): boolean | null;
20
38
  export declare function parseNativeStatus(text: string): NativeStatus;
21
39
  /**
22
40
  * The app's executables keep their execute permission (0.13.6+). @shardflux/menubar-darwin 0.1.1 and 0.1.2 reached npm
@@ -37,13 +55,52 @@ export declare class MenubarManager {
37
55
  get platform(): NodeJS.Platform;
38
56
  /** The version of the app this CLI's npm package carries (what `start` installs). */
39
57
  packagedVersion(): Promise<string>;
58
+ /** Whether the app shows any window (see windowsOpen); null when that could not be read. */
59
+ windowsOpen(pids: number[]): Promise<boolean | null>;
40
60
  status(): Promise<MenubarStatus>;
41
61
  stop(): Promise<MenubarStatus>;
42
62
  login(value: 'on' | 'off'): Promise<MenubarStatus>;
43
63
  start(configDir: string, apiUrl: string): Promise<MenubarStatus>;
64
+ /**
65
+ * Brings an installed app up to the one this CLI's npm package carries, without disturbing the person: nothing when
66
+ * the versions are equal (each swap of an ad-hoc signed app costs its privacy grants), the copy on disk alone when it
67
+ * is not running, and a running app only when it shows no window and no `shard update` runs. A running app that
68
+ * swaps itself in (SELF_UPDATING_APP or later) is left to do so: it also knows when it is in the middle of something.
69
+ */
70
+ refresh(configDir: string, apiUrl: string): Promise<{
71
+ result: 'current' | 'not_installed' | 'installed' | 'restarted' | 'deferred' | 'busy';
72
+ from: string | null;
73
+ to: string | null;
74
+ }>;
44
75
  }
45
76
  /** On macOS, `shard` brings the menu bar up by itself (with launch at login) unless the user ran `shard menubar stop`.
46
77
  * It never fails the command center: a missing optional package or a refused start just leaves it off. */
47
78
  export declare function ensureMenubar(dir: string, apiUrl: string, manager?: MenubarManager, platform?: NodeJS.Platform): Promise<string | undefined>;
79
+ /** How often a command may start a background refresh while the app is busy (a window open). */
80
+ export declare const REFRESH_RETRY_MS: number;
81
+ /** Where this CLI's npm package keeps the app it carries, or null when the optional package is missing. */
82
+ export declare function packagedAppPath(): string | null;
83
+ export interface BackgroundRefreshOptions {
84
+ dir: string;
85
+ home: string;
86
+ /** This `shard` process: the refresh runs the same Node.js and script. */
87
+ runtime: {
88
+ node: string;
89
+ script: string;
90
+ };
91
+ env: Record<string, string | undefined>;
92
+ platform?: NodeJS.Platform;
93
+ packageApp?: () => string | null;
94
+ now?: () => number;
95
+ spawn?: (node: string, args: string[], env: Record<string, string | undefined>) => void;
96
+ }
97
+ /**
98
+ * Every `shard` command (0.15.2+): when the npm package carries a newer app than the installed one (two Info.plist
99
+ * reads, no network, no tool), `shard menubar refresh` runs detached, so the command never waits for it. It is started
100
+ * at most every 5 minutes while the app is busy, and not at all after `shard menubar stop`. Returns whether it started.
101
+ */
102
+ export declare function refreshMenubarInBackground(o: BackgroundRefreshOptions): boolean;
103
+ /** `shard menubar refresh` (not listed in help, so not in HANDLERS): what refreshMenubarInBackground starts. */
104
+ export declare const runMenubarRefresh: Handler;
48
105
  export declare const MENUBAR_HANDLERS: Record<string, Handler>;
49
106
  export {};