@shardflux/cli 0.15.2 → 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 (77) hide show
  1. package/CHANGELOG.md +144 -14
  2. package/README.md +92 -7
  3. package/dist/acp-models.js +5 -1
  4. package/dist/agent-commands.d.ts +15 -1
  5. package/dist/agent-commands.js +84 -23
  6. package/dist/agent-models.d.ts +61 -0
  7. package/dist/agent-models.js +79 -0
  8. package/dist/agent-state.d.ts +10 -0
  9. package/dist/agent-state.js +13 -0
  10. package/dist/agent-target-commands.d.ts +13 -3
  11. package/dist/agent-target-commands.js +129 -23
  12. package/dist/agent-targets.d.ts +5 -0
  13. package/dist/agent-targets.js +14 -5
  14. package/dist/agent-terminal.d.ts +8 -0
  15. package/dist/agent-terminal.js +35 -2
  16. package/dist/args.d.ts +5 -1
  17. package/dist/args.js +74 -11
  18. package/dist/cli-proxy-forward.d.ts +45 -0
  19. package/dist/cli-proxy-forward.js +432 -0
  20. package/dist/cli-proxy-hub.d.ts +273 -0
  21. package/dist/cli-proxy-hub.js +749 -0
  22. package/dist/cli-proxy.d.ts +325 -0
  23. package/dist/cli-proxy.js +1016 -0
  24. package/dist/codex-cloud.d.ts +3 -0
  25. package/dist/codex-cloud.js +17 -5
  26. package/dist/command-center-actions.js +25 -1
  27. package/dist/commands.d.ts +22 -0
  28. package/dist/commands.js +54 -14
  29. package/dist/conversation-commands.js +6 -0
  30. package/dist/conversation-local.js +2 -2
  31. package/dist/conversation-stash.js +4 -3
  32. package/dist/conversation-terminal.d.ts +5 -0
  33. package/dist/conversation-terminal.js +54 -6
  34. package/dist/conversations.d.ts +31 -3
  35. package/dist/conversations.js +53 -10
  36. package/dist/device-approvals.d.ts +12 -0
  37. package/dist/device-approvals.js +73 -0
  38. package/dist/device-helper.d.ts +3 -1
  39. package/dist/device-helper.js +14 -4
  40. package/dist/errors.d.ts +7 -0
  41. package/dist/errors.js +12 -2
  42. package/dist/files-mirror.d.ts +164 -0
  43. package/dist/files-mirror.js +761 -0
  44. package/dist/handlers.js +5 -1
  45. package/dist/index.d.ts +4 -0
  46. package/dist/index.js +3 -0
  47. package/dist/linux-helper.d.ts +12 -0
  48. package/dist/linux-helper.js +88 -0
  49. package/dist/linux-terminal.d.ts +11 -0
  50. package/dist/linux-terminal.js +42 -0
  51. package/dist/main.js +38 -8
  52. package/dist/onboarding.d.ts +2 -0
  53. package/dist/onboarding.js +53 -5
  54. package/dist/registry.d.ts +2 -42
  55. package/dist/registry.js +6 -90
  56. package/dist/t3-launch.d.ts +37 -0
  57. package/dist/t3-launch.js +133 -0
  58. package/dist/t3-mode.d.ts +121 -0
  59. package/dist/t3-mode.js +654 -0
  60. package/dist/t3-phone.d.ts +17 -0
  61. package/dist/t3-phone.js +15 -0
  62. package/dist/t3-thread-script.d.ts +18 -0
  63. package/dist/t3-thread-script.js +245 -0
  64. package/dist/terminal-onboarding.d.ts +11 -0
  65. package/dist/terminal-onboarding.js +75 -0
  66. package/dist/thread-on-computer.d.ts +35 -0
  67. package/dist/thread-on-computer.js +206 -0
  68. package/dist/update.d.ts +1 -1
  69. package/dist/update.js +12 -1
  70. package/dist/wait-status.d.ts +22 -0
  71. package/dist/wait-status.js +59 -0
  72. package/dist/worktree-twin-engine.d.ts +125 -0
  73. package/dist/worktree-twin-engine.js +615 -0
  74. package/dist/worktree-twin-land.py +331 -0
  75. package/dist/worktree-twin.d.ts +313 -0
  76. package/dist/worktree-twin.js +843 -0
  77. 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";
