@shardflux/cli 0.14.5 → 0.15.1

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +76 -12
  3. package/dist/account-args.js +31 -10
  4. package/dist/account-commands.d.ts +2 -0
  5. package/dist/account-commands.js +158 -14
  6. package/dist/acp-catalog.js +2 -2
  7. package/dist/acp-login.d.ts +20 -0
  8. package/dist/acp-login.js +122 -0
  9. package/dist/acp.js +36 -13
  10. package/dist/agent-commands.js +71 -6
  11. package/dist/agent-git-commands.js +2 -1
  12. package/dist/agent-reachability.js +1 -1
  13. package/dist/agent-target-commands.d.ts +14 -0
  14. package/dist/agent-target-commands.js +79 -13
  15. package/dist/agent-targets.d.ts +7 -0
  16. package/dist/agent-targets.js +44 -32
  17. package/dist/args.d.ts +3 -1
  18. package/dist/args.js +21 -9
  19. package/dist/browser-login.d.ts +35 -0
  20. package/dist/browser-login.js +145 -0
  21. package/dist/codex-cloud.d.ts +49 -0
  22. package/dist/codex-cloud.js +157 -0
  23. package/dist/command-center-actions.js +7 -0
  24. package/dist/command-center-renderer.d.ts +3 -0
  25. package/dist/command-center-renderer.js +42 -38
  26. package/dist/command-center.js +65 -11
  27. package/dist/conversation-terminal.js +19 -0
  28. package/dist/conversations.d.ts +1 -1
  29. package/dist/conversations.js +21 -8
  30. package/dist/credential-prompts.d.ts +4 -2
  31. package/dist/credential-prompts.js +38 -16
  32. package/dist/device-bridge.d.ts +13 -0
  33. package/dist/device-bridge.js +14 -4
  34. package/dist/device-helper.js +2 -1
  35. package/dist/errors.d.ts +9 -0
  36. package/dist/errors.js +15 -2
  37. package/dist/format.js +3 -1
  38. package/dist/handlers.js +2 -1
  39. package/dist/initial-prompt.d.ts +26 -0
  40. package/dist/initial-prompt.js +196 -0
  41. package/dist/legal.d.ts +36 -0
  42. package/dist/legal.js +44 -0
  43. package/dist/main.js +6 -2
  44. package/dist/menubar.d.ts +8 -0
  45. package/dist/menubar.js +9 -1
  46. package/dist/onboarding-app/AGENTS.md +19 -0
  47. package/dist/onboarding-app/CLAUDE.md +19 -0
  48. package/dist/onboarding-app/README.md +12 -0
  49. package/dist/onboarding-app/dev.mjs +18 -0
  50. package/dist/onboarding-app/index.html +25 -0
  51. package/dist/onboarding-app/package.json +12 -0
  52. package/dist/onboarding-app/server.mjs +41 -0
  53. package/dist/onboarding-sample.d.ts +3 -0
  54. package/dist/onboarding-sample.js +29 -0
  55. package/dist/onboarding.d.ts +258 -0
  56. package/dist/onboarding.js +371 -0
  57. package/dist/terminal-brand.d.ts +5 -0
  58. package/dist/terminal-brand.js +18 -0
  59. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
1
  import { prepareSetupCache } from "./setup-cache.js";
2
+ import { launchCodexCloud, openCodexConversation } from "./codex-cloud.js";
2
3
  import { agentReachability } from "./agent-reachability.js";
3
4
  import { configureCredentialFill, watchCredentialPrompts } from "./credential-prompts.js";
4
5
  import { createHash } from 'node:crypto';
@@ -10,7 +11,7 @@ import { prepareProjectTemplate } from "./project-template-setup.js";
10
11
  import { projectIdentity, projectLabels } from "./project-identity.js";
11
12
  import { ensureProjectGit } from "./project-git.js";
12
13
  import { json } from "./format.js";
13
- import { resolveWorkspace } from "./commands.js";
14
+ import { out, resolveWorkspace } from "./commands.js";
14
15
  import { savedMachine } from "./filesystem-state.js";
