@shardflux/cli 0.16.0 → 0.17.0

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 (64) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +43 -5
  3. package/dist/agent-commands.d.ts +9 -1
  4. package/dist/agent-commands.js +65 -16
  5. package/dist/agent-models.d.ts +61 -0
  6. package/dist/agent-models.js +79 -0
  7. package/dist/agent-state.d.ts +10 -0
  8. package/dist/agent-state.js +13 -0
  9. package/dist/agent-target-commands.d.ts +6 -1
  10. package/dist/agent-target-commands.js +117 -16
  11. package/dist/agent-targets.d.ts +2 -0
  12. package/dist/agent-targets.js +10 -4
  13. package/dist/args.d.ts +5 -1
  14. package/dist/args.js +49 -9
  15. package/dist/cli-proxy-forward.d.ts +45 -0
  16. package/dist/cli-proxy-forward.js +432 -0
  17. package/dist/cli-proxy-hub.d.ts +273 -0
  18. package/dist/cli-proxy-hub.js +749 -0
  19. package/dist/cli-proxy.d.ts +325 -0
  20. package/dist/cli-proxy.js +1016 -0
  21. package/dist/codex-cloud.d.ts +2 -1
  22. package/dist/codex-cloud.js +16 -6
  23. package/dist/command-center-actions.js +16 -1
  24. package/dist/conversation-local.js +2 -2
  25. package/dist/conversation-stash.js +4 -3
  26. package/dist/conversation-terminal.d.ts +5 -0
  27. package/dist/conversation-terminal.js +45 -6
  28. package/dist/conversations.d.ts +18 -2
  29. package/dist/conversations.js +12 -5
  30. package/dist/device-approvals.d.ts +12 -0
  31. package/dist/device-approvals.js +73 -0
  32. package/dist/device-helper.d.ts +3 -1
  33. package/dist/device-helper.js +5 -2
  34. package/dist/errors.d.ts +2 -0
  35. package/dist/errors.js +1 -1
  36. package/dist/handlers.js +3 -1
  37. package/dist/index.d.ts +4 -0
  38. package/dist/index.js +3 -0
  39. package/dist/linux-helper.d.ts +12 -0
  40. package/dist/linux-helper.js +88 -0
  41. package/dist/linux-terminal.d.ts +11 -0
  42. package/dist/linux-terminal.js +42 -0
  43. package/dist/main.js +29 -6
  44. package/dist/onboarding.d.ts +2 -0
  45. package/dist/onboarding.js +53 -5
  46. package/dist/t3-launch.d.ts +37 -0
  47. package/dist/t3-launch.js +133 -0
  48. package/dist/t3-mode.d.ts +121 -0
  49. package/dist/t3-mode.js +654 -0
  50. package/dist/t3-phone.d.ts +17 -0
  51. package/dist/t3-phone.js +15 -0
  52. package/dist/t3-thread-script.d.ts +18 -0
  53. package/dist/t3-thread-script.js +245 -0
  54. package/dist/terminal-onboarding.d.ts +11 -0
  55. package/dist/terminal-onboarding.js +75 -0
  56. package/dist/thread-on-computer.d.ts +2 -2
  57. package/dist/thread-on-computer.js +13 -7
  58. package/dist/update.d.ts +1 -1
  59. package/dist/update.js +12 -1
  60. package/dist/worktree-twin-engine.d.ts +31 -1
  61. package/dist/worktree-twin-engine.js +253 -40
  62. package/dist/worktree-twin.d.ts +17 -0
  63. package/dist/worktree-twin.js +147 -22
  64. package/package.json +3 -3
@@ -5,17 +5,23 @@ import { ensureCloudClaude } from "./claude-cloud-login.js";
5
5
  import { spawn } from 'node:child_process';
6
6
  import { Readable, Duplex } from 'node:stream';
7
7
  import { ShardfluxApiError, WorkspaceTunnels } from '@shardflux/sdk';
8
+ import { conversationWorkspace, getConversation } from "./conversations.js";
8
9
  import { out, resolveWorkspace } from "./commands.js";
10
+ import { MODEL_CHOICES } from "./agent-models.js";
9
11
  import { AgentTargets, installedTargets, targetLabel } from "./agent-targets.js";
10
12
  import { join, relative } from 'node:path';
11
13
  import { projectIdentity } from "./project-identity.js";
12
14
  import { projectWorkspacePath } from "./agent-repository.js";
13
- import { UsageError } from "./errors.js";
15
+ import { UsageError, describeError } from "./errors.js";
16
+ import { t3PhoneLink } from "./t3-phone.js";
14
17
  import { prepareDesktopRouter } from "./conversation-router.js";
15
18
  import { openUrl } from "./browser.js";
16
19
  import { agentMachineName, prepareAgentMachine } from "./agent-machine.js";
17
20
  import { prewarmT3 } from "./t3-runtime.js";
18
21
  import { AgentStateStore } from "./agent-state.js";