9
- import { AgentTargets, installedTargets } from "./agent-targets.js";
10
+ import { MODEL_CHOICES } from "./agent-models.js";
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);
@@ -137,12 +149,18 @@ export function proxyStream(io, ended, outputGate) {
137
149
  * this computer just started can accept its first prompt before Remote Control registers, so an open of our recent
138
150
  * launch probes the guest for up to 30 s; history browsing stays passive.
139
151
  */
140
- export async function remoteControlUrl(ctx, ws) {
152
+ /**
153
+ * The Claude Remote Control URL of a session in `ws`. With several threads on one computer (0.16.0+) a conversation's
154
+ * own session is chosen by its harness session id; a thread that has not reported one yet is unambiguous only alone.
155
+ */
156
+ export async function remoteControlUrl(ctx, ws, conversation) {
141
157
  let cursor;
142
158
  let url;
159
+ const session = conversation?.harness_session_id ?? undefined;
160
+ const mine = (id) => session === undefined || id === session;
143
161
  do {
144
162
  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;
163
+ url = page.data.find(a => a.workspace_id === ws.id && a.app === 'claude' && a.state !== 'exited' && a.remote_control_url && mine(a.id.slice(a.workspace_id.length + 1)))?.remote_control_url;
146
164
  cursor = page.nextCursor ?? undefined;
147
165
  } while (!url && cursor && !ctx.signal.aborted);
148
166
  // A new Claude process can accept its first prompt before Remote Control registers.
@@ -158,7 +176,7 @@ export async function remoteControlUrl(ctx, ws) {
158
176
  });
159
177
  if (result.exitCode !== 0)
160
178
  break;
161
- url = guestSessions(result.stdout).find(a => a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
179
+ url = guestSessions(result.stdout).find(a => a.app === 'claude' && a.state !== 'exited' && a.remote_control_url && mine(a.session_id))?.remote_control_url;
162
180
  if (!url && Date.now() < deadline)
163
181
  await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal: ctx.signal });
164
182
  }
@@ -184,9 +202,9 @@ export const CLAUDE_DESKTOP_BUNDLE = 'com.anthropic.claudefordesktop';
184
202
  * Opens the conversation itself (its live Remote Control session) in Claude Desktop (0.15.1+), the way `--in auto`
185
203
  * opens a Claude conversation when Claude Desktop is installed. --json prints {target, url, app_bundle_id} for apps.
186
204
  */
187
- export async function openClaudeDesktopSession(ctx, ws) {
205
+ export async function openClaudeDesktopSession(ctx, ws, conversation) {
188
206
  await ensureCloudClaude(ctx, ws);
189
- const session = await remoteControlUrl(ctx, ws);
207
+ const session = await remoteControlUrl(ctx, ws, conversation);
190
208
  const url = claudeDesktopLink(session);
191
209
  if (!ctx.json)
192
210
  await openLink(url, ctx.io.env);
@@ -206,23 +224,41 @@ async function openLink(url, env) {
206
224
  }
207
225
  const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
208
226
  const detected = installedTargets(ctx.io.env);
227
+ if (target === 't3' && !detected.targets.includes('t3'))
228
+ throw t3NotInstalled();
209
229
  if (target !== 'web' && !detected.targets.includes(target))
210
230
  throw new UsageError(`${target} is not installed on this machine`);
211
- 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));
212
237
  if (values.reachable !== undefined)
213
238
  ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
214
- 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')
215
247
  await ensureCloudClaude(ctx, ws);