15
16
  import { startAgentConfigTunnels, syncAgentConfig } from "./agent-config-sync.js";
16
17
  import { rememberGitAgent } from "./agent-git-commands.js";
@@ -21,7 +22,8 @@ import { progress } from "./progress.js";
21
22
  import { rememberLaunch } from "./agent-live.js";
22
23
  import { prepareAgentMachine } from "./agent-machine.js";
23
24
  import { installedTargets } from "./agent-targets.js";
24
- import { openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
25
+ import { openClaudeDesktopSession, openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
26
+ import { submitInitialPrompt, prepareInitialCodexProject } from "./initial-prompt.js";
25
27
  import { conversationWorkspace, conversationsUnavailable, getConversation, listConversations, resumeConversation } from "./conversations.js";
26
28
  import { conversationTerminal } from "./conversation-terminal.js";
27
29
  const active = (session) => session.state === 'running' || session.state === 'starting';
@@ -98,9 +100,16 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
98
100
  // Laptop-local MCP servers are reachable while a terminal is attached (L6); --json/--detach start no tunnels.
99
101
  const tunnels = attach ? await startAgentConfigTunnels(ctx, ws) : null;
100
102
  try {
103
+ if (app === 'codex' && typeof values.initialPrompt === 'string') {
104
+ await prepareInitialCodexProject(cell, cwd, `shard-${ws.labels.agent_pty_name || agent}-${app}`);
105
+ }
101
106
  const credentialFill = await configureCredentialFill(ctx, ws);
102
107
  // A cold launch is remembered before it starts, so the listing can show it before the projection does (agent-live.ts).
103
- const session = await agentPty(cell, app, agent, values.approvals === true, ctx.io.env.TERM || 'xterm-256color', ctx.signal, ws.labels.shard_project || ws.key.split('/')[0], setupHash, cwd, ws.labels.agent_pty_name, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); }, true, conversation);
108
+ const term = ctx.io.env.TERM && ctx.io.env.TERM !== 'dumb' ? ctx.io.env.TERM : 'xterm-256color';
109
+ const session = await agentPty(cell, app, agent, values.approvals === true, term, ctx.signal, ws.labels.shard_project || ws.key.split('/')[0], setupHash, cwd, ws.labels.agent_pty_name, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); }, true, conversation);
110
+ // A new conversation's guest can start the agent before this command reaches it, so the launch above never ran.
111
+ // Remember it either way: listing and `agents open` then read the guest until the projection lists the session.
112
+ rememberLaunch(ctx, ws.id);
104
113
  // The real agent is running before any cache provisioning or mount wait.