22
+ import { workspaceRepositoryPath } from "./agent-repository.js";
23
+ import { openInChoices, openT3, saveOpenIn, startSshThread, t3Install, t3NotInstalled, t3OpenFailed, t3Status } from "./t3-mode.js";
24
+ import { openT3Conversation, t3Fields, withT3Context } from "./t3-launch.js";
19
25
  const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
20
26
  const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/${ref}`;
21
27
  /** How long opening a Claude conversation this computer just started waits for Claude to publish its Remote Control URL. */
@@ -72,6 +78,12 @@ export async function prepareSsh(ws, ctx, targets, host) {
72
78
  return cell;
73
79
  }
74
80
  async function openApp(target, env) {
81
+ if (target === 't3') {
82
+ const install = t3Install(env);
83
+ if (!install)
84
+ throw t3NotInstalled();
85
+ return openT3(env, install.path);
86
+ }
75
87
  const child = spawn('open', ['-b', { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target]], { env, stdio: 'ignore' });
76
88
  await new Promise((resolve, reject) => {
77
89
  const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('app launcher timed out')); }, 10_000);
@@ -212,23 +224,41 @@ async function openLink(url, env) {
212
224
  }
213
225
  const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
214
226
  const detected = installedTargets(ctx.io.env);
227
+ if (target === 't3' && !detected.targets.includes('t3'))
228
+ throw t3NotInstalled();
215
229
  if (target !== 'web' && !detected.targets.includes(target))
216
230
  throw new UsageError(`${target} is not installed on this machine`);
217
- let ws = await resolveWorkspace(ctx, keyOf(ref));
231
+ // A conversation started in T3 (its Shardflux provider) opens its own thread there (0.17.0+).
232
+ if (target === 't3' && /^conv_[a-z0-9]{26}$/.test(ref))
233
+ return openT3Conversation(ctx, ref, values);
234
+ // A conversation id opens that conversation's own session on its computer (claude.ai, the desktop apps).
235
+ const conversation = /^conv_[a-z0-9]{26}$/.test(ref) ? await getConversation(ctx, ref) : undefined;
236
+ let ws = conversation ? await conversationWorkspace(ctx, conversation) : await resolveWorkspace(ctx, keyOf(ref));
218
237
  if (values.reachable !== undefined)
219
238
  ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
220
- if (target !== 'codex-app')
239
+ // T3 lists both agents and opens without Claude (it shows Claude as signed out); the Claude apps need it.
240
+ if (target === 't3')
241
+ await ensureCloudClaude(ctx, ws).catch((error) => {
242
+ if (!(error instanceof UsageError))
243
+ throw error;
244
+ ctx.io.stderr.write(`Claude Code in T3: ${error.message}\n`);
245
+ });
246
+ else if (target !== 'codex-app')
221
247
  await ensureCloudClaude(ctx, ws);
222
248
  if (target === 'web') {
223
- const url = await remoteControlUrl(ctx, ws);
249
+ const url = await remoteControlUrl(ctx, ws, conversation);
224
250
  if (!ctx.json && !openUrl(url, ctx.io.env))
225
251
  throw new Error(`could not open ${url}`);
226
- out(ctx, `${url}\n`, { target, workspace: ws.data, url });
252
+ out(ctx, `${url}\n`, { target, workspace: ws.data, url, ...(conversation ? { conversation_id: conversation.id } : {}) });
227
253
  return 0;
228
254
  }
229
- return openDesktopWorkspace(ctx, ws, target, detected.versions.t3);
255
+ return openDesktopWorkspace(ctx, ws, target, detected.versions.t3, false, undefined, undefined, conversation?.id);
230
256
  };
231
- export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed = false) {
257
+ /**
258
+ * Opens a workspace as a desktop app's SSH environment. For T3 (0.17.0+) a first message becomes a thread on the T3
259
+ * server in the VM (SSH mode), and T3 is brought to the front with --json too (`opened: true`).
260
+ */
261
+ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed = false, thread, install, conversation) {
232
262
  if (target !== 't3')
233
263
  ws = await prepareDesktopRouter(ctx, ws);
234
264
  await prepareAgentMachine(ctx, ws);
@@ -241,13 +271,58 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
241
271
  : target === 'claude-desktop'
242
272
  ? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
243
273
  : `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
244
- if (!ctx.json)
274
+ let started;
275
+ if (target === 't3' && thread) {
276
+ try {
277
+ started = await startSshThread(ctx, ws, t3Version, workspaceRepositoryPath(ws), thread);
278
+ }
279
+ catch (error) {
280
+ throw withT3Context(error, { workspace_key: ws.key, conversation_id: conversation ?? (ws.labels.shard_conversation || null) });
281
+ }
282
+ }
283
+ if (target === 't3') {
284
+ // The thread exists once startSshThread returns: a failure to show T3 names it rather than inviting a retry.
285
+ try {
286
+ await openT3(ctx.io.env, (install ?? t3Install(ctx.io.env))?.path ?? '');
287
+ }
288
+ catch (error) {
289
+ throw started ? t3OpenFailed(error, { t3_thread_id: started.t3_thread_id, conversation_id: conversation ?? (ws.labels.shard_conversation || null), workspace_key: ws.key }) : error;
290
+ }
291
+ }
292
+ else if (!ctx.json)
245
293
  await openApp(target, ctx.io.env);
