@shardflux/cli 0.13.5 → 0.14.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 (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +40 -4
  3. package/dist/acp-catalog.d.ts +152 -0
  4. package/dist/acp-catalog.js +287 -0
  5. package/dist/acp-journal.d.ts +18 -0
  6. package/dist/acp-journal.js +232 -0
  7. package/dist/acp-rpc.d.ts +41 -0
  8. package/dist/acp-rpc.js +115 -0
  9. package/dist/acp.d.ts +27 -0
  10. package/dist/acp.js +290 -0
  11. package/dist/agent-commands.d.ts +10 -2
  12. package/dist/agent-commands.js +37 -18
  13. package/dist/agent-machine.d.ts +6 -0
  14. package/dist/agent-machine.js +93 -0
  15. package/dist/agent-repository.js +1 -1
  16. package/dist/agent-state.d.ts +3 -0
  17. package/dist/agent-state.js +33 -12
  18. package/dist/agent-target-commands.d.ts +4 -1
  19. package/dist/agent-target-commands.js +64 -22
  20. package/dist/agent-targets.d.ts +6 -4
  21. package/dist/agent-targets.js +72 -31
  22. package/dist/args.d.ts +1 -1
  23. package/dist/args.js +53 -14
  24. package/dist/command-center-actions.js +26 -3
  25. package/dist/command-center.js +20 -4
  26. package/dist/conversation-commands.d.ts +3 -0
  27. package/dist/conversation-commands.js +36 -0
  28. package/dist/conversation-local-resume.d.ts +8 -0
  29. package/dist/conversation-local-resume.js +56 -0
  30. package/dist/conversation-local.d.ts +7 -0
  31. package/dist/conversation-local.js +61 -0
  32. package/dist/conversation-router.d.ts +12 -0
  33. package/dist/conversation-router.js +89 -0
  34. package/dist/conversation-stash.d.ts +11 -0
  35. package/dist/conversation-stash.js +158 -0
  36. package/dist/conversation-terminal.d.ts +3 -0
  37. package/dist/conversation-terminal.js +74 -0
  38. package/dist/conversation-worktree.d.ts +4 -0
  39. package/dist/conversation-worktree.js +22 -0
  40. package/dist/conversations.d.ts +53 -0
  41. package/dist/conversations.js +118 -0
  42. package/dist/device-bridge.d.ts +80 -0
  43. package/dist/device-bridge.js +398 -0
  44. package/dist/device-helper.d.ts +3 -0
  45. package/dist/device-helper.js +104 -0
  46. package/dist/errors.js +5 -0
  47. package/dist/format.js +3 -2
  48. package/dist/handlers.js +3 -1
  49. package/dist/main.js +15 -3
  50. package/dist/menubar.d.ts +5 -0
  51. package/dist/menubar.js +22 -1
  52. package/dist/project-sync.d.ts +5 -1
  53. package/dist/project-sync.js +7 -6
  54. package/dist/t3-provider.d.ts +6 -0
  55. package/dist/t3-provider.js +99 -0
  56. package/dist/t3-runtime.d.ts +6 -0
  57. package/dist/t3-runtime.js +63 -0
  58. package/dist/target-lock.d.ts +3 -0
  59. package/dist/target-lock.js +143 -0
  60. package/package.json +3 -3
@@ -2,9 +2,12 @@ import { Duplex } from 'node:stream';
2
2
  import type { CellClient, Workspace } from '@shardflux/sdk';
3
3
  import type { Ctx, Handler, Io } from './commands.js';
4
4
  import { AgentTargets } from './agent-targets.js';
5
+ import type { DesktopTarget } from './agent-targets.js';
5
6
  /** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
6
7
  export declare function installDeviceKey(cell: CellClient, key: string, signal?: AbortSignal): Promise<void>;
7
8
  /** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
8
9
  export declare function prepareSsh(ws: Workspace, ctx: Ctx, targets: AgentTargets, host: string): Promise<CellClient>;
9
- export declare function proxyStream(io: Io): Duplex;
10
+ export declare function proxyStream(io: Io, ended?: () => void): Duplex;
11
+ export declare function openDesktopWorkspace(ctx: Ctx, ws: Workspace, target: DesktopTarget, t3Version?: string, prewarmed?: boolean): Promise<number>;
12
+ export declare function queueT3Prewarm(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
10
13
  export declare const TARGET_HANDLERS: Record<string, Handler>;
@@ -9,7 +9,11 @@ import { join, relative } from 'node:path';
9
9
  import { projectIdentity } from "./project-identity.js";
10
10
  import { projectWorkspacePath } from "./agent-repository.js";
11
11
  import { UsageError } from "./errors.js";
12
+ import { prepareDesktopRouter } from "./conversation-router.js";
12
13
  import { openUrl } from "./browser.js";
14
+ import { agentMachineName, prepareAgentMachine } from "./agent-machine.js";
15
+ import { prewarmT3 } from "./t3-runtime.js";
16
+ import { AgentStateStore } from "./agent-state.js";
13
17
  const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
14
18
  const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/${ref}`;
15
19
  /** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
@@ -47,14 +51,16 @@ export async function installDeviceKey(cell, key, signal) {
47
51
  export async function prepareSsh(ws, ctx, targets, host) {
48
52
  if (!ws.ready)
49
53
  await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
54
+ if (ws.labels?.t3_environment_id)
55
+ await prepareAgentMachine(ctx, ws);
50
56
  const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
51
57
  const hostKey = await cell.files.readText('/etc/ssh/ssh_host_ed25519_key.pub', { length: 4096 });
52
- targets.pin(host, hostKey);
58
+ await targets.pin(host, hostKey, ctx.signal);
53
59
  await installDeviceKey(cell, targets.deviceKey(), ctx.signal);
54
60
  return cell;
55
61
  }
56
62
  async function openApp(target, env) {
57
- const child = spawn('open', ['-b', target === 'claude-desktop' ? 'com.anthropic.claudefordesktop' : 'com.openai.codex'], { env, stdio: 'ignore' });
63
+ const child = spawn('open', ['-b', { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target]], { env, stdio: 'ignore' });
58
64
  await new Promise((resolve, reject) => {
59
65
  const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('app launcher timed out')); }, 10_000);
60
66
  child.once('error', e => { clearTimeout(timer); reject(e); });
@@ -64,7 +70,7 @@ async function openApp(target, env) {
64
70
  reject(new Error(`app launcher exited ${code}`)); });
65
71
  });
66
72
  }
67
- export function proxyStream(io) {
73
+ export function proxyStream(io, ended) {
68
74
  const stdout = io.stdout;
69
75
  const source = Readable.from(io.stdin, { objectMode: false });
70
76
  let blocked = false;
@@ -92,15 +98,24 @@ export function proxyStream(io) {
92
98
  done(e);
93
99
  }
94
100
  },
95
- destroy(error, done) { source.destroy(); done(error); },
101
+ // A guest can close before SSH authenticates (e.g. sshd MaxStartups).
102
+ // Once output drains, end the proxy so SSH sees EOF instead of waiting
103
+ // for input EOF from the client that is itself waiting for us.
104
+ final(done) { done(); ended?.(); },
105
+ destroy(error, done) { source.destroy(); stdout.off?.('error', outputError); stdout.off?.('close', outputClosed); done(error); },
96
106
  });
107
+ const outputError = (error) => stream.destroy(error);
108
+ const outputClosed = () => stream.destroy(new Error('SSH client output closed'));
109
+ stdout.once?.('error', outputError);
110
+ stdout.once?.('close', outputClosed);
97
111
  source.on('readable', pump);
98
- source.once('end', () => stream.push(null));
112
+ source.once('end', () => { stream.push(null); ended?.(); });
99
113
  source.once('error', (error) => stream.destroy(error));
100
114
  return stream;
101
115
  }
102
116
  const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
103
- if (target !== 'web' && !installedTargets(ctx.io.env).targets.includes(target))
117
+ const detected = installedTargets(ctx.io.env);
118
+ if (target !== 'web' && !detected.targets.includes(target))
104
119
  throw new UsageError(`${target} is not installed on this machine`);
105
120
  let ws = await resolveWorkspace(ctx, keyOf(ref));
106
121
  if (values.reachable !== undefined)
@@ -125,37 +140,57 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
125
140
  out(ctx, `${url}\n`, { target, workspace: ws.data, url });
126
141
  return 0;
127
142
  }
143
+ return openDesktopWorkspace(ctx, ws, target, detected.versions.t3);
144
+ };
145
+ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed = false) {
146
+ if (target !== 't3')
147
+ ws = await prepareDesktopRouter(ctx, ws);
148
+ await prepareAgentMachine(ctx, ws);
149
+ if (target === 't3' && t3Version && !prewarmed)
150
+ await queueT3Prewarm(ctx, ws, t3Version);
128
151
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
129
- const entry = targets.configure(ws.key, ctx.apiUrl, [target]).find(e => e.workspace === ws.key);
130
- const instructions = target === 'claude-desktop'
131
- ? `Restart Claude Desktop to load the connection, then choose Shardflux · ${entry.project} · ${entry.agent} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
132
- : `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
152
+ const entry = (await (ws.labels.shard_router === 'project' ? targets.configureProject(ws.key, ctx.apiUrl, [target], ctx.signal) : targets.configure(ws.key, ctx.apiUrl, [target], false, ctx.signal))).find(e => e.workspace === ws.key);
153
+ const instructions = target === 't3'
154
+ ? `Add environment → SSH → ${entry.host} (${agentMachineName(ws.key)}), then open ${projectWorkspacePath({ slug: entry.project })}.`
155
+ : target === 'claude-desktop'
156
+ ? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
157
+ : `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
133
158
  if (!ctx.json)
134
159
  await openApp(target, ctx.io.env);
160
+ new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
135
161
  out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions });
136
162
  return 0;
137
- };
163
+ }
164
+ export async function queueT3Prewarm(ctx, ws, version) {
165
+ try {
166
+ await prewarmT3(ctx, ws, version);
167
+ }
168
+ catch (error) {
169
+ ctx.io.stderr.write(`T3 runtime will install on connect: ${error instanceof Error ? error.message : String(error)}\n`);
170
+ }
171
+ }
138
172
  const tilde = (targets, file) => file.startsWith(`${targets.home}/`) ? `~/${relative(targets.home, file)}` : file;
139
- const targetLabel = (e) => `[SF] ${e.project} · ${e.agent}`;
173
+ const targetLabel = (e) => e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
140
174
  /** Where each managed connection of an entry lives: the SSH host block (and its Include), then each desktop app's file. */
141
- function configuredTargets(targets, e) {
175
+ function configuredTargets(targets, e, t3 = false) {
142
176
  return [
143
177
  { target: 'ssh', label: 'SSH', file: targets.sshFile, name: e.host, detail: `(included from ${tilde(targets, join(targets.home, '.ssh', 'config'))}): ssh ${e.host}` },
144
178
  ...(e.targets.includes('claude-desktop') ? [{ target: 'claude-desktop', label: 'Claude Desktop', file: targets.claudeFile, name: targetLabel(e), detail: `(sshConfigs: ${targetLabel(e)}); restart Claude Desktop, then pick it in Code's environment list` }] : []),
145
179
  ...(e.targets.includes('codex-app') ? [{ target: 'codex-app', label: 'Codex app', file: targets.codexFile, name: targetLabel(e), detail: `(remoteConnections: ${targetLabel(e)}); restart Codex to import it` }] : []),
180
+ ...(t3 ? [{ target: 't3', label: 'T3 Code', file: targets.sshFile, name: agentMachineName(e.workspace), detail: `Add environment → SSH → ${e.host}; project ${projectWorkspacePath({ slug: e.project })}` }] : []),
146
181
  ];
147
182
  }
148
183
  function targetFiles(targets, entries) {
149
184
  return [...new Set(entries.flatMap(e => configuredTargets(targets, e).map(c => c.file)))];
150
185
  }
151
186
  export const TARGET_HANDLERS = {
152
- 'agents targets'(ctx, values, [ref]) {
187
+ async 'agents targets'(ctx, values, [ref]) {
153
188
  const detected = installedTargets(ctx.io.env);
154
189
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
155
190
  const key = ref ? keyOf(ref) : undefined;
156
- const before = values.remove === true ? targets.configure(undefined, ctx.apiUrl, []) : [];
191
+ const before = values.remove === true ? await targets.configure(undefined, ctx.apiUrl, [], false, ctx.signal) : [];
157
192
  const entries = ref || values.remove === true
158
- ? targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app'), values.remove === true)
193
+ ? await targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app' || t === 't3'), values.remove === true, ctx.signal)
159
194
  : [];
160
195
  const detection = `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n`;
161
196
  if (values.remove === true) {
@@ -168,23 +203,30 @@ export const TARGET_HANDLERS = {
168
203
  out(ctx, detection, { ...detected, entries });
169
204
  return Promise.resolve(0);
170
205
  }
171
- const configured = configuredTargets(targets, entry);
206
+ const t3 = detected.targets.includes('t3');
207
+ const configured = configuredTargets(targets, entry, t3);
172
208
  out(ctx, `Configured ${entry.workspace} as SSH host ${entry.host}:\n${configured.map(c => ` ${c.label.padEnd(15)} ${tilde(targets, c.file)}${c.detail ? ` ${c.detail}` : ''}`).join('\n')}\n` +
173
- ['claude-desktop', 'codex-app'].filter(t => !entry.targets.includes(t)).map(t => ` ${(t === 'claude-desktop' ? 'Claude Desktop' : 'Codex app').padEnd(15)} not installed on this computer\n`).join(''), { ...detected, entries, configured: configured.map(({ target, file, name }) => ({ target, file, name })) });
209
+ ['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 })) });
174
210
  return Promise.resolve(0);
175
211
  },
176
212
  'agents open claude-desktop': openTarget('claude-desktop'),
177
213
  'agents open codex-app': openTarget('codex-app'),
214
+ 'agents open t3': openTarget('t3'),
178
215
  'agents open web': openTarget('web'),
179
216
  async 'ssh-proxy'(ctx, _values, [host]) {
180
217
  if (ctx.json || !host)
181
218
  throw new UsageError('ssh-proxy requires a managed host and binary standard IO');
182
219
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir), entry = targets.entry(host, ctx.apiUrl);
183
- const ws = await resolveWorkspace(ctx, entry.workspace);
184
- const cell = await prepareSsh(ws, ctx, targets, host);
185
- const stream = proxyStream(ctx.io);
220
+ const controller = new AbortController();
221
+ const signal = AbortSignal.any([ctx.signal, controller.signal]);
222
+ const proxyCtx = { ...ctx, signal };
223
+ const stream = proxyStream(ctx.io, () => controller.abort(new Error('SSH proxy stream ended')));
224
+ stream.on('error', error => controller.abort(error));
186
225
  try {
187
- const tunnel = await new WorkspaceTunnels(() => cell, () => ws.grantedTools).forward({ remotePort: 22, stream, signal: ctx.signal });
226
+ const ws = await resolveWorkspace(proxyCtx, entry.workspace);
227
+ void ws.hint({ signal }).catch(() => undefined);
228
+ const cell = await prepareSsh(ws, proxyCtx, targets, host);
229
+ const tunnel = await new WorkspaceTunnels(() => cell, () => ws.grantedTools).forward({ remotePort: 22, stream, signal, passive: true });
188
230
  try {
189
231
  await tunnel.closed;
190
232
  }
@@ -1,4 +1,4 @@
1
- export type DesktopTarget = 'claude-desktop' | 'codex-app';
1
+ export type DesktopTarget = 'claude-desktop' | 'codex-app' | 't3';
2
2
  export interface TargetEntry {
3
3
  host: string;
4
4
  workspace: string;
@@ -10,9 +10,10 @@ 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
- export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform): {
13
+ export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform, appDirs?: string[]): {
14
14
  targets: string[];
15
15
  clients: string[];
16
+ versions: Partial<Record<DesktopTarget, string>>;
16
17
  };
17
18
  /** All paths are supplied by the invocation; tests use a temporary HOME, never the owner's app stores. */
18
19
  export declare class AgentTargets {
@@ -28,6 +29,7 @@ export declare class AgentTargets {
28
29
  constructor(env: Record<string, string | undefined>, dir: string);
29
30
  entry(host: string, apiUrl: string): TargetEntry;
30
31
  deviceKey(): string;
31
- pin(host: string, publicKey: string): void;
32
- configure(key: string | undefined, apiUrl: string, targets: DesktopTarget[], remove?: boolean): TargetEntry[];
32
+ pin(host: string, publicKey: string, signal?: AbortSignal): Promise<void>;
33
+ configure(key: string | undefined, apiUrl: string, targets: DesktopTarget[], remove?: boolean, signal?: AbortSignal): Promise<TargetEntry[]>;
34
+ configureProject(key: string, apiUrl: string, targets: DesktopTarget[], signal?: AbortSignal): Promise<TargetEntry[]>;
33
35
  }
@@ -1,16 +1,20 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { accessSync, constants, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
3
+ import { accessSync, constants, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { writePrivateFile } from "./credentials.js";
7
7
  import { UsageError } from "./errors.js";
8
8
  import { projectWorkspacePath } from "./agent-repository.js";
9
+ import { withTargetLock } from "./target-lock.js";
10
+ const PERSISTED_TARGETS = ['claude-desktop', 'codex-app'];
9
11
  const BEGIN = '# BEGIN Shardflux managed targets';
10
12
  const END = '# END Shardflux managed targets';
11
13
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
12
14
  const shellQuote = (s) => "'" + s.replaceAll("'", "'\\''").replaceAll('%', '%%') + "'";
13
15
  const quote = (s) => '"' + s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%') + '"';
16
+ // Some SSH config readers (T3 Code's host discovery) split Include on whitespace and keep quotes; quote only when needed.
17
+ const includePath = (s) => /^[^\s"'\\#%]+$/.test(s) ? s : quote(s);
14
18
  export function targetIdentity(key) {
15
19
  const parts = key.split('/');
16
20
  if (parts.length !== 2 || parts.some(p => !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(p)))
@@ -29,15 +33,48 @@ export function managedBlock(text, body) {
29
33
  const stop = end + END.length + (text[end + END.length] === '\n' ? 1 : 0);
30
34
  return text.slice(0, start) + block + text.slice(stop);
31
35
  }
32
- export function installedTargets(env, platform = process.platform) {
36
+ export function installedTargets(env, platform = process.platform, appDirs) {
33
37
  const home = env.HOME || homedir();
34
- const hasApp = (name, bundle) => platform === 'darwin' && [join('/Applications', name), join(home, 'Applications', name)].some(path => {
38
+ // 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')]);
40
+ const hasApp = (name, bundle) => platform === 'darwin' && dirs.map(dir => join(dir, name)).some(path => {
35
41
  const info = join(path, 'Contents', 'Info.plist');
36
42
  if (!existsSync(info))
37
43
  return false;
38
44
  const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', info], { input: '', encoding: 'utf8', timeout: 1000 });
39
45
  return result.status === 0 && result.stdout.trim() === bundle;
40
46
  });
47
+ 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
+ }
41
78
  const executable = (name) => (env.PATH ?? '').split(':').filter(Boolean).some(p => { try {
42
79
  accessSync(join(p, name), constants.X_OK);
43
80
  return true;
@@ -46,7 +83,9 @@ export function installedTargets(env, platform = process.platform) {
46
83
  return false;
47
84
  } });
48
85
  const clients = [hasApp('Claude.app', 'com.anthropic.claudefordesktop') && 'claude-desktop', (hasApp('Codex.app', 'com.openai.codex') || hasApp('ChatGPT.app', 'com.openai.codex')) && 'codex-app', executable('claude') && 'claude', executable('codex') && 'codex'].filter((v) => !!v);
49
- return { targets: [...(platform === 'darwin' ? clients.filter(c => c.endsWith('-app') || c === 'claude-desktop') : []), 'web'], clients };
86
+ if (t3)
87
+ clients.push('t3');
88
+ return { targets: [...(platform === 'darwin' ? clients.filter(c => c.endsWith('-app') || c === 'claude-desktop' || c === 't3') : []), 'web'], clients, versions };
50
89
  }
51
90
  /** All paths are supplied by the invocation; tests use a temporary HOME, never the owner's app stores. */
52
91
  export class AgentTargets {
@@ -107,11 +146,16 @@ export class AgentTargets {
107
146
  if (Object.values(doc.created).some(v => !Array.isArray(v) || v.some(k => typeof k !== 'string')))
108
147
  throw new UsageError('invalid target ownership metadata');
109
148
  for (const e of doc.entries) {
110
- if (!object(e) || typeof e.workspace !== 'string' || typeof e.api_url !== 'string' || /[\r\n\0]/.test(e.api_url) || !Array.isArray(e.targets) || e.targets.some(t => t !== 'claude-desktop' && t !== 'codex-app'))
149
+ if (!object(e) || typeof e.workspace !== 'string' || typeof e.api_url !== 'string' || /[\r\n\0]/.test(e.api_url) || !Array.isArray(e.targets) || e.targets.some(t => typeof t !== 'string'))
111
150
  throw new UsageError('invalid Shardflux target entry');
151
+ // Forward compatible: a newer CLI may record targets this one does not know; they write no files here.
152
+ e.targets = e.targets.filter(t => PERSISTED_TARGETS.includes(t));
112
153
  const identity = targetIdentity(e.workspace);
113
154
  if (identity.host !== e.host || identity.project !== e.project || identity.agent !== e.agent)
114
155
  throw new UsageError('invalid Shardflux target identity');
156
+ // Earlier CVE entries used this optional flag. Identity now comes solely
157
+ // from the router workspace key; remove the obsolete flag on rewrite.
158
+ delete e.project_router;
115
159
  }
116
160
  return doc;
117
161
  }
@@ -139,8 +183,8 @@ export class AgentTargets {
139
183
  const key = r.stdout.trim().split(/\s+/).slice(0, 2).join(' ');
140
184
  return `${key} shardflux-device-${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
141
185
  }
142
- pin(host, publicKey) {
143
- this.#locked(() => this.#pin(host, publicKey));
186
+ pin(host, publicKey, signal) {
187
+ return withTargetLock(this.dir, () => this.#pin(host, publicKey), signal);
144
188
  }
145
189
  #pin(host, publicKey) {
146
190
  if (!/^shardflux-[a-z0-9._-]+$/.test(host) || !/^ssh-ed25519 [A-Za-z0-9+/=]+(?: [^\r\n]*)?$/.test(publicKey.trim()))
@@ -156,28 +200,19 @@ export class AgentTargets {
156
200
  if (!previous.length)
157
201
  writePrivateFile(this.dir, this.knownHosts, text + (text && !text.endsWith('\n') ? '\n' : '') + `${host} ${key}\n`);
158
202
  }
159
- configure(key, apiUrl, targets, remove = false) {
160
- return this.#locked(() => this.#configure(key, apiUrl, targets, remove));
203
+ configure(key, apiUrl, targets, remove = false, signal) {
204
+ return withTargetLock(this.dir, () => this.#configure(key, apiUrl, targets, remove), signal);
161
205
  }
162
- #locked(work) {
163
- mkdirSync(this.dir, { recursive: true, mode: 0o700 });
164
- const lock = join(this.dir, 'targets.lock');
165
- try {
166
- mkdirSync(lock, { mode: 0o700 });
167
- }
168
- catch (e) {
169
- if (e.code === 'EEXIST')
170
- throw new UsageError('another target update holds targets.lock; retry after it finishes');
171
- throw e;
172
- }
173
- try {
174
- return work();
175
- }
176
- finally {
177
- rmSync(lock, { recursive: true });
178
- }
206
+ configureProject(key, apiUrl, targets, signal) {
207
+ return withTargetLock(this.dir, () => {
208
+ const identity = targetIdentity(key);
209
+ if (identity.agent !== 'router')
210
+ throw new UsageError('Project targets require the managed router workspace.');
211
+ const previous = this.#manifest().entries.filter(e => e.project === identity.project && e.api_url === apiUrl).flatMap(e => e.targets);
212
+ return this.#configure(key, apiUrl, [...new Set([...previous, ...targets])], false, true);
213
+ }, signal);
179
214
  }
180
- #configure(key, apiUrl, targets, remove) {
215
+ #configure(key, apiUrl, targets, remove, projectTarget = false) {
181
216
  const url = new URL(apiUrl);
182
217
  if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || /[\r\n\0]/.test(apiUrl))
183
218
  throw new UsageError('invalid target API URL');
@@ -188,8 +223,11 @@ export class AgentTargets {
188
223
  const existing = old.find(e => e.host === identity?.host);
189
224
  if (existing && (existing.workspace !== key || existing.api_url !== apiUrl))
190
225
  throw new UsageError('SSH alias already belongs to another workspace or API URL');
191
- const added = identity ? { ...identity, api_url: apiUrl, targets: [...new Set([...(existing?.targets ?? []), ...targets])] } : undefined;
192
- const entries = remove ? old.filter(e => identity ? e.host !== identity.host : e.api_url !== apiUrl) : existing ? old.map(e => e === existing ? added : e) : [...old, added];
226
+ // Only targets that own a config file are recorded: published CLIs reject any other value in this manifest.
227
+ const added = identity ? { ...identity, api_url: apiUrl, targets: [...new Set([...(existing?.targets ?? []), ...targets.filter((t) => PERSISTED_TARGETS.includes(t))])] } : undefined;
228
+ let entries = remove ? old.filter(e => identity ? e.host !== identity.host : e.api_url !== apiUrl) : existing ? old.map(e => e === existing ? added : e) : [...old, added];
229
+ if (projectTarget)
230
+ entries = entries.map(e => e.host !== added.host && e.project === added.project && e.api_url === apiUrl ? { ...e, targets: [] } : e);
193
231
  const sshConfig = join(this.home, '.ssh', 'config');
194
232
  const writes = new Map();
195
233
  const rememberFile = (file) => {
@@ -208,7 +246,7 @@ export class AgentTargets {
208
246
  const text = this.#text(this.knownHosts);
209
247
  writes.set(this.knownHosts, text.split(/(?<=\n)/).filter(line => !removed.some(e => line.split(/\s+/)[0] === e.host)).join(''));
210
248
  }
211
- writes.set(sshConfig, managedBlock(this.#text(sshConfig), entries.length ? `Include ${quote(this.sshFile)}` : ''));
249
+ writes.set(sshConfig, managedBlock(this.#text(sshConfig), entries.length ? `Include ${includePath(this.sshFile)}` : ''));
212
250
  const blocks = entries.map(e => `Host ${e.host}\n HostName ${e.host}\n ProxyCommand ${this.dir === join(this.home, '.config', 'shardflux') ? '' : `env ${shellQuote(`SHARDFLUX_CONFIG_DIR=${this.dir}`)} `}shard ssh-proxy %n --api-url ${shellQuote(e.api_url)}\n User user\n UserKnownHostsFile ${quote(this.knownHosts)}\n StrictHostKeyChecking yes\n IdentityFile ${quote(this.identityFile)}\n IdentitiesOnly yes`);
213
251
  writes.set(this.sshFile, managedBlock(this.#text(this.sshFile), blocks.join('\n\n')));
214
252
  for (const [file, prop, target] of [[this.claudeFile, 'sshConfigs', 'claude-desktop'], [this.codexFile, 'remoteConnections', 'codex-app']]) {
@@ -229,7 +267,10 @@ export class AgentTargets {
229
267
  manifest.created[file] ??= [];
230
268
  if (!(prop in doc) && !manifest.created[file].includes(prop))
231
269
  manifest.created[file].push(prop);
232
- doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e => target === 'claude-desktop' ? { id: e.host, name: `[SF] ${e.project} · ${e.agent}`, sshHost: e.host, startDirectory: projectWorkspacePath({ slug: e.project }) } : { sshAlias: e.host, projects: [{ remotePath: projectWorkspacePath({ slug: e.project }), label: `[SF] ${e.project} · ${e.agent}` }] })];
270
+ doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e => {
271
+ const label = e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
272
+ return target === 'claude-desktop' ? { id: e.host, name: label, sshHost: e.host, startDirectory: projectWorkspacePath({ slug: e.project }) } : { sshAlias: e.host, projects: [{ remotePath: projectWorkspacePath({ slug: e.project }), label }] };
273
+ })];
233
274
  if (!doc[prop].length && manifest.created[file].includes(prop))
234
275
  delete doc[prop];
235
276
  if (file === this.codexFile) {
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.13.5";
1
+ export declare const CLI_VERSION = "0.14.0";
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.13.5';
15
+ export const CLI_VERSION = '0.14.0';
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).' },
@@ -139,10 +139,29 @@ const SECRET_PERMISSIONS = {
139
139
  'allow-project': { type: 'string', multiple: true, value: '<project-id>', description: 'Organization secrets: projects whose workspaces may use it (repeatable). Default: none until granted.' },
140
140
  'all-projects': { type: 'boolean', description: 'Organization secrets: every project of the organization may use it.' },
141
141
  };
142
+ // Cloud agents: a VM is the coding template, then the machine, then the project template.
143
+ const LAYER_HELP = {
144
+ machine: 'Your machine is what every cloud agent starts with: tools, credentials, dotfiles, browser logins, git identity, Claude/Codex settings and skills. "shard machine" opens its VM; change it there (or in any agent’s VM), then run "shard machine save". It is saved from an agent’s VM, so it always keeps Claude Code, Codex and the agent runtime.',
145
+ template: 'The project template is this repository’s layer: toolchains, dependencies, services and project secrets. Setup after "shard ./" saves it; "shard template save" saves the current agent VM’s project changes. It is saved from an agent’s VM; "shard templates" builds templates for your own app’s workspaces instead.',
146
+ };
142
147
  const KEY_COMMANDS = [
148
+ { path: ['conversations', 'list'], summary: 'List project conversations', positionals: [], options: {
149
+ project: { type: 'string', value: '<id>', description: 'Project ID for a user session.' }, state: { type: 'string', value: '<state>', description: 'Conversation state.' },
150
+ parent: { type: 'string', value: '<id>', description: 'Children of this conversation.' }, limit: { type: 'string', value: '<count>', description: 'Page size.' }, cursor: { type: 'string', value: '<cursor>', description: 'Next page cursor.' }
151
+ } },
152
+ { path: ['conversations', 'resume'], summary: 'Resume a saved conversation in a fresh VM', positionals: [{ name: 'id', required: true }], options: {} },
153
+ { path: ['conversations', 'reader'], summary: 'Open a read-only project space reader', positionals: [{ name: 'project_id', required: true }], options: {} },
154
+ { path: ['conversations', 'get'], summary: 'Show a conversation and its current VM', positionals: [{ name: 'id', required: true }], options: {} },
155
+ { path: ['conversations', 'send'], summary: 'Send the next user turn', positionals: [{ name: 'id', required: true }, { name: 'text', required: true, variadic: true, words: true }], options: {} },
156
+ { path: ['conversations', 'wait'], summary: 'Wait for a result or question', positionals: [{ name: 'id', required: true }], options: {
157
+ until: { type: 'string', value: '<states>', description: 'Comma-separated conversation states.' }, timeout: { type: 'string', value: '<seconds>', description: 'Wait for 0..900 seconds (default 900).' }
158
+ } },
159
+ { path: ['conversations', 'land'], summary: 'Record the landing action performed by the agent', positionals: [{ name: 'id', required: true }, { name: 'action', required: true }], options: { into: { type: 'string', value: '<ref>', description: 'Destination branch or PR URL.' } } },
160
+ { path: ['conversations', 'move'], summary: 'Move at the end of the turn', positionals: [{ name: 'id', required: true }, { name: 'to', required: true }], options: {} },
143
161
  ...['machine', 'template'].flatMap((level) => [
144
- { path: [level], summary: level === 'machine' ? 'Open your machine agent, or show its saved filesystem with --json' : 'Show this project’s template filesystem', positionals: [], options: {} },
145
- { path: [level, 'save'], summary: `Save the ${level} filesystem as a new private version`, positionals: [], options: {
162
+ { path: [level], summary: level === 'machine' ? 'Open your machine agent, or show its saved filesystem with --json' : 'Show this project’s template filesystem', positionals: [], options: {},
163
+ description: LAYER_HELP[level] },
164
+ { path: [level, 'save'], summary: `Save the ${level} filesystem as a new private version`, description: LAYER_HELP[level], positionals: [], options: {
146
165
  wait: { type: 'boolean', description: 'Return when the new version is ready (or failed), printing progress on stderr.' },
147
166
  timeout: { type: 'string', value: '<duration>', description: 'With --wait: give up waiting after this long (default 10m). The save continues server side.' },
148
167
  } },
@@ -958,7 +977,7 @@ const KEY_COMMANDS = [
958
977
  {
959
978
  path: ['templates', 'init'],
960
979
  summary: 'Write a template.yaml that starts from a base template',
961
- description: 'The file is the recipe v2 document: languages, packages, files (local "from" paths are uploaded by "shard templates build"), build steps, and the settings a workspace gets when it opens (env, inputs, start commands, services, defaults). Edit it, then build it.',
980
+ description: 'For your own app’s workspaces (SDK/API). Cloud agents do not use it: their machine and project templates are saved from an agent’s VM with "shard machine save" and "shard template save", and a repository bound to another template is refused (template_not_agent_capable). The file is the recipe v2 document: languages, packages, files (local "from" paths are uploaded by "shard templates build"), build steps, and the settings a workspace gets when it opens (env, inputs, start commands, services, defaults). Edit it, then build it.',
962
981
  positionals: [],
963
982
  options: {
964
983
  base: { type: 'string', value: '<slug[@version]>', description: 'Base template (required), e.g. ubuntu-24.04; without @version, the version open resolves to now.' },
@@ -970,7 +989,7 @@ const KEY_COMMANDS = [
970
989
  {
971
990
  path: ['templates', 'build'],
972
991
  summary: 'Build a template from template.yaml: upload its local files and folders, then build it',
973
- description: 'Folders named by "from" are packed as a reproducible tar and uploaded with files unless the organization already has the same bytes. The version stays unpublished (test it with "shard templates test") unless --publish. Progress goes to stderr.',
992
+ description: 'For your own app’s workspaces (SDK/API). Cloud agents do not use it: their machine and project templates are saved from an agent’s VM with "shard machine save" and "shard template save", and a repository bound to another template is refused (template_not_agent_capable). Folders named by "from" are packed as a reproducible tar and uploaded with files unless the organization already has the same bytes. The version stays unpublished (test it with "shard templates test") unless --publish. Progress goes to stderr.',
974
993
  positionals: [{ name: 'file', required: true }],
975
994
  options: {
976
995
  slug: { type: 'string', value: '<slug>', description: 'Organization template to build into (default: the name of the directory holding the file). Created by the first build.' },
@@ -1080,7 +1099,7 @@ const KEY_COMMANDS = [
1080
1099
  {
1081
1100
  path: ['templates', 'draft', 'open'],
1082
1101
  summary: 'Open the draft of an organization template (a layered workspace to edit live)',
1083
- description: 'One live draft per template. Edit it with "shard ws exec|files ... <draft key>", capture states, open test instances, then publish it as the next version.',
1102
+ description: 'For your own app’s workspaces (SDK/API). Cloud agents do not use it: their machine and project templates are saved from an agent’s VM with "shard machine save" and "shard template save", and a repository bound to another template is refused (template_not_agent_capable). One live draft per template. Edit it with "shard ws exec|files ... <draft key>", capture states, open test instances, then publish it as the next version.',
1084
1103
  positionals: [SLUG],
1085
1104
  options: {
1086
1105
  base: { type: 'string', value: '<slug@version>', description: 'Version to start from (default: the template’s latest published version; required for a new template).' },
@@ -1152,14 +1171,15 @@ const AGENT_OPTIONS = {
1152
1171
  approvals: { type: 'boolean', description: 'Keep the agent’s permission prompts.' },
1153
1172
  };
1154
1173
  export const PROJECT_COMMAND = {
1155
- path: ['<path>'], summary: 'Open the project’s cloud agent command center, or start its agent directly with claude|codex',
1156
- description: 'Naming the app starts (or reattaches) that agent at once; with --json it starts detached and prints the session. Detach with Ctrl-] then d; /exit ends the agent session.',
1157
- positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: AGENT_OPTIONS,
1174
+ path: ['<path>'], summary: 'Start a conversation in its own cloud VM',
1175
+ 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
+ positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: { ...AGENT_OPTIONS, new: { type: 'boolean', description: 'Start a new conversation.' }, resume: { type: 'boolean', description: 'Continue a conversation from this project.' } },
1158
1177
  };
1159
1178
  export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud agent command center', positionals: [], options: {} };
1160
1179
  const AGENT_COMMANDS = [
1180
+ { 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.' } } },
1161
1181
  { 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.' } } },
1162
- ...['claude-desktop', 'codex-app', 'web'].map(target => ({ path: ['agents', 'open', target], summary: `Open the agent in ${target}`, positionals: [{ name: 'agent' }], options: { reachable: AGENT_OPTIONS.reachable } })),
1182
+ ...['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 } })),
1163
1183
  { path: ['ssh-proxy'], summary: 'Forward SSH standard IO to a managed workspace', positionals: [{ name: 'host', required: true }], options: {} },
1164
1184
  ...['worktree', 'merge', 'keep', 'discard', 'restore'].map((action) => ({
1165
1185
  path: ['agents', action], summary: { worktree: 'Open agent work in a local worktree', merge: 'Merge agent work into the current branch and clean up', keep: 'Keep agent work as a branch and clean up', discard: 'Delete an agent and its local work', restore: 'Restore archived work as a local branch within 30 days' }[action],
@@ -1169,12 +1189,13 @@ const AGENT_COMMANDS = [
1169
1189
  ...(action === 'keep' ? { push: { type: 'boolean', description: 'Push the kept branch to origin and open a PR when gh is installed.' } } : {}),
1170
1190
  },
1171
1191
  })),
1172
- { 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>', description: 'Where to open the session (default terminal).' } } },
1192
+ { 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).' } } },
1173
1193
  { path: ['agents', 'new'], summary: 'Start a session or a named agent', positionals: [{ name: 'agent' }], options: {
1174
1194
  ...AGENT_OPTIONS,
1175
1195
  app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' },
1176
1196
  path: { type: 'string', value: '<path>', description: 'Local project path for first launch.' },
1177
1197
  name: { type: 'string', value: '<name>', description: 'New agent name; copies the project main workspace with --like.' },
1198
+ in: { type: 'string', value: '<terminal|claude-desktop|codex-app|t3>', description: 'Where to open the new agent (default terminal).' },
1178
1199
  like: { type: 'string', value: '<id|key>', description: 'Copy the workspace configuration onto a fresh disk.' },
1179
1200
  } },
1180
1201
  { path: ['agents', 'fork'], summary: 'Fork an agent’s memory and disk', positionals: [{ name: 'agent', required: true }, { name: 'name', required: true }], options: WAIT },
@@ -1193,6 +1214,7 @@ const MENUBAR_COMMANDS = ['start', 'stop', 'status'].map(action => ({
1193
1214
  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')];
1194
1215
  const GROUPS = {
1195
1216
  menubar: 'macOS menu bar app',
1217
+ conversations: 'Project conversations, branches and messages',
1196
1218
  agents: 'Cloud agent sessions and copied credentials',
1197
1219
  auth: 'Create an account and sign in (the session is saved in the credentials file); passwords, email, two-factor authentication and sessions',
1198
1220
  'auth password': 'Change or reset your password',
@@ -1218,6 +1240,8 @@ const GROUPS = {
1218
1240
  templates: 'Build templates from template.yaml, test and export versions, browse templates (file tree, diff), develop them in a draft, and publish or archive versions',
1219
1241
  };
1220
1242
  const ALIASES = {
1243
+ c: 'conversations',
1244
+ conversation: 'conversations',
1221
1245
  ws: 'workspaces',
1222
1246
  workspace: 'workspaces',
1223
1247
  ops: 'operations',
@@ -1361,6 +1385,8 @@ function toParseArgsOptions(opts) {
1361
1385
  * returns help text for --help, bare groups and non-TTY bare `shard`.
1362
1386
  */
1363
1387
  export function parseCommandLine(argv, options = {}) {
1388
+ if (argv.length === 1 && (argv[0] === 'acp' || argv[0] === 'device-helper'))
1389
+ return { kind: 'run', command: { path: [argv[0]], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1364
1390
  const terminator = argv.indexOf('--');
1365
1391
  const head = terminator < 0 ? argv : argv.slice(0, terminator);
1366
1392
  const leading = [];
@@ -1551,8 +1577,9 @@ export const EXIT_CODE_HELP = [
1551
1577
  /** The top help's sections: every command's group (first word) is in exactly one (args.test.ts checks). */
1552
1578
  export const HELP_SECTIONS = [
1553
1579
  { 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'] },
1554
- { title: 'Workspaces (a project API key: SHARDFLUX_API_KEY, else the one shard setup saved)', groups: ['login', 'whoami', 'status', 'usage', 'machine', 'template', 'agents', 'ssh-proxy', 'workspaces', 'files', 'operations', 'executions', 'secrets', 'credentials', 'volumes', 'egress'] },
1555
- { title: 'Templates (publish and archive use the session; the rest the API key)', groups: ['templates'] },
1580
+ { title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'machine', 'template', 'agents', 'ssh-proxy'] },
1581
+ { 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'] },
1582
+ { title: 'Templates for your app’s workspaces (not for cloud agents; publish and archive use the session, the rest the API key)', groups: ['templates'] },
1556
1583
  { title: 'Other', groups: ['menubar', 'update', 'feedback', 'version'] },
1557
1584
  ];
1558
1585
  export function topHelp() {
@@ -1575,7 +1602,19 @@ export function topHelp() {
1575
1602
  ` shard auth verify-email '<link from the email>'`,
1576
1603
  ` printf '%s\\n' "$PASSWORD" | shard auth login --email you@example.com`,
1577
1604
  ' shard setup # organization, project and an API key, saved',
1578
- ' shard ws open demo --template python-node-browser',
1605
+ ' shard ./ # a cloud agent for this folder',
1606
+ ' shard ws open demo --template python-node-browser # or a workspace for your app (SDK/API)',
1607
+ '',
1608
+ 'Cloud agents (Claude Code or Codex in its own VM, with your setup):',
1609
+ ' shard ./ # a conversation on this folder’s repository: Claude Code in its own VM (shard ./ codex: Codex; --resume: a saved one)',
1610
+ ' shard machine save # what every agent starts with: tools, logins, dotfiles, Claude/Codex config',
1611
+ ' shard template save # this project: toolchains, dependencies, services, project secrets',
1612
+ ' A VM is the coding template, then your machine, then the project template. Setup after shard ./ saves the',
1613
+ ' project template; machine and project templates are always saved from an agent’s VM, so they keep Claude Code,',
1614
+ ' Codex and the agent runtime. "shard templates" is for your own app’s workspaces, not for agents: a repository',
1615
+ ' bound to such a template is refused (template_not_agent_capable).',
1616
+ ' In the VM, Claude Code and Codex have Shardflux computer use next to their own tools: the shardflux-computer MCP',
1617
+ ' server’s computer and computer_batch drive the VM’s desktop (start programs with DISPLAY=:1).',
1579
1618
  ...sections,
1580
1619
  '',
1581
1620
  'Aliases: ws = workspaces, ops = operations, org = orgs, keys = api-keys; "shard workspaces files|operations|executions ..." also works.',