105
114
  const preparation = prepareSetupCache(ctx, ws).catch((error) => {
106
115
  ctx.io.stderr.write(`Account setup cache unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -112,9 +121,12 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
112
121
  throw new Error(`project setup could not be queued: ${queued.stderr.trim() || `exit ${queued.exitCode}`}`);
113
122
  });
114
123
  store.save(project, app);
124
+ if (typeof values.initialPrompt === 'string') {
125
+ await submitInitialPrompt(signal => cellConnector(cell, session.session_id)(0, signal), values.initialPrompt, ws.key, ctx.signal, app, bytes => cell.pty.input(session.session_id, bytes));
126
+ }
115
127
  if (!attach) {
116
128
  await preparation;
117
- ctx.io.stdout.write(json({ workspace: ws.data, session }));
129
+ ctx.io.stdout.write(json({ workspace: ws.data, session, ...(typeof values.initialPrompt === 'string' ? { initial_prompt: { submitted: true } } : {}) }));
118
130
  return 0;
119
131
  }
120
132
  ctx.io.stderr.write(`Shardflux · ${ws.key}\nDetach: Ctrl-] then d\n`);
@@ -167,6 +179,8 @@ export const launchProject = async (ctx, values, [path = './', explicitApp]) =>
167
179
  if (target && (!['claude-desktop', 'codex-app', 't3'].includes(target) || !detected?.targets.includes(target)))
168
180
  throw new UsageError(`${target} is not an installed desktop target`);
169
181
  const app = explicitApp ?? store.app(projectKey, like?.labels.app === 'codex' ? 'codex' : 'claude');
182
+ if (target === 'codex-app' || (app === 'codex' && !like && values.in === undefined))
183
+ return launchCodexCloud(ctx, values, path);
170
184
  const agent = typeof values.name === 'string' ? values.name : 'main';
171
185
  if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
172
186
  throw new UsageError('agent name must match ^[a-z0-9][a-z0-9._-]{0,63}$');
@@ -236,6 +250,49 @@ export const AGENT_HANDLERS = {
236
250
  async 'agents open'(ctx, values, [agent = 'main']) {
237
251
  if (values.app !== undefined && values.app !== 'claude' && values.app !== 'codex')
238
252
  throw new UsageError('--app must be claude or codex');
253
+ let resolvedConversation;
254
+ if (/^conv_[a-z0-9]{26}$/.test(agent)) {
255
+ resolvedConversation = await getConversation(ctx, agent);
256
+ if (resolvedConversation.route === 'codex-cloud')
257
+ return openCodexConversation(ctx, resolvedConversation);
258
+ }
259
+ if (values.in === 'auto') {
260
+ // The menu and other launchers use the same CLI-owned target selection. Never put provider URLs in a client.
261
+ const ws = await resolveWorkspace(ctx, agent);
262
+ const project = ws.labels.repo_remote || ws.labels.shard_project || ws.key;
263
+ const store = new AgentStateStore(ctx.store.dir);
264
+ const app = values.app ?? ws.labels.shard_app ?? ws.labels.app ?? store.app(project);
265
+ const detected = installedTargets(ctx.io.env);
266
+ const saved = store.read().projects?.[project]?.target ??
267
+ (project.startsWith('file://') ? store.read().projects?.[new URL(project).pathname]?.target : undefined);
268
+ // Codex's default open implementation owns Cloud/desktop routing. PAS-XB replaces that path; do not pin
269
+ // the menu to the old per-VM SSH target. An explicit saved desktop target still takes precedence.
270
+ if ((!saved || !detected.targets.includes(saved)) && app === 'codex') {
271
+ const next = { ...values };
272
+ delete next.in;
273
+ return AGENT_HANDLERS['agents open'](ctx, next, [ws.key]);
274
+ }
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]);
295
+ }
239
296
  if (values.in !== undefined && values.in !== 'terminal') {
240
297
  const { HANDLERS } = await import("./handlers.js");
241
298
  const target = typeof values.in === 'string' ? HANDLERS[`agents open ${values.in}`] : undefined;
@@ -247,7 +304,10 @@ export const AGENT_HANDLERS = {
247
304
  let ws;
248
305
  let conversation;
249
306
  if (/^conv_[a-z0-9]{26}$/.test(agent)) {
250
- conversation = await resumeConversation(ctx, await getConversation(ctx, agent));
307
+ conversation = resolvedConversation ?? await getConversation(ctx, agent);
308
+ if (conversation.route === 'codex-cloud')
309
+ return openCodexConversation(ctx, conversation);
310
+ conversation = await resumeConversation(ctx, conversation);
251
311
  ws = await conversationWorkspace(ctx, conversation);
252
312
  }
253
313
  else {
@@ -278,12 +338,17 @@ export const AGENT_HANDLERS = {
278
338
  throw error;
279
339
  if (matches.length !== 1)
280
340
  throw new UsageError('Use the conversation id.');
341
+ if (matches[0].route === 'codex-cloud')
342
+ return openCodexConversation(ctx, matches[0]);
281
343
  conversation = await resumeConversation(ctx, matches[0]);
282
344
  ws = await conversationWorkspace(ctx, conversation);
283
345
  }
284
346
  // The menu bar supplies workspace_key from agents list, not the conversation key.
285
347
  if (!conversation && ws.labels.shard_conversation) {
286
- conversation = await resumeConversation(ctx, await getConversation(ctx, ws.labels.shard_conversation));
348
+ conversation = await getConversation(ctx, ws.labels.shard_conversation);
349
+ if (conversation.route === 'codex-cloud')
350
+ return openCodexConversation(ctx, conversation);
351
+ conversation = await resumeConversation(ctx, conversation);
287
352
  ws = await conversationWorkspace(ctx, conversation);
288
353
  }
289
354
  }
@@ -61,7 +61,8 @@ async function workspace(ctx, git, name, key) {
61
61
  if (ws.data.deleted_at)
62
62
  throw new UsageError(`workspace ${key} is gone; use shard agents restore ${name} if it was archived`);
63
63
  const project = projectIdentity(git.root);
64
- if (ws.labels.repo_remote && ws.labels.repo_remote !== project.remote)
64
+ // A folder without an origin is identified by its path, as conversations label it (agent-repository.ts).
65
+ if (ws.labels.repo_remote && ws.labels.repo_remote !== (project.remote ?? `file://${project.folder}`))
65
66
  throw new UsageError('agent repository does not match this local repository');
66
67
  const saved = git.entries()[name];
67
68
  if (saved && saved.workspace_id !== ws.id)
@@ -8,6 +8,6 @@ export function agentReachability(values) {
8
8
  throw new UsageError('--reachable requires a duration');
9
9
  const ms = /^0+(?:\.0+)?(?:ms|s|m|h)?$/.test(text.trim()) ? 0 : parseDuration(text, 'reachable');
10
10
  if (ms % 1000 !== 0 || ms > 86_400_000)
11
- throw new UsageError('--reachable must be whole seconds from 0 to 24h (e.g. 30m; 0 off)');
11
+ throw new UsageError('--reachable must be whole seconds from 0 to 24h (deprecated; accepted for compatibility)');
12
12
  return { agentReachableSeconds: ms / 1000 };
13
13
  }
@@ -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>;
@@ -1,3 +1,5 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { guestSessions, LAUNCH_WINDOW_MS } from "./agent-live.js";
1
3
  import { agentReachability } from "./agent-reachability.js";
2
4
  import { ensureCloudClaude } from "./claude-cloud-login.js";
3
5
  import { spawn } from 'node:child_process';
@@ -16,6 +18,8 @@ import { prewarmT3 } from "./t3-runtime.js";
16
18
  import { AgentStateStore } from "./agent-state.js";
17
19
  const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
18
20
  const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/${ref}`;
21
+ /** How long opening a Claude conversation this computer just started waits for Claude to publish its Remote Control URL. */
22
+ const RECENT_LAUNCH_URL_WAIT_MS = 30_000;
19
23
  /** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
20
24
  export async function installDeviceKey(cell, key, signal) {
21
25
  const path = '/home/user/.ssh/authorized_keys';
@@ -128,6 +132,78 @@ export function proxyStream(io, ended, outputGate) {
128
132
  source.once('error', (error) => stream.destroy(error));
129
133
  return stream;
130
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
+ }
131
207
  const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
132
208
  const detected = installedTargets(ctx.io.env);
133
209
  if (target !== 'web' && !detected.targets.includes(target))
@@ -138,18 +214,7 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
138
214
  if (target !== 'codex-app')
139
215
  await ensureCloudClaude(ctx, ws);
140
216
  if (target === 'web') {
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
- if (!url)
149
- throw new UsageError(`${ws.key} has no active Remote Control URL; start Claude with Remote Control first`);
150
- const parsed = new URL(url);
151
- if (parsed.protocol !== 'https:' || parsed.hostname !== 'claude.ai' || !parsed.pathname.startsWith('/code/') || parsed.username || parsed.password)
152
- throw new Error('agent returned an invalid Claude Remote Control URL');
217
+ const url = await remoteControlUrl(ctx, ws);
153
218
  if (!ctx.json && !openUrl(url, ctx.io.env))
154
219
  throw new Error(`could not open ${url}`);
155
220
  out(ctx, `${url}\n`, { target, workspace: ws.data, url });
@@ -173,7 +238,8 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
173
238
  if (!ctx.json)
174
239
  await openApp(target, ctx.io.env);
175
240
  new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
176
- out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions });
241
+ out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions,
242
+ app_bundle_id: { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target] });
177
243
  return 0;
178
244
  }
179
245
  export async function queueT3Prewarm(ctx, ws, version) {
@@ -10,6 +10,13 @@ export interface TargetEntry {
10
10
  export declare function targetIdentity(key: string): Pick<TargetEntry, 'host' | 'workspace' | 'project' | 'agent'>;
11
11
  /** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
12
12
  export declare function managedBlock(text: string, body: string): string;
13
+ /** The installed T3 Code apps (any channel), with their path and version, in the given app folders. */
14
+ export declare function t3Apps(dirs: string[], platform?: NodeJS.Platform): Array<{
15
+ path: string;
16
+ version: string | null;
17
+ }>;
18
+ /** The app folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS, else /Applications and ~/Applications. */
19
+ export declare function applicationDirs(env: Record<string, string | undefined>): string[];
13
20
  export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform, appDirs?: string[]): {
14
21
  targets: string[];
15
22
  clients: string[];
@@ -33,10 +33,47 @@ export function managedBlock(text, body) {
33
33
  const stop = end + END.length + (text[end + END.length] === '\n' ? 1 : 0);
34
34
  return text.slice(0, start) + block + text.slice(stop);
35
35
  }
36
- export function installedTargets(env, platform = process.platform, appDirs) {
36
+ /** The installed T3 Code apps (any channel), with their path and version, in the given app folders. */
37
+ export function t3Apps(dirs, platform = process.platform) {
38
+ const found = [];
39
+ if (platform !== 'darwin')
40
+ return found;
41
+ for (const dir of dirs) {
42
+ let names;
43
+ try {
44
+ names = readdirSync(dir);
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ for (const name of names.filter(name => name.endsWith('.app'))) {
50
+ const info = join(dir, name, 'Contents', 'Info.plist');
51
+ // XML and binary plists store this ASCII bundle id verbatim. Avoid a
52
+ // subprocess for every unrelated app on the normal no-T3 path.
53
+ try {
54
+ if (!readFileSync(info).includes('com.t3tools.t3code'))
55
+ continue;
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', '-c', 'Print :CFBundleShortVersionString', info], { input: '', encoding: 'utf8', timeout: 1000 });
61
+ const [bundle, version] = result.stdout?.trim().split('\n') ?? [];
62
+ if (bundle !== 'com.t3tools.t3code')
63
+ continue;
64
+ found.push({ path: join(dir, name), version: result.status === 0 && version && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version) ? version : null });
65
+ }
66
+ }
67
+ return found;
68
+ }
69
+ /** The app folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS, else /Applications and ~/Applications. */
70
+ export function applicationDirs(env) {
37
71
  const home = env.HOME || homedir();
72
+ return env.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')];
73
+ }
74
+ export function installedTargets(env, platform = process.platform, appDirs) {
38
75
  // SHARDFLUX_APPLICATIONS_DIRS (colon-separated) replaces the scanned app folders, like SHARDFLUX_CONFIG_DIR does for config.
39
- const dirs = appDirs ?? (env.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')]);
76
+ const dirs = appDirs ?? applicationDirs(env);
40
77
  const hasApp = (name, bundle) => platform === 'darwin' && dirs.map(dir => join(dir, name)).some(path => {
41
78
  const info = join(path, 'Contents', 'Info.plist');
42
79
  if (!existsSync(info))
@@ -45,36 +82,11 @@ export function installedTargets(env, platform = process.platform, appDirs) {
45
82
  return result.status === 0 && result.stdout.trim() === bundle;
46
83
  });
47
84
  const versions = {};
48
- let t3 = false;
49
- if (platform === 'darwin')
50
- for (const dir of dirs) {
51
- let names;
52
- try {
53
- names = readdirSync(dir);
54
- }
55
- catch {
56
- continue;
57
- }
58
- for (const name of names.filter(name => name.endsWith('.app'))) {
59
- const info = join(dir, name, 'Contents', 'Info.plist');
60
- // XML and binary plists store this ASCII bundle id verbatim. Avoid a
61
- // subprocess for every unrelated app on the normal no-T3 path.
62
- try {
63
- if (!readFileSync(info).includes('com.t3tools.t3code'))
64
- continue;
65
- }
66
- catch {
67
- continue;
68
- }
69
- const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', '-c', 'Print :CFBundleShortVersionString', info], { input: '', encoding: 'utf8', timeout: 1000 });
70
- const [bundle, version] = result.stdout?.trim().split('\n') ?? [];
71
- if (bundle !== 'com.t3tools.t3code')
72
- continue;
73
- t3 = true;
74
- if (result.status === 0 && version && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
75
- versions.t3 ??= version;
76
- }
77
- }
85
+ const t3Bundles = t3Apps(dirs, platform);
86
+ const t3 = t3Bundles.length > 0;
87
+ for (const app of t3Bundles)
88
+ if (app.version)
89
+ versions.t3 ??= app.version;
78
90
  const executable = (name) => (env.PATH ?? '').split(':').filter(Boolean).some(p => { try {
79
91
  accessSync(join(p, name), constants.X_OK);
80
92
  return true;
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.14.5";
1
+ export declare const CLI_VERSION = "0.15.1";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
@@ -6,6 +6,8 @@ export interface OptionSpec {
6
6
  /** Placeholder shown in help, e.g. `<slug>`. */
7
7
  value?: string;
8
8
  description: string;
9
+ /** Accept the option for compatibility but omit it from help. */
10
+ hidden?: boolean;
9
11
  }
10
12
  export interface PositionalSpec {
11
13
  name: 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.14.5';
15
+ export const CLI_VERSION = '0.15.1';
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).' },
@@ -268,7 +268,7 @@ const KEY_COMMANDS = [
268
268
  'delete-after-idle': { type: 'string', value: '<days>d', description: 'Delete this persistent workspace after 1..3650 idle days (e.g. 14d).' },
269
269
  like: { type: 'string', value: '<id|key>', description: 'Open a fresh workspace with the source’s template and configuration.' },
270
270
  volume: { type: 'string', multiple: true, value: '<name:/path[:ro]>', description: 'Replace the named volume mounts; repeat for each mount.' },
271
- reachable: { type: 'string', value: '<duration>', description: 'Keep waiting agents reachable (default 30m; 0 off; maximum 24h).' },
271
+ reachable: { type: 'string', value: '<duration>', hidden: true, description: 'Deprecated; ignored. Claude conversations stay reachable without a time limit.' },
272
272
  'agent-idle-grace-seconds': { type: 'string', value: '<0-3600>', description: 'Grace after a coding agent waits or exits (default 300 seconds).' },
273
273
  'idle-policy': { type: 'string', value: '<policy>', description: 'adaptive, never or fixed:<seconds>.' },
274
274
  secret: {
@@ -876,9 +876,10 @@ const KEY_COMMANDS = [
876
876
  { path: ['credentials', 'list'], summary: 'Show pending login requests', positionals: [], options: {} },
877
877
  { path: ['credentials', 'enable'], summary: 'Enable credential fill in a workspace', positionals: [REF], options: {} },
878
878
  { path: ['credentials', 'deny'], summary: 'Deny a login request', positionals: [{ name: 'request-id', required: true }], options: {} },
879
- { path: ['credentials', 'forget'], summary: 'Remove a remembered site credential', positionals: [REF, { name: 'site', required: true }], options: {} },
879
+ { path: ['credentials', 'forget'], summary: 'Remove a remembered site credential', positionals: [REF, { name: 'site', required: true }], options: { scope: { type: 'string', value: '<conversation|project|machine>', description: 'Remembered sign-in scope (default project).' } } },
880
880
  { path: ['credentials', 'approve'], summary: 'Approve a login directly from your vault', positionals: [{ name: 'request-id', required: true }], options: {
881
881
  from: { type: 'string', description: 'hsec, 1password, bitwarden or keychain.' }, item: { type: 'string', description: 'Vault login item name.' },
882
+ scope: { type: 'string', value: '<ask|project|machine>', description: 'Future-request scope; unattended sign-in uses the same scope.' },
882
883
  always: { type: 'boolean', description: 'Always allow these fields for this project on this laptop.' }, remember: { type: 'boolean', description: 'Remember for unattended use, encrypted with AWS KMS.' },
883
884
  } },
884
885
  {
@@ -1166,8 +1167,12 @@ const AGENT_CREDENTIAL_COMMANDS = [
1166
1167
  ...['sync', 'status', 'forget'].map((action) => ({ path: ['agents', 'credentials', action], summary: `${action} Codex copies and the account's cloud Claude login (contents never printed)`, positionals: [], options: {} })),
1167
1168
  { path: ['agents', 'credentials', 'login'], summary: 'Sign in once to cloud Claude for this account', positionals: [{ name: 'app', required: true }], options: { workspace: { type: 'string', value: '<id|key>', description: 'Use this workspace for the cloud sign-in.' } } },
1168
1169
  ];
1170
+ const COMPUTER_OPTION = { type: 'boolean', description: 'Give the new conversation a desktop with a browser its agent drives (computer use).' };
1169
1171
  const AGENT_OPTIONS = {
1170
- reachable: { type: 'string', value: '<duration>', description: 'Keep waiting agents reachable (default 30m; 0 off; maximum 24h).' },
1172
+ prompt: { type: 'string', value: '<text>', description: 'First user message for a new conversation.' },
1173
+ 'prompt-file': { type: 'string', value: '<file|->', description: 'Read the first user message from a UTF-8 file or stdin (-), up to 256 KiB.' },
1174
+ image: { type: 'string', multiple: true, value: '<file|->', description: 'Attach a PNG, JPEG, GIF or WebP image to the first message (repeatable, up to 5 of 10 MiB each; - reads PNG images back to back from stdin) (0.15.0+).' },
1175
+ reachable: { type: 'string', value: '<duration>', hidden: true, description: 'Deprecated; ignored. Claude conversations stay reachable without a time limit.' },
1171
1176
  approvals: { type: 'boolean', description: 'Keep the agent’s permission prompts.' },
1172
1177
  };
1173
1178
  export const PROJECT_COMMAND = {
@@ -1175,6 +1180,7 @@ export const PROJECT_COMMAND = {
1175
1180
  description: 'Opens Claude Code or Codex directly. --resume continues a project conversation. With --json it starts detached and prints the session. Detach with Ctrl-] then d.',
1176
1181
  positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: {
1177
1182
  ...AGENT_OPTIONS,
1183
+ computer: COMPUTER_OPTION,
1178
1184
  new: { type: 'boolean', description: 'Start a new conversation.' },
1179
1185
  resume: { type: 'boolean', description: 'Continue a conversation from this project.' },
1180
1186
  claude: { type: 'boolean', description: 'Same as the claude argument (0.14.4+).' },
@@ -1200,9 +1206,11 @@ const AGENT_COMMANDS = [
1200
1206
  ...(action === 'keep' ? { push: { type: 'boolean', description: 'Push the kept branch to origin and open a PR when gh is installed.' } } : {}),
1201
1207
  },
1202
1208
  })),
1203
- { path: ['agents', 'open'], summary: 'Attach an agent in the terminal', description: 'Detach with Ctrl-] then d; /exit ends the agent 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|claude-desktop|codex-app|web|t3>', description: 'Where to open the session (default terminal).' } } },
1204
- { path: ['agents', 'new'], summary: 'Start a session or a named agent', positionals: [{ name: 'agent' }], options: {
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 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
+ { path: ['agents', 'new'], summary: 'Start a conversation in a fresh VM; Codex opens a genuine Cloud thread', positionals: [{ name: 'agent' }], options: {
1205
1212
  ...AGENT_OPTIONS,
1213
+ computer: COMPUTER_OPTION,
1206
1214
  app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' },
1207
1215
  path: { type: 'string', value: '<path>', description: 'Local project path for first launch.' },
1208
1216
  name: { type: 'string', value: '<name>', description: 'New agent name; copies the project main workspace with --like.' },
@@ -1396,6 +1404,9 @@ function toParseArgsOptions(opts) {
1396
1404
  * returns help text for --help, bare groups and non-TTY bare `shard`.
1397
1405
  */
1398
1406
  export function parseCommandLine(argv, options = {}) {
1407
+ // `acp --login` is the ACP terminal sign-in an editor runs (ACP appends the method's args to `shard acp`).
1408
+ if (argv.length === 2 && argv[0] === 'acp' && argv[1] === '--login')
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 } };
1399
1410
  if (argv.length === 1 && (argv[0] === 'acp' || argv[0] === 'device-helper'))
1400
1411
  return { kind: 'run', command: { path: [argv[0]], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1401
1412
  const terminator = argv.indexOf('--');
@@ -1559,7 +1570,7 @@ function emailedTokensAsPositionals(rest, command) {
1559
1570
  }
1560
1571
  // ---- help ----------------------------------------------------------------------------------
1561
1572
  function optionLines(opts) {
1562
- const rows = Object.entries(opts).map(([name, o]) => [`${o.short ? `-${o.short}, ` : ' '}--${name}${o.value ? ` ${o.value}` : ''}`, o.description]);
1573
+ const rows = Object.entries(opts).filter(([, o]) => !o.hidden).map(([name, o]) => [`${o.short ? `-${o.short}, ` : ' '}--${name}${o.value ? ` ${o.value}` : ''}`, o.description]);
1563
1574
  const w = Math.max(0, ...rows.map((r) => r[0].length));
1564
1575
  return rows.map(([l, d]) => ` ${l.padEnd(w)} ${d}`);
1565
1576
  }
@@ -1574,8 +1585,9 @@ export function commandHelp(c) {
1574
1585
  const lines = [`Usage: ${usageLine(c)}`, '', c.summary + '.'];
1575
1586
  if (c.description)
1576
1587
  lines.push('', c.description);
1577
- if (Object.keys(c.options).length > 0)
1578
- lines.push('', 'Options:', ...optionLines(c.options));
1588
+ const options = optionLines(c.options);
1589
+ if (options.length > 0)
1590
+ lines.push('', 'Options:', ...options);
1579
1591
  lines.push('', 'Global options:', ...optionLines(GLOBAL_OPTIONS));
1580
1592
  if (c.examples?.length)
1581
1593
  lines.push('', 'Examples:', ...c.examples.map((e) => ` ${e}`));
@@ -0,0 +1,35 @@
1
+ import type { AccountClientOptions, AuthUser } from '@shardflux/sdk';
2
+ export interface DeviceAuthorization {
3
+ device_code: string;
4
+ user_code: string;
5
+ verification_uri: string;
6
+ verification_uri_complete: string;
7
+ expires_in: number;
8
+ interval: number;
9
+ }
10
+ export interface DeviceSession {
11
+ status: 'authenticated';
12
+ user: AuthUser;
13
+ session_token: string;
14
+ session_expires_at: string;
15
+ }
16
+ export interface DevicePollOptions {
17
+ signal: AbortSignal;
18
+ /** Waits `ms` (ends early when the signal aborts). */
19
+ sleep(ms: number): Promise<void>;
20
+ now?: () => number;
21
+ /** A token poll that has not answered after this long is a connection timeout (default 15 s). */
22
+ requestTimeoutMs?: number;
23
+ }
24
+ /** The default name on the approval page: "shard CLI on <hostname>". */
25
+ export declare function defaultClientName(hostname: string): string;
26
+ /** POST /v1/auth/device. */
27
+ export declare function startDeviceAuthorization(opts: AccountClientOptions, clientName: string, signal: AbortSignal): Promise<DeviceAuthorization>;
28
+ /**
29
+ * Polls POST /v1/auth/device/token until the session is issued (RFC 8628 §3.4, §3.5): waits `interval` seconds before
30
+ * each poll; a slow_down adds 5 s (or takes the server's longer interval); a connection failure or timeout doubles the
31
+ * interval (exponential backoff); 429 waits its Retry-After and 5xx the interval, while the request can still be
32
+ * approved. Stops on access_denied, expired_token and every other refusal (thrown as the API's error), and when the
33
+ * request's lifetime has passed.
34
+ */
35
+ export declare function waitForDeviceSession(opts: AccountClientOptions, d: DeviceAuthorization, poll: DevicePollOptions): Promise<DeviceSession>;