246
294
  new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
247
- out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions,
295
+ if (target === 't3')
296
+ new AgentStateStore(ctx.store.dir).saveT3Environment({ workspace: ws.key, host: entry.host, api_url: ctx.apiUrl });
297
+ const phone = target === 't3' ? await linkT3Phone(ctx, ws) : undefined;
298
+ const threadLine = started ? `Started a thread in T3 Code on ${agentMachineName(ws.key)}${started.model ? ` (${started.model}${started.effort ? `, ${started.effort}` : ''})` : ''}.\n` : '';
299
+ out(ctx, `${threadLine}${instructions}\n${phone?.line ?? ''}`, { target, workspace: ws.data, host: entry.host, instructions, ...(phone ? { t3_link: phone.link } : {}),
300
+ ...(target === 't3' ? t3Fields(ws, entry.project, install ?? t3Install(ctx.io.env), started, conversation) : {}),
248
301
  app_bundle_id: { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target] });
249
302
  return 0;
250
303
  }
304
+ /**
305
+ * The T3 app on the phone reaches this workspace through the account's T3 Connect sign-in (0.17.0+). The sign-in is
306
+ * asked for once per person; linking continues by itself once it is approved.
307
+ */
308
+ async function linkT3Phone(ctx, ws) {
309
+ try {
310
+ const { link, signIn, needsLogin } = await t3PhoneLink(ctx, ws.id);
311
+ const line = signIn
312
+ ? `Phone: to use ${link.label} from the T3 app, open ${signIn.verification_uri} and confirm ${signIn.user_code} (once).\n`
313
+ : needsLogin ? `Phone: to use ${link.label} from the T3 app, sign in with shard auth login, then run this again.\n`
314
+ : `Phone: ${link.label} appears in the T3 app's environments.\n`;
315
+ // --json carries the sign-in shard started with its session too, so the menu bar can show it.
316
+ return { line, link: signIn ? { ...link, sign_in: { ...link.sign_in, ...signIn } } : needsLogin ? { ...link, needs_login: true } : link };
317
+ }
318
+ catch (error) {
319
+ // An API without T3 Connect leaves the desktop route as it is.
320
+ if (describeError(error).code === 'not_found')
321
+ return undefined;
322
+ ctx.io.stderr.write(`Phone link: ${describeError(error).message}\n`);
323
+ return undefined;
324
+ }
325
+ }
251
326
  export async function queueT3Prewarm(ctx, ws, version) {
252
327
  try {
253
328
  await prewarmT3(ctx, ws, version);
@@ -269,30 +344,56 @@ function configuredTargets(targets, e, t3 = false) {
269
344
  function targetFiles(targets, entries) {
270
345
  return [...new Set(entries.flatMap(e => configuredTargets(targets, e).map(c => c.file)))];
271
346
  }
347
+ /**
348
+ * The project `agents targets` reports T3 for: the agent argument's, else `--path`'s (resolved as `agents new --path`
349
+ * resolves it: the repository remote, else the folder), else the current folder's when it is one.
350
+ */
351
+ function targetsProject(ref, path) {
352
+ if (ref)
353
+ return keyOf(ref).split('/')[0];
354
+ if (path !== undefined)
355
+ return projectIdentity(path).slug;
356
+ try {
357
+ return projectIdentity('./').slug;
358
+ }
359
+ catch {
360
+ return null;
361
+ }
362
+ }
272
363
  export const TARGET_HANDLERS = {
273
364
  async 'agents targets'(ctx, values, [ref]) {
274
- const detected = installedTargets(ctx.io.env);
365
+ const found = installedTargets(ctx.io.env);
366
+ // models (0.17.0+): the model and effort choices a new thread can start with; clients never hardcode them.
367
+ // T3 Code mode (0.17.0+): T3's state for this project (the agent's, else this folder's), and where `shard ./` opens
368
+ // each app's new conversations (--open-in sets it).
369
+ const project = targetsProject(ref, typeof values.path === 'string' ? values.path : undefined);
370
+ const t3 = t3Status(ctx, { project, lastUsed: true });
371
+ if (typeof values['open-in'] === 'string')
372
+ saveOpenIn(ctx, values['open-in'], t3, found.clients);
373
+ const detected = { ...found, t3, open_in: openInChoices(ctx, project, { clients: found.clients }) };
275
374
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
276
375
  const key = ref ? keyOf(ref) : undefined;
277
376
  const before = values.remove === true ? await targets.configure(undefined, ctx.apiUrl, [], false, ctx.signal) : [];
278
377
  const entries = ref || values.remove === true
279
378
  ? await targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app' || t === 't3'), values.remove === true, ctx.signal)
280
379
  : [];
281
- const detection = `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n`;
380
+ const detection = `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n` +
381
+ (t3.installed ? `T3 Code ${t3.version ?? ''}: ${t3.mode === 'acp' ? 'Shardflux provider' : 'SSH environments'}, ${t3.connected ? 'connected' : 'not connected'}\n`.replace(' :', ':') : '') +
382
+ `New conversations open in: Claude → ${detected.open_in.claude}, Codex → ${detected.open_in.codex}\n`;
282
383
  if (values.remove === true) {
283
384
  const removed = before.filter(e => !entries.some(n => n.host === e.host));
284
- 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 });
385
+ 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, models: MODEL_CHOICES });
285
386
  return Promise.resolve(0);
286
387
  }
287
388
  const entry = key ? entries.find(e => e.workspace === key) : undefined;
288
389
  if (!entry) {
289
- out(ctx, detection, { ...detected, entries });
390
+ out(ctx, detection, { ...detected, entries, models: MODEL_CHOICES });
290
391
  return Promise.resolve(0);
291
392
  }
292
- const t3 = detected.targets.includes('t3');
293
- const configured = configuredTargets(targets, entry, t3);
393
+ const t3Installed = detected.targets.includes('t3');
394
+ const configured = configuredTargets(targets, entry, t3Installed);
294
395
  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` +
295
- ['claude-desktop', 'codex-app', 't3'].filter(t => t === 't3' ? !t3 : !entry.targets.includes(t)).map(t => ` ${{ 'claude-desktop': 'Claude Desktop', 'codex-app': 'Codex app', t3: 'T3 Code' }[t].padEnd(15)} not installed on this computer\n`).join(''), { ...detected, entries, configured: configured.map(({ target, file, name }) => ({ target, file, name })) });
396
+ ['claude-desktop', 'codex-app', 't3'].filter(t => t === 't3' ? !t3Installed : !entry.targets.includes(t)).map(t => ` ${{ 'claude-desktop': 'Claude Desktop', 'codex-app': 'Codex app', t3: 'T3 Code' }[t].padEnd(15)} not installed on this computer\n`).join(''), { ...detected, entries, configured: configured.map(({ target, file, name }) => ({ target, file, name })), models: MODEL_CHOICES });
296
397
  return Promise.resolve(0);
297
398
  },
298
399
  'agents open claude-desktop': openTarget('claude-desktop'),
@@ -20,6 +20,8 @@ export declare function t3Apps(dirs: string[], platform?: NodeJS.Platform): Arra
20
20
  }>;
21
21
  /** The app folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS, else /Applications and ~/Applications. */
22
22
  export declare function applicationDirs(env: Record<string, string | undefined>): string[];
23
+ /** The first `name` app bundle with this bundle id in the app folders (macOS only). */
24
+ export declare function desktopAppPath(dirs: string[], name: string, bundle: string, platform?: NodeJS.Platform): string | undefined;
23
25
  export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform, appDirs?: string[]): {
24
26
  targets: string[];
25
27
  clients: string[];
@@ -74,16 +74,22 @@ export function applicationDirs(env) {
74
74
  const home = env.HOME || homedir();
75
75
  return env.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')];
76
76
  }
77
- export function installedTargets(env, platform = process.platform, appDirs) {
78
- // SHARDFLUX_APPLICATIONS_DIRS (colon-separated) replaces the scanned app folders, like SHARDFLUX_CONFIG_DIR does for config.
79
- const dirs = appDirs ?? applicationDirs(env);
80
- const hasApp = (name, bundle) => platform === 'darwin' && dirs.map(dir => join(dir, name)).some(path => {
77
+ /** The first `name` app bundle with this bundle id in the app folders (macOS only). */
78
+ export function desktopAppPath(dirs, name, bundle, platform = process.platform) {
79
+ if (platform !== 'darwin')
80
+ return undefined;
81
+ return dirs.map(dir => join(dir, name)).find(path => {
81
82
  const info = join(path, 'Contents', 'Info.plist');
82
83
  if (!existsSync(info))
83
84
  return false;
84
85
  const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', info], { input: '', encoding: 'utf8', timeout: 1000 });
85
86
  return result.status === 0 && result.stdout.trim() === bundle;
86
87
  });
88
+ }
89
+ export function installedTargets(env, platform = process.platform, appDirs) {
90
+ // SHARDFLUX_APPLICATIONS_DIRS (colon-separated) replaces the scanned app folders, like SHARDFLUX_CONFIG_DIR does for config.
91
+ const dirs = appDirs ?? applicationDirs(env);
92
+ const hasApp = (name, bundle) => desktopAppPath(dirs, name, bundle, platform) !== undefined;
87
93
  const versions = {};
88
94
  const t3Bundles = t3Apps(dirs, platform);
89
95
  const t3 = t3Bundles.length > 0;
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.16.0";
1
+ export declare const CLI_VERSION = "0.17.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
@@ -27,9 +27,12 @@ export interface CommandSpec {
27
27
  positionals: PositionalSpec[];
28
28
  options: Record<string, OptionSpec>;
29
29
  examples?: string[];
30
+ /** Runs, but is left out of the top and group help (a program other tools start, such as a forwarder). */
31
+ hidden?: boolean;
30
32
  }
31
33
  export declare const GLOBAL_OPTIONS: Record<string, OptionSpec>;
32
34
  export declare const PROJECT_COMMAND: CommandSpec;
35
+ export declare const ONBOARDING_COMMAND: CommandSpec;
33
36
  export declare const CENTER_COMMAND: CommandSpec;
34
37
  export declare const COMMANDS: CommandSpec[];
35
38
  export declare function normalizeWords(words: string[]): string[];
@@ -74,6 +77,7 @@ export type Parsed = {
74
77
  */
75
78
  export declare function parseCommandLine(argv: readonly string[], options?: {
76
79
  tty?: boolean;
80
+ platform?: NodeJS.Platform;
77
81
  isProjectPath?: (path: string) => boolean;
78
82
  }): Parsed;
79
83
  export declare function usageLine(c: CommandSpec): string;
package/dist/args.js CHANGED
@@ -12,7 +12,8 @@
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.16.0';
15
+ import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
16
+ export const CLI_VERSION = '0.17.0';
16
17
  export const GLOBAL_OPTIONS = {
17
18
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
19
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -1188,6 +1189,18 @@ const AGENT_OPTIONS = {
1188
1189
  reachable: { type: 'string', value: '<duration>', hidden: true, description: 'Deprecated; ignored. Claude conversations stay reachable without a time limit.' },
1189
1190
  approvals: { type: 'boolean', description: 'Keep the agent’s permission prompts.' },
1190
1191
  };
1192
+ /** `Claude fable, opus, sonnet or haiku; Codex …` from the CLI's one list (agent-models.ts), without "default". */
1193
+ function choiceIds(kind) {
1194
+ return ['claude', 'codex'].map(app => {
1195
+ const ids = MODEL_CHOICES[app][kind].map(c => c.id).filter(id => id !== DEFAULT_CHOICE);
1196
+ return `${app === 'claude' ? 'Claude' : 'Codex'} ${ids.slice(0, -1).join(', ')} or ${ids.at(-1)}`;
1197
+ }).join('; ');
1198
+ }
1199
+ /** The model and effort a new thread starts with; `shard agents targets --json` lists the choices (0.17.0+). */
1200
+ const MODEL_OPTIONS = {
1201
+ model: { type: 'string', value: '<model>', description: `Model the new thread starts with: ${choiceIds('models')}; default is the agent’s own (0.17.0+).` },
1202
+ effort: { type: 'string', value: '<level>', description: `Reasoning effort the new thread starts with: ${choiceIds('efforts')}; default is the agent’s own (0.17.0+).` },
1203
+ };
1191
1204
  /** A new thread on an existing conversation's computer (0.16.0+). */
1192
1205
  const ON_OPTIONS = {
1193
1206
  on: { type: 'string', value: '<conversation|computer>', description: 'Start the thread on the computer of an existing conversation, sharing its packages, caches, services, ports and files, on its own branch: a conversation id, key or name, or its workspace key or id (shard agents computers lists them) (0.16.0+).' },
@@ -1198,6 +1211,7 @@ export const PROJECT_COMMAND = {
1198
1211
  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.',
1199
1212
  positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: {
1200
1213
  ...AGENT_OPTIONS,
1214
+ ...MODEL_OPTIONS,
1201
1215
  ...ON_OPTIONS,
1202
1216
  computer: COMPUTER_OPTION,
1203
1217
  new: { type: 'boolean', description: 'Start a new conversation.' },
@@ -1211,10 +1225,17 @@ function projectExample(rest, app) {
1211
1225
  const path = rest.find((r, i) => !r.startsWith('-') && !GLOBAL_VALUE_FLAGS.has(rest[i - 1] ?? '')) ?? './';
1212
1226
  return `shard ${/^[\w./~@:+,=-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`} ${app}`;
1213
1227
  }
1228
+ export const ONBOARDING_COMMAND = { path: ['<onboarding>'], summary: 'Set up Shardflux in your terminal', positionals: [], options: { 'accept-terms': { type: 'boolean', description: 'Accept the Terms of Service and Privacy Policy.' }, apps: { type: 'string', value: '<claude,codex>', description: 'Connect Claude, Codex or both.' } } };
1214
1229
  export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud agent command center', positionals: [], options: {} };
1215
1230
  const AGENT_COMMANDS = [
1231
+ { path: ['device', 'requests'], summary: 'Review local device requests waiting for approval', positionals: [], options: {} },
1232
+ ...['approve', 'deny'].map(action => ({ path: ['device', action], summary: `${action} a local device request`, positionals: [{ name: 'id', required: true }], options: {} })),
1216
1233
  { path: ['conversations', 'try'], summary: 'Try a conversation in a synced local worktree', positionals: [{ name: 'conversation', required: true }], options: { path: { type: 'string', value: '<path>', description: 'Local project checkout.' }, stop: { type: 'boolean', description: 'Stop the device helper’s local sync and ports.' } } },
1217
- { path: ['agents', 'targets'], summary: 'Detect apps or configure managed desktop connections', positionals: [{ name: 'agent' }], options: { remove: { type: 'boolean', description: 'Remove this agent’s managed entries, or all entries for this API URL.' } } },
1234
+ { path: ['agents', 'targets'], summary: 'Detect apps or configure managed desktop connections', positionals: [{ name: 'agent' }], options: {
1235
+ remove: { type: 'boolean', description: 'Remove this agent’s managed entries, or all entries for this API URL.' },
1236
+ 'open-in': { type: 'string', value: '<app=target,…>', description: 'Where new conversations open (0.17.0+): claude=terminal|claude-desktop|web|t3, codex=codex-app|t3, e.g. claude=t3,codex=t3.' },
1237
+ path: { type: 'string', value: '<path>', description: 'Local project folder whose T3 Code state and open_in to report, as agents new --path names it (default the current folder; the agent argument wins) (0.17.0+).' },
1238
+ } },
1218
1239
  ...['claude-desktop', 'codex-app', 'web', 't3'].map(target => ({ path: ['agents', 'open', target], summary: `Open the agent in ${target}`, positionals: [{ name: 'agent' }], options: { reachable: AGENT_OPTIONS.reachable } })),
1219
1240
  { path: ['ssh-proxy'], summary: 'Forward SSH standard IO to a managed workspace', positionals: [{ name: 'host', required: true }], options: {} },
1220
1241
  ...['worktree', 'merge', 'keep', 'discard', 'restore'].map((action) => ({
@@ -1232,12 +1253,13 @@ const AGENT_COMMANDS = [
1232
1253
  } },
1233
1254
  { path: ['agents', 'new'], summary: 'Start a conversation in a fresh VM, or with --on on an existing conversation’s computer; Codex opens a genuine Cloud thread', positionals: [{ name: 'agent' }], options: {
1234
1255
  ...AGENT_OPTIONS,
1256
+ ...MODEL_OPTIONS,
1235
1257
  ...ON_OPTIONS,
1236
1258
  computer: COMPUTER_OPTION,
1237
1259
  app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' },
1238
1260
  path: { type: 'string', value: '<path>', description: 'Local project path for first launch.' },
1239
1261
  name: { type: 'string', value: '<name>', description: 'New agent name; copies the project main workspace with --like.' },
1240
- in: { type: 'string', value: '<terminal|claude-desktop|codex-app|t3>', description: 'Where to open the new agent (default terminal).' },
1262
+ in: { type: 'string', value: '<terminal|auto|claude-desktop|codex-app|t3>', description: 'Where to open the new agent (default terminal). t3 (0.17.0+): the first message starts a T3 Code thread (with --on, on that computer’s T3 server); auto: where agents targets --open-in says.' },
1241
1263
  like: { type: 'string', value: '<id|key>', description: 'Copy the workspace configuration onto a fresh disk.' },
1242
1264
  } },
1243
1265
  { path: ['agents', 'fork'], summary: 'Fork an agent’s memory and disk', positionals: [{ name: 'agent', required: true }, { name: 'name', required: true }], options: WAIT },
@@ -1248,13 +1270,29 @@ const AGENT_COMMANDS = [
1248
1270
  follow: { type: 'boolean', description: 'Follow output until interrupted.' },
1249
1271
  } },
1250
1272
  ];
1273
+ const CLI_PROXY_HELP = 'One CLIProxyAPI for your whole account: your Claude, Codex and other subscription logins live in a small hosted copy that sleeps between requests and wakes on the next one. This computer and every VM reach it at http://127.0.0.1:8317, so Claude Code, Codex and T3 Code keep their settings.';
1274
+ const CLI_PROXY_COMMANDS = [
1275
+ { path: ['cli-proxy'], summary: 'Your hosted CLIProxyAPI: its accounts, and where it is reachable', description: CLI_PROXY_HELP, positionals: [], options: {
1276
+ config: { type: 'string', value: '<path>', description: 'This computer’s CLIProxyAPI config file, when none is set up yet (default: found automatically).' },
1277
+ } },
1278
+ { path: ['cli-proxy', 'import'], summary: 'Move CLIProxyAPI from this computer into your account, reachable at 127.0.0.1:8317 everywhere', description: `${CLI_PROXY_HELP} Import brings your config and logins over, stops the local copy (one copy refreshes each login), and connects this computer and your machine template. On a second computer it connects that computer.`, positionals: [], options: {
1279
+ config: { type: 'string', value: '<path>', description: 'Your CLIProxyAPI config file (default: the one the running CLIProxyAPI uses, else the usual locations).' },
1280
+ yes: { type: 'boolean', description: 'Stop the local CLIProxyAPI without asking.' },
1281
+ }, examples: ['shard cli-proxy import', 'shard cli-proxy'] },
1282
+ { path: ['cli-proxy', 'export'], summary: 'Bring your logins back to this computer and stop the hosted CLIProxyAPI', description: 'The hosted copy stops first, so only this computer refreshes your logins from then on. Its logins, as last refreshed, go to the auth folder the import took them from (0600), this computer stops forwarding 127.0.0.1:8317, and the command that starts your local CLIProxyAPI is shown. shard cli-proxy import moves them back.', positionals: [], options: {
1283
+ yes: { type: 'boolean', description: 'Do it without asking.' },
1284
+ } },
1285
+ { path: ['cli-proxy', 'forward'], hidden: true, summary: 'Forward 127.0.0.1:8317 to your hosted CLIProxyAPI (started at login)', positionals: [], options: {} },
1286
+ ];
1251
1287
  /** Every command: the account plane first (sign in, set up), then the project-key commands. */
1252
1288
  const MENUBAR_COMMANDS = ['start', 'stop', 'status'].map(action => ({
1253
1289
  path: ['menubar', action], summary: `${action === 'start' ? 'Install and start' : action === 'stop' ? 'Stop' : 'Show'} the macOS menu bar app`,
1254
1290
  positionals: [], options: { login: { type: 'string', value: '<on|off>', description: 'Enable or disable launch at login.' } },
1255
1291
  }));
1256
- export const COMMANDS = [...ACCOUNT_COMMANDS.filter((c) => c.path[0] !== 'templates'), ...AGENT_COMMANDS, ...AGENT_CREDENTIAL_COMMANDS, ...MENUBAR_COMMANDS, ...KEY_COMMANDS, ...ACCOUNT_COMMANDS.filter((c) => c.path[0] === 'templates')];
1292
+ export const COMMANDS = [...ACCOUNT_COMMANDS.filter((c) => c.path[0] !== 'templates'), ...AGENT_COMMANDS, ...AGENT_CREDENTIAL_COMMANDS, ...CLI_PROXY_COMMANDS, ...MENUBAR_COMMANDS, ...KEY_COMMANDS, ...ACCOUNT_COMMANDS.filter((c) => c.path[0] === 'templates')];
1257
1293
  const GROUPS = {
1294
+ device: 'Local device approvals',
1295
+ 'cli-proxy': 'Your hosted CLIProxyAPI, reachable at http://127.0.0.1:8317 on this computer and in every VM',
1258
1296
  menubar: 'macOS menu bar app',
1259
1297
  conversations: 'Project conversations, branches and messages',
1260
1298
  agents: 'Cloud agent sessions and copied credentials',
@@ -1433,6 +1471,8 @@ export function parseCommandLine(argv, options = {}) {
1433
1471
  // `menubar refresh` (0.15.2+, not in help): the background step each command may start (menubar.ts).
1434
1472
  if (argv.length === 2 && argv[0] === 'menubar' && argv[1] === 'refresh')
1435
1473
  return { kind: 'run', command: { path: ['menubar', 'refresh'], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1474
+ if (argv[0] === 'device-helper' && argv.length === 2 && argv[1] === '--service')
1475
+ return { kind: 'run', command: { path: ['device-helper'], summary: '', positionals: [], options: {} }, values: { service: true }, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1436
1476
  if (argv.length === 1 && (argv[0] === 'acp' || argv[0] === 'device-helper'))
1437
1477
  return { kind: 'run', command: { path: [argv[0]], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1438
1478
  const terminator = argv.indexOf('--');
@@ -1494,8 +1534,8 @@ export function parseCommandLine(argv, options = {}) {
1494
1534
  const path = rest.find((r, i) => !r.startsWith('-') && !GLOBAL_VALUE_FLAGS.has(rest[i - 1] ?? ''));
1495
1535
  if (path !== undefined && (/^(?:\.|\/|~\/)/.test(path) || options.isProjectPath?.(path)))
1496
1536
  command = PROJECT_COMMAND;
1497
- else if (path === undefined && !wantsHelp && !head.includes('--version') && !head.includes('-V') && (options.tty || head.includes('--json')))
1498
- command = CENTER_COMMAND;
1537
+ else if (path === undefined && !wantsHelp && !head.includes('--version') && !head.includes('-V') && ((options.platform ?? process.platform) === 'linux' || options.tty || head.includes('--json')))
1538
+ command = (options.platform ?? process.platform) === 'linux' && !head.includes('--json') ? ONBOARDING_COMMAND : CENTER_COMMAND;
1499
1539
  }
1500
1540
  if (!command) {
1501
1541
  if (words.length === 0) {
@@ -1623,7 +1663,7 @@ export function commandHelp(c) {
1623
1663
  export function groupHelp(prefix) {
1624
1664
  const words = typeof prefix === 'string' ? [prefix] : [...prefix];
1625
1665
  const group = words.join(' ');
1626
- const cmds = COMMANDS.filter((c) => words.every((w, i) => c.path[i] === w));
1666
+ const cmds = COMMANDS.filter((c) => !c.hidden && words.every((w, i) => c.path[i] === w));
1627
1667
  const w = Math.max(...cmds.map((c) => usageLine(c).length));
1628
1668
  return [`Usage: shard ${group} <command> [options]`, '', `${GROUPS[group] ?? GROUPS[words[0]] ?? ''}.`, '', 'Commands:', ...cmds.map((c) => ` ${usageLine(c).padEnd(w)} ${c.summary}`), ...(group === 'workspaces' ? ['', 'Also: shard workspaces files <read|write|ls|stat|search|patch> ..., shard workspaces operations <list|get|wait> ... and shard workspaces executions get ...'] : []), '', `Run "shard ${group} <command> --help" for details.`].join('\n') + '\n';
1629
1669
  }
@@ -1641,14 +1681,14 @@ export const EXIT_CODE_HELP = [
1641
1681
  /** The top help's sections: every command's group (first word) is in exactly one (args.test.ts checks). */
1642
1682
  export const HELP_SECTIONS = [
1643
1683
  { title: 'Account (a user session: shard signup or shard auth login)', groups: ['signup', 'verify', 'auth', 'setup', 'env', 'orgs', 'projects', 'api-keys', 'members', 'invitations', 'billing', 'audit', 'account'] },
1644
- { title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'machine', 'template', 'agents', 'ssh-proxy'] },
1684
+ { title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'machine', 'template', 'agents', 'device', 'cli-proxy', 'ssh-proxy'] },
1645
1685
  { title: 'Workspaces for your app (a project API key: SHARDFLUX_API_KEY, else the one shard setup saved)', groups: ['login', 'whoami', 'status', 'usage', 'workspaces', 'files', 'operations', 'executions', 'secrets', 'credentials', 'volumes', 'egress'] },
1646
1686
  { title: 'Templates for your app’s workspaces (not for cloud agents; publish and archive use the session, the rest the API key)', groups: ['templates'] },
1647
1687
  { title: 'Other', groups: ['menubar', 'update', 'feedback', 'version'] },
1648
1688
  ];
1649
1689
  export function topHelp() {
1650
1690
  const w = Math.max(...COMMANDS.map((c) => usageLine(c).length));
1651
- const sections = HELP_SECTIONS.flatMap((s) => ['', `${s.title}:`, ...s.groups.flatMap((g) => COMMANDS.filter((c) => c.path[0] === g)).map((c) => ` ${usageLine(c).padEnd(w)} ${c.summary}`)]);
1691
+ const sections = HELP_SECTIONS.flatMap((s) => ['', `${s.title}:`, ...s.groups.flatMap((g) => COMMANDS.filter((c) => c.path[0] === g && !c.hidden)).map((c) => ` ${usageLine(c).padEnd(w)} ${c.summary}`)]);
1652
1692
  return [
1653
1693
  `shard ${CLI_VERSION} — the Shardflux command line (built on @shardflux/sdk)`,
1654
1694
  '',
@@ -0,0 +1,45 @@
1
+ import type { HubInfo } from './cli-proxy-hub.js';
2
+ export declare const REPLAY_LIMIT: number;
3
+ /** The forwarder answers this path itself (paths under /__shardflux/ are reserved by the edge, so no hub route is lost). */
4
+ export declare const IDENTITY_PATH = "/__shardflux/forwarder";
5
+ export interface ForwarderOptions {
6
+ /** The current hub info, read on every request (cheap: hubInfoSource caches by mtime). */
7
+ hub: () => HubInfo | null;
8
+ /** Starts CLIProxyAPI in the hub again (after `port_not_listening`). At most one runs at a time. */
9
+ repair?: () => Promise<void>;
10
+ /**
11
+ * Replaces the hub's callback URLs after the edge answers `callback_invalid` (the API key that created them was
12
+ * revoked) and gives VMs the new one. At most one runs at a time and at most one a minute; each request is renewed
13
+ * for once, so a URL that stays invalid is answered as is.
14
+ */
15
+ renew?: () => Promise<void>;
16
+ host?: string;
17
+ port?: number;
18
+ /** How long one request may be retried for (default 60 s, plus the time a repair takes). */
19
+ retryBudgetMs?: number;
20
+ /** Between attempts to bind a port another process holds (default 5 s). */
21
+ bindRetryMs?: number;
22
+ log?: (line: string) => void;
23
+ sleep?: (ms: number) => Promise<void>;
24
+ /** Request bodies kept for a retry, in bytes (default REPLAY_LIMIT); a larger body streams and is not retried. */
25
+ replayLimit?: number;
26
+ }
27
+ /** The hub info file, re-read when it changes (a replaced callback URL takes effect on the next request). */
28
+ export declare function hubInfoSource(dir: string): () => HubInfo | null;
29
+ export declare class CliProxyForwarder {
30
+ #private;
31
+ constructor(opts: ForwarderOptions);
32
+ get address(): {
33
+ host: string;
34
+ port: number;
35
+ } | null;
36
+ /**
37
+ * Listens on host:port. While another program holds the port (also one listening on all interfaces, which a
38
+ * loopback bind would not notice), logs once and tries again every bindRetryMs (until close()); resolves once
39
+ * listening.
40
+ */
41
+ listen(): Promise<void>;
42
+ /** Closes the idle keep-alive connections to the edge (the next request opens a fresh one). */
43
+ dropIdleConnections(): void;
44
+ close(): Promise<void>;
45
+ }