216
248
  if (target === 'web') {
217
- const url = await remoteControlUrl(ctx, ws);
249
+ const url = await remoteControlUrl(ctx, ws, conversation);
218
250
  if (!ctx.json && !openUrl(url, ctx.io.env))
219
251
  throw new Error(`could not open ${url}`);
220
- out(ctx, `${url}\n`, { target, workspace: ws.data, url });
252
+ out(ctx, `${url}\n`, { target, workspace: ws.data, url, ...(conversation ? { conversation_id: conversation.id } : {}) });
221
253
  return 0;
222
254
  }
223
- return openDesktopWorkspace(ctx, ws, target, detected.versions.t3);
255
+ return openDesktopWorkspace(ctx, ws, target, detected.versions.t3, false, undefined, undefined, conversation?.id);
224
256
  };
225
- 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) {
226
262
  if (target !== 't3')
227
263
  ws = await prepareDesktopRouter(ctx, ws);
228
264
  await prepareAgentMachine(ctx, ws);
@@ -235,13 +271,58 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
235
271
  : target === 'claude-desktop'
236
272
  ? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
237
273
  : `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
238
- 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)
239
293
  await openApp(target, ctx.io.env);
240
294
  new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
241
- 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) : {}),
242
301
  app_bundle_id: { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target] });
243
302
  return 0;
244
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
+ }
245
326
  export async function queueT3Prewarm(ctx, ws, version) {
246
327
  try {
247
328
  await prewarmT3(ctx, ws, version);
@@ -251,7 +332,6 @@ export async function queueT3Prewarm(ctx, ws, version) {
251
332
  }
252
333
  }
253
334
  const tilde = (targets, file) => file.startsWith(`${targets.home}/`) ? `~/${relative(targets.home, file)}` : file;
254
- const targetLabel = (e) => e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
255
335
  /** Where each managed connection of an entry lives: the SSH host block (and its Include), then each desktop app's file. */
256
336
  function configuredTargets(targets, e, t3 = false) {
257
337
  return [
@@ -264,30 +344,56 @@ function configuredTargets(targets, e, t3 = false) {
264
344
  function targetFiles(targets, entries) {
265
345
  return [...new Set(entries.flatMap(e => configuredTargets(targets, e).map(c => c.file)))];
266
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
+ }
267
363
  export const TARGET_HANDLERS = {
268
364
  async 'agents targets'(ctx, values, [ref]) {
269
- 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 }) };
270
374
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
271
375
  const key = ref ? keyOf(ref) : undefined;
272
376
  const before = values.remove === true ? await targets.configure(undefined, ctx.apiUrl, [], false, ctx.signal) : [];
273
377
  const entries = ref || values.remove === true
274
378
  ? await targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app' || t === 't3'), values.remove === true, ctx.signal)
275
379
  : [];
276
- 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`;
277
383
  if (values.remove === true) {
278
384
  const removed = before.filter(e => !entries.some(n => n.host === e.host));
279
- 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 });
280
386
  return Promise.resolve(0);
281
387
  }
282
388
  const entry = key ? entries.find(e => e.workspace === key) : undefined;
283
389
  if (!entry) {
284
- out(ctx, detection, { ...detected, entries });
390
+ out(ctx, detection, { ...detected, entries, models: MODEL_CHOICES });
285
391
  return Promise.resolve(0);
286
392
  }
287
- const t3 = detected.targets.includes('t3');
288
- const configured = configuredTargets(targets, entry, t3);
393
+ const t3Installed = detected.targets.includes('t3');
394
+ const configured = configuredTargets(targets, entry, t3Installed);
289
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` +
290
- ['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 });
291
397
  return Promise.resolve(0);
292
398
  },
293
399
  'agents open claude-desktop': openTarget('claude-desktop'),
@@ -7,6 +7,9 @@ export interface TargetEntry {
7
7
  agent: string;
8
8
  targets: DesktopTarget[];
9
9
  }
10
+ /** What Claude Desktop and the Codex app list a target as: the project's router "Shardflux · <project>", an agent
11
+ * "<agent> · <project>" (the thing, then the place, as session titles are; no tag). */
12
+ export declare const targetLabel: (e: Pick<TargetEntry, "agent" | "project">) => string;
10
13
  export declare function targetIdentity(key: string): Pick<TargetEntry, 'host' | 'workspace' | 'project' | 'agent'>;
11
14
  /** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
12
15
  export declare function managedBlock(text: string, body: string): string;
@@ -17,6 +20,8 @@ export declare function t3Apps(dirs: string[], platform?: NodeJS.Platform): Arra
17
20
  }>;
18
21
  /** The app folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS, else /Applications and ~/Applications. */
19
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;
20
25
  export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform, appDirs?: string[]): {
21
26
  targets: string[];
22
27
  clients: string[];
@@ -15,6 +15,9 @@ const shellQuote = (s) => "'" + s.replaceAll("'", "'\\''").replaceAll('%', '%%')
15
15
  const quote = (s) => '"' + s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%') + '"';
16
16
  // Some SSH config readers (T3 Code's host discovery) split Include on whitespace and keep quotes; quote only when needed.
17
17
  const includePath = (s) => /^[^\s"'\\#%]+$/.test(s) ? s : quote(s);
18
+ /** What Claude Desktop and the Codex app list a target as: the project's router "Shardflux · <project>", an agent
19
+ * "<agent> · <project>" (the thing, then the place, as session titles are; no tag). */
20
+ export const targetLabel = (e) => e.agent === 'router' ? `Shardflux · ${e.project}` : `${e.agent} · ${e.project}`;
18
21
  export function targetIdentity(key) {
19
22
  const parts = key.split('/');
20
23
  if (parts.length !== 2 || parts.some(p => !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(p)))
@@ -71,16 +74,22 @@ export function applicationDirs(env) {
71
74
  const home = env.HOME || homedir();
72
75
  return env.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')];
73
76
  }
74
- export function installedTargets(env, platform = process.platform, appDirs) {
75
- // SHARDFLUX_APPLICATIONS_DIRS (colon-separated) replaces the scanned app folders, like SHARDFLUX_CONFIG_DIR does for config.
76
- const dirs = appDirs ?? applicationDirs(env);
77
- 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 => {
78
82
  const info = join(path, 'Contents', 'Info.plist');
79
83
  if (!existsSync(info))
80
84
  return false;
81
85
  const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', info], { input: '', encoding: 'utf8', timeout: 1000 });
82
86
  return result.status === 0 && result.stdout.trim() === bundle;
83
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;
84
93
  const versions = {};
85
94
  const t3Bundles = t3Apps(dirs, platform);
86
95
  const t3 = t3Bundles.length > 0;
@@ -280,7 +289,7 @@ export class AgentTargets {
280
289
  if (!(prop in doc) && !manifest.created[file].includes(prop))
281
290
  manifest.created[file].push(prop);
282
291
  doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e => {
283
- const label = e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
292
+ const label = targetLabel(e);
284
293
  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 }] };
285
294
  })];
286
295
  if (!doc[prop].length && manifest.created[file].includes(prop))
@@ -8,6 +8,14 @@ export interface TerminalConnection {
8
8
  }
9
9
  /** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
10
10
  export declare function sleepingAttach(error: unknown): boolean;
11
+ /**
12
+ * The wait before the next attach (0.16.0+): an attach refused with a Retry-After (503 `host_capacity` while the
13
+ * workspace's host makes room, 429 `quota_exceeded`) waits it with up to 20 % jitter; others back off from 100 ms to
14
+ * 2 s by consecutive failures, which reset once an attach delivers.
15
+ */
16
+ export declare function reattachDelayMs(error: unknown, failures: number, random?: () => number): number;
17
+ /** The line an attach that waits for room prints (0.16.0+), or null for any other refusal. */
18
+ export declare function admissionNotice(error: unknown): string | null;
11
19
  export declare function retryableAttach(error: unknown): boolean;
12
20
  /** Offsets address bytes, including escape sequences and UTF-8 split across frames. */
13
21
  export declare class PtyOutputCursor {
@@ -1,11 +1,29 @@
1
1
  import { StringDecoder } from 'node:string_decoder';
2
- import { PtyConnection, ShardfluxApiError, ShardfluxProtocolError, exitCodePosix } from '@shardflux/sdk';
2
+ import { PtyConnection, ShardfluxApiError, ShardfluxProtocolError, exitCodePosix, isAdmissionRefusal } from '@shardflux/sdk';
3
3
  import { UsageError } from "./errors.js";
4
4
  /** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
5
5
  export function sleepingAttach(error) {
6
6
  return error instanceof ShardfluxApiError && error.code === 'workspace_not_running';
7
7
  }
8
8
  const ASLEEP_NOTICE = '\r\n\x1b[2m· asleep. Type to wake it.\x1b[0m\r\n';
9
+ /**
10
+ * The wait before the next attach (0.16.0+): an attach refused with a Retry-After (503 `host_capacity` while the
11
+ * workspace's host makes room, 429 `quota_exceeded`) waits it with up to 20 % jitter; others back off from 100 ms to
12
+ * 2 s by consecutive failures, which reset once an attach delivers.
13
+ */
14
+ export function reattachDelayMs(error, failures, random = Math.random) {
15
+ const hinted = error instanceof ShardfluxApiError ? error.retryAfterSeconds : undefined;
16
+ if (hinted !== undefined && hinted > 0)
17
+ return Math.floor(hinted * 1000 * (1 + 0.2 * random()));
18
+ return Math.min(2_000, 100 * 2 ** Math.min(failures, 5));
19
+ }
20
+ /** The line an attach that waits for room prints (0.16.0+), or null for any other refusal. */
21
+ export function admissionNotice(error) {
22
+ if (!isAdmissionRefusal(error))
23
+ return null;
24
+ const position = typeof error.details?.queue_position === 'number' ? ` (position ${error.details.queue_position})` : '';
25
+ return error.reason === 'host_capacity' ? `waiting for room on the workspace's host${position}` : 'waiting for a free working slot: every workspace the plan lets work at once is busy';
26
+ }
9
27
  export function retryableAttach(error) {
10
28
  if (error instanceof ShardfluxProtocolError && /invalid|byte gap|omitted/.test(error.message))
11
29
  return false;
@@ -164,6 +182,10 @@ export async function attachTerminal(options) {
164
182
  controller.abort();
165
183
  } });
166
184
  let reconnects = 0;
185
+ // Consecutive failed attaches: the backoff's exponent, back to 0 once an attach delivers a message.
186
+ let failures = 0;
187
+ let lastError;
188
+ let notice = null;
167
189
  let asleep = false;
168
190
  try {
169
191
  while (!signal.aborted) {
@@ -193,6 +215,9 @@ export async function attachTerminal(options) {
193
215
  for await (const message of connection.messages()) {
194
216
  if (signal.aborted)
195
217
  break;
218
+ // The attach delivers: a later drop starts the backoff over, and the next wait for room is said again.
219
+ failures = 0;
220
+ notice = null;
196
221
  const bytes = cursor.consume(message);
197
222
  if (bytes?.length)
198
223
  io.stdout.write(bytes);
@@ -212,6 +237,7 @@ export async function attachTerminal(options) {
212
237
  asleep = true;
213
238
  continue;
214
239
  }
240
+ lastError = error;
215
241
  }
216
242
  finally {
217
243
  const previous = connection;
@@ -220,7 +246,14 @@ export async function attachTerminal(options) {
220
246
  await previous?.close();
221
247
  }
222
248
  reconnects += 1;
223
- await options.sleep(Math.min(2_000, 100 * 2 ** Math.min(reconnects, 5)), signal);
249
+ failures += 1;
250
+ // Waiting for room on the workspace's host (or a working slot): said once, and again when the position changes.
251
+ const waiting = admissionNotice(lastError);
252
+ if (waiting && waiting !== notice)
253
+ io.stderr.write(`\r\n\x1b[2m· ${waiting}\x1b[0m\r\n`);
254
+ notice = waiting;
255
+ await options.sleep(reattachDelayMs(lastError, failures), signal);
256
+ lastError = undefined;
224
257
  }
225
258
  if (inputFailure)
226
259
  throw inputFailure;
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.15.2";
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;