@shardflux/cli 0.22.0 → 0.23.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 (58) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +37 -10
  3. package/dist/acp-journal.js +58 -6
  4. package/dist/acp-login.d.ts +10 -3
  5. package/dist/acp-login.js +31 -4
  6. package/dist/acp-models.d.ts +6 -1
  7. package/dist/acp-models.js +21 -16
  8. package/dist/acp.js +60 -7
  9. package/dist/agent-commands.js +29 -15
  10. package/dist/agent-target-commands.d.ts +20 -1
  11. package/dist/agent-target-commands.js +49 -18
  12. package/dist/agent-targets.js +6 -1
  13. package/dist/args.d.ts +1 -1
  14. package/dist/args.js +8 -7
  15. package/dist/cli-proxy-forward.d.ts +0 -2
  16. package/dist/cli-proxy-forward.js +2 -9
  17. package/dist/cli-proxy-hub.d.ts +22 -30
  18. package/dist/cli-proxy-hub.js +65 -94
  19. package/dist/cli-proxy.d.ts +12 -38
  20. package/dist/cli-proxy.js +51 -168
  21. package/dist/codex-cloud.d.ts +9 -4
  22. package/dist/codex-cloud.js +47 -14
  23. package/dist/codex-linux.d.ts +14 -0
  24. package/dist/codex-linux.js +69 -0
  25. package/dist/command-center-actions.js +13 -4
  26. package/dist/command-center-model.d.ts +2 -0
  27. package/dist/command-center-model.js +6 -1
  28. package/dist/command-center.js +3 -3
  29. package/dist/commands.js +2 -2
  30. package/dist/conversation-terminal.js +3 -2
  31. package/dist/conversations.d.ts +14 -1
  32. package/dist/conversations.js +55 -6
  33. package/dist/desktop-linux.js +16 -3
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +5 -2
  36. package/dist/filesystem-commands.js +25 -3
  37. package/dist/menubar.d.ts +6 -5
  38. package/dist/menubar.js +38 -28
  39. package/dist/move-worktrees.d.ts +13 -7
  40. package/dist/move-worktrees.js +68 -17
  41. package/dist/move.js +20 -25
  42. package/dist/onboarding.js +4 -2
  43. package/dist/prepared-repository.d.ts +2 -0
  44. package/dist/prepared-repository.js +1 -1
  45. package/dist/prepared-repository.py +3 -2
  46. package/dist/project-preparation.d.ts +14 -0
  47. package/dist/project-preparation.js +16 -4
  48. package/dist/project-sync.d.ts +3 -0
  49. package/dist/project-sync.js +4 -1
  50. package/dist/t3-launch.d.ts +2 -1
  51. package/dist/t3-launch.js +11 -2
  52. package/dist/t3-proxy.d.ts +81 -0
  53. package/dist/t3-proxy.js +325 -0
  54. package/dist/terminal-apps.d.ts +11 -0
  55. package/dist/terminal-apps.js +18 -5
  56. package/dist/update.d.ts +2 -0
  57. package/dist/update.js +32 -6
  58. package/package.json +5 -5
package/dist/acp.js CHANGED
@@ -6,7 +6,8 @@ import { AcpRpc, RpcError, objectValue } from "./acp-rpc.js";
6
6
  import { ACP_PROBE_CATALOG, nativeCurrentModel, nativeModelOptions, withoutNativeModel } from "./acp-catalog.js";
7
7
  import { ACP_LIFECYCLE, AcpModelCache, acpScope } from "./acp-models.js";
8
8
  import { AcpJournal } from "./acp-journal.js";
9
- import { getConversation, conversationWorkspace, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
9
+ import { getConversation, conversationWorkspace, preparedInFlight, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
10
+ import { seedEvent } from "./seed-phases.js";
10
11
  import { projectIdentity } from "./project-identity.js";
11
12
  import { projectSyncBoundary } from "./project-sync.js";
12
13
  import { localManifest } from "./project-sync-manifest.js";
@@ -18,6 +19,7 @@ import { ACP_LOGIN_METHOD, acpSignedIn } from "./acp-login.js";
18
19
  import { ensureMenubar, MenubarManager } from "./menubar.js";
19
20
  import { syncAgentConfigAfterStart } from "./agent-config-sync.js";
20
21
  import { savedMachine } from "./filesystem-state.js";
22
+ import { accountVmProxy, prepareVmProxy } from "./t3-proxy.js";
21
23
  const PREFIX = 'shardflux:';
22
24
  export function acpModel(app, id) { return `${PREFIX}${app}:${id}`; }
23
25
  export function parseAcpModel(value) {
@@ -82,6 +84,7 @@ export class ShardfluxAcp {
82
84
  #sessions = new Map();
83
85
  #lifecycle;
84
86
  #cache;
87
+ #proxied = new Map();
85
88
  #initialized = false;
86
89
  #probe = false;
87
90
  constructor(ctx, client, errors = acpError, lifecycle = ACP_LIFECYCLE) { this.#ctx = ctx; this.#client = client; this.#store = new AgentStateStore(ctx.store.dir); this.#cache = new AcpModelCache(ctx.store.dir); this.#errors = errors; this.#lifecycle = lifecycle; }
@@ -253,6 +256,7 @@ export class ShardfluxAcp {
253
256
  }
254
257
  session.preparing = (async () => {
255
258
  await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
259
+ const proxy = this.#proxy();
256
260
  const catalog = await this.#lifecycle.prepare(ctx, record.folder, `acp-${record.id.slice(3).replaceAll('-', '')}`, {
257
261
  allocated: (c, view) => {
258
262
  session.conversation = c;
@@ -272,6 +276,7 @@ export class ShardfluxAcp {
272
276
  },
273
277
  ready: ws => { session.ws = ws; record.scope = acpScope(ctx.apiUrl, ws.data); this.#cache.remember(ctx, record.folder, record.scope); },
274
278
  prepared: () => { session.seedReady = true; prepared(); },
279
+ proxy: { env: { ...proxy.env.claude, ...proxy.env.codex }, ready: ws => this.#proxyReady(ws, proxy) },
275
280
  });
276
281
  ctx.signal.throwIfAborted();
277
282
  if (!record.scope)
@@ -371,13 +376,20 @@ export class ShardfluxAcp {
371
376
  }
372
377
  async #start(session) {
373
378
  const ctx = this.#ctx, { record } = session, app = record.app ?? parseAcpModel(record.model).app;
379
+ // SHARDFLUX_TRACE_SEED: when each step of the connection ends (one line each, wall clock).
380
+ const mark = (name) => seedEvent(`acp_${name}`, Date.now());
381
+ mark('connect');
374
382
  record.app = app;
375
383
  await session.transport?.close();
376
- // The sign-in is checked beside reading the conversation and its VM; it passes before anything starts in the VM.
384
+ // The user's T3 Code proxy setup, read again for every connection; the agent's variables apply when its process starts.
385
+ const proxy = this.#proxy();
386
+ // The sign-in is checked beside reading the conversation and its VM; it passes before anything starts in the VM. A
387
+ // proxy with its own credential needs no Shardflux Claude sign-in.
377
388
  const signIn = (async () => {
378
389
  await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
379
- if (app === 'claude' && !(await cloudClaudeLogin(ctx)))
380
- throw new RpcError(-32000, 'Authentication required: connect Claude Code.', { code: 'unauthenticated', fix: SIGN_IN });
390
+ if (app === 'claude' && !proxy.claudeCredential && !(await cloudClaudeLogin(ctx))) {
391
+ throw new RpcError(-32000, proxy.importable.includes('claude') ? 'Authentication required: connect Claude Code, or move your CLIProxyAPI into your account (shard cli-proxy import).' : 'Authentication required: connect Claude Code.', { code: 'unauthenticated', fix: SIGN_IN });
392
+ }
381
393
  })();
382
394
  signIn.catch(() => undefined);
383
395
  if (!record.conversationId) {
@@ -394,25 +406,36 @@ export class ShardfluxAcp {
394
406
  session.conversation = await resumeConversation(ctx, current);
395
407
  session.ws = await conversationWorkspace(ctx, session.conversation);
396
408
  rememberConversation(ctx, session.conversation, record.folder);
409
+ mark('conversation');
397
410
  }
398
411
  await signIn;
412
+ mark('signed_in');
399
413
  let c = session.conversation;
400
414
  const cell = session.ws.cell();
401
415
  const menubar = ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
402
416
  await restoreConversationBinding(ctx, c, session.ws);
417
+ mark('binding');
403
418
  // The project files are reconciled, device approvals served and the supervisor launched side by side; the native
404
- // session starts once all three are done (0.21.1+).
419
+ // session starts once the supervisor is up, and the connection is done (the prompt goes) once the files are
420
+ // reconciled too (0.21.1+).
405
421
  const files = (async () => {
406
422
  const boundary = await projectSyncBoundary({ ...ctx, signal: AbortSignal.any([ctx.signal, session.stop.signal]) }, session.ws, projectIdentity(record.folder), {
407
423
  root: c.worktree_path, agent: record.id.replace(/^sf-/, 'acp-'), seed: false,
408
424
  stateKey: `acp:${ctx.apiUrl}:${record.id}:${record.folder}`, ...(session.hashes ? { cache: session.hashes } : {}),
409
425
  // Before its first prompt the VM holds what this computer seeded it with, so edits made here since are not conflicts.
410
426
  vmBaseline: !record.prompted,
427
+ // Large media of a prepared start that are still on their way land by themselves (0.22.0+).
428
+ inFlight: () => preparedInFlight(ctx, c.id),
411
429
  });
412
430
  await boundary.reconcile();
431
+ mark('files');
413
432
  return boundary;
414
433
  })();
415
- const launch = cell.exec.run(['sf-agent', 'acp', app, '--conversation', c.id, '--detach'], { cwd: c.worktree_path, timeoutMs: 30_000, signal: ctx.signal, maxOutputBytes: 1 << 20 });
434
+ // The agent starts with the proxy setup's variables, once the VM files it reads are in place.
435
+ const env = proxy.env[app];
436
+ const launch = this.#proxyReady(session.ws, proxy).then(() => cell.exec.run(['sf-agent', 'acp', app, '--conversation', c.id, '--detach'], {
437
+ cwd: c.worktree_path, timeoutMs: 30_000, signal: ctx.signal, maxOutputBytes: 1 << 20, ...(Object.keys(env).length ? { env } : {})
438
+ }));
416
439
  files.catch(() => undefined);
417
440
  launch.catch(() => undefined);
418
441
  session.approvals ??= await serveAcpDeviceApprovals(ctx.store.dir, c.id, async (approval) => {
@@ -422,13 +445,15 @@ export class ShardfluxAcp {
422
445
  const outcome = objectValue(result.outcome);
423
446
  return outcome.outcome === 'selected' && (outcome.optionId === 'once' || outcome.optionId === 'always') ? outcome.optionId : 'deny';
424
447
  });
425
- session.files = await files;
448
+ mark('approvals');
426
449
  const launched = await launch;
450
+ mark('launched');
427
451
  await menubar;
428
452
  if (launched.exitCode !== 0)
429
453
  throw new Error(`ACP supervisor could not attach: ${launched.stderr.trim()}`);
430
454
  const [claimed, statusText] = await Promise.all([getConversation(ctx, c.id), cell.files.readText(`/home/user/.local/share/shardflux/acp/${c.id}/status.json`, { length: 8192 })]);
431
455
  c = claimed;
456
+ mark('status');
432
457
  if (c.harness !== app)
433
458
  throw new Error('The guest did not claim this conversation harness.');
434
459
  session.conversation = c;
@@ -476,13 +501,16 @@ export class ShardfluxAcp {
476
501
  };
477
502
  session.transport = transport;
478
503
  await transport.start();
504
+ mark('journal');
479
505
  if (initialize) {
480
506
  const initialized = objectValue(await transport.rpc.request('initialize', { protocolVersion: 1, clientInfo: { name: 'shardflux', version: CLI_VERSION }, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } }));
481
507
  if (initialized.protocolVersion !== 1)
482
508
  throw new Error('Agent protocol version changed');
509
+ mark('initialized');
483
510
  const nativeId = c.harness_session_id ?? record.nativeId;
484
511
  const result = objectValue(await transport.rpc.request(nativeId ? 'session/load' : 'session/new', { cwd: c.worktree_path, mcpServers: [{ name: 'shardflux', command: '/usr/local/bin/sf-agent', args: ['conversation', 'mcp'], env: [{ name: 'SF_CONVERSATION_ID', value: c.id }] }], ...(nativeId ? { sessionId: nativeId } : {}) }));
485
512
  const id = result.sessionId ?? nativeId;
513
+ mark('session');
486
514
  if (typeof id !== 'string' || !id)
487
515
  throw new Error('Agent returned no native session id');
488
516
  record.nativeId = id;
@@ -505,6 +533,31 @@ export class ShardfluxAcp {
505
533
  this.#cacheSession(session);
506
534
  await this.#modelUpdate(session);
507
535
  }
536
+ // Not connected until the files are: a failed reconcile closes the journal, so the next use connects again.
537
+ try {
538
+ session.files = await files;
539
+ }
540
+ catch (error) {
541
+ await session.transport.close();
542
+ throw error;
543
+ }
544
+ mark('files_ready');
545
+ }
546
+ /** The T3 Code proxy setup as a conversation VM follows it. Its credentials are kept out of every message. */
547
+ #proxy() { return accountVmProxy(this.#ctx); }
548
+ /** Puts the proxy's VM files in place once per VM and setup (prepareVmProxy). */
549
+ #proxyReady(ws, plan) {
550
+ if (!plan.hubInfo && !plan.codexConfig)
551
+ return Promise.resolve();
552
+ const key = JSON.stringify([plan.hubInfo, plan.codexConfig]);
553
+ const done = this.#proxied.get(ws.id);
554
+ if (done?.key === key)
555
+ return done.ready;
556
+ const ready = prepareVmProxy(ws.cell(), plan);
557
+ this.#proxied.set(ws.id, { key, ready });
558
+ ready.catch(() => { if (this.#proxied.get(ws.id)?.ready === ready)
559
+ this.#proxied.delete(ws.id); });
560
+ return ready;
508
561
  }
509
562
  async #close(session) {
510
563
  this.#sessions.delete(session.record.id);
@@ -1,5 +1,5 @@
1
1
  import { linuxTerminalSession } from "./linux-terminal.js";
2
- import { attachable, currentTerminal, openShardInTerminal, referenceFolder, reportTerminalOpen, storedTerminal } from "./terminal-apps.js";
2
+ import { attachable, currentTerminal, handedToTerminal, openShardInTerminal, referenceFolder, reportTerminalOpen, storedTerminal } from "./terminal-apps.js";
3
3
  import { repositoryName } from "./files-mirror.js";
4
4
  import { prepareSetupCache, recordSetupCacheFailure } from "./setup-cache.js";
5
5
  import { launchCodexCloud, openCodexConversation } from "./codex-cloud.js";
@@ -30,7 +30,7 @@ import { openInFor } from "./t3-mode.js";
30
30
  import { storedTitlePrefix } from "./client-settings.js";
31
31
  import { openClaudeDesktopSession, openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
32
32
  import { guestFirstPrompt, INBOX_MAX_CHARS, submitInitialPrompt, prepareInitialCodexProject } from "./initial-prompt.js";
33
- import { conversationWorkspace, conversationsUnavailable, getConversation, listConversations, resumeConversation } from "./conversations.js";
33
+ import { conversationForOpen, conversationWorkspace, conversationsUnavailable, getConversation, listConversations, resumeConversation } from "./conversations.js";
34
34
  import { conversationClient } from "./conversation-commands.js";
35
35
  import { conversationTerminal, reopenConversation } from "./conversation-terminal.js";
36
36
  import { CHOICE_PENDING, choiceReport, hasChoice, launchChoice, modelChoice, runFlags } from "./agent-models.js";
@@ -325,7 +325,8 @@ export async function launchProjectWith(ctx, values, [path = './', explicitApp],
325
325
  * terminal), and `--in terminal` typed in a terminal other than the chosen one.
326
326
  */
327
327
  function opensInTerminalApp(ctx, values) {
328
- if (values.detach === true)
328
+ // A terminal this CLI opened for the conversation attaches there, never in yet another one (0.23.0+).
329
+ if (values.detach === true || handedToTerminal(ctx.io.env))
329
330
  return false;
330
331
  const here = attachable(ctx.io);
331
332
  if (values.in === undefined)
@@ -364,15 +365,26 @@ export const AGENT_HANDLERS = {
364
365
  async 'agents open'(ctx, values, [agent = 'main']) {
365
366
  if (values.app !== undefined && values.app !== 'claude' && values.app !== 'codex')
366
367
  throw new UsageError('--app must be claude or codex');
367
- // The person's terminal app (0.20.1+): before any cloud work, the new tab owns the attach, credentials and prompts.
368
- if (opensInTerminalApp(ctx, values))
369
- return openInTerminalApp(ctx, agent, values);
368
+ // A Codex Cloud conversation opens its chat in Codex, never in a terminal (0.23.0+): it is resolved before any
369
+ // terminal hand-off, by its id or by its computer's key, which menus before Linux 0.1.9 and menu bar 0.8.4 pass.
370
+ // Nimblefox (2026-10-13): the hand-off came first, and from a Codex shell each new terminal opened another one.
371
+ // The person's terminal app (0.20.1+): after that lookup, the new tab owns the attach, credentials and prompts; a
372
+ // lookup this shell cannot make (signed out here, offline) leaves the hand-off to it.
373
+ const handOff = opensInTerminalApp(ctx, values);
370
374
  let resolvedConversation;
371
- if (/^conv_[a-z0-9]{26}$/.test(agent)) {
372
- resolvedConversation = await getConversation(ctx, agent);
373
- if (resolvedConversation.route === 'codex-cloud')
374
- return openCodexConversation(ctx, resolvedConversation);
375
+ if (/^conv_[a-z0-9]{26}$/.test(agent) || agent.includes('/')) {
376
+ try {
377
+ resolvedConversation = await conversationForOpen(ctx, agent);
378
+ }
379
+ catch (error) {
380
+ if (!handOff)
381
+ throw error;
382
+ }
375
383
  }
384
+ if (resolvedConversation?.route === 'codex-cloud')
385
+ return openCodexConversation(ctx, resolvedConversation, values.original === true ? true : values.history);
386
+ if (handOff)
387
+ return openInTerminalApp(ctx, agent, values);
376
388
  if (values.in === 'auto') {
377
389
  // The menu and other launchers use the same CLI-owned target selection. Never put provider URLs in a client.
378
390
  // A conversation id opens that conversation's own session, also when its computer hosts other threads.
@@ -405,6 +417,8 @@ export const AGENT_HANDLERS = {
405
417
  // Codex's default open implementation owns Cloud/desktop routing. PAS-XB replaces that path; do not pin
406
418
  // the menu to the old per-VM SSH target. An explicit saved desktop target still takes precedence.
407
419
  if ((!saved || !detected.targets.includes(saved)) && app === 'codex') {
420
+ if (resolvedConversation?.route === 'terminal')
421
+ return terminalAuto(ctx, ws, opened, values);
408
422
  const next = { ...values };
409
423
  delete next.in;
410
424
  return AGENT_HANDLERS['agents open'](ctx, next, [resolvedConversation?.id ?? ws.key]);
@@ -414,7 +428,7 @@ export const AGENT_HANDLERS = {
414
428
  const handler = HANDLERS[`agents open ${saved}`];
415
429
  if (!handler)
416
430
  throw new UsageError('Choose an installed open target with shard agents targets.');
417
- return handler(ctx, { ...values, in: saved }, [ws.key]);
431
+ return handler(ctx, { ...values, in: saved }, [opened]);
418
432
  }
419
433
  // Claude opens where people work (0.15.1+): the conversation in Claude Desktop when it is installed, else Claude
420
434
  // Code in a terminal. The browser (claude.ai) only when it is the chosen target (--in web).
@@ -433,10 +447,10 @@ export const AGENT_HANDLERS = {
433
447
  await syncCredentials(ctx);
434
448
  let ws;
435
449
  let conversation;
436
- if (/^conv_[a-z0-9]{26}$/.test(agent)) {
450
+ if (resolvedConversation || /^conv_[a-z0-9]{26}$/.test(agent)) {
437
451
  conversation = resolvedConversation ?? await getConversation(ctx, agent);
438
452
  if (conversation.route === 'codex-cloud')
439
- return openCodexConversation(ctx, conversation);
453
+ return openCodexConversation(ctx, conversation, values.original === true ? true : values.history);
440
454
  conversation = await resumeConversation(ctx, conversation);
441
455
  ws = await conversationWorkspace(ctx, conversation);
442
456
  }
@@ -469,7 +483,7 @@ export const AGENT_HANDLERS = {
469
483
  if (matches.length !== 1)
470
484
  throw new UsageError('Use the conversation id.');
471
485
  if (matches[0].route === 'codex-cloud')
472
- return openCodexConversation(ctx, matches[0]);
486
+ return openCodexConversation(ctx, matches[0], values.original === true ? true : values.history);
473
487
  conversation = await resumeConversation(ctx, matches[0]);
474
488
  ws = await conversationWorkspace(ctx, conversation);
475
489
  }
@@ -477,7 +491,7 @@ export const AGENT_HANDLERS = {
477
491
  if (!conversation && ws.labels.shard_conversation) {
478
492
  conversation = await getConversation(ctx, ws.labels.shard_conversation);
479
493
  if (conversation.route === 'codex-cloud')
480
- return openCodexConversation(ctx, conversation);
494
+ return openCodexConversation(ctx, conversation, values.original === true ? true : values.history);
481
495
  conversation = await resumeConversation(ctx, conversation);
482
496
  ws = await conversationWorkspace(ctx, conversation);
483
497
  }
@@ -1,9 +1,10 @@
1
1
  import { Duplex } from 'node:stream';
2
- import type { CellClient, Workspace } from '@shardflux/sdk';
2
+ import type { CellClient, T3Link, Workspace } from '@shardflux/sdk';
3
3
  import type { Conversation } from './conversations.js';
4
4
  import type { Ctx, Handler, Io } from './commands.js';
5
5
  import { AgentTargets } from './agent-targets.js';
6
6
  import type { DesktopTarget } from './agent-targets.js';
7
+ import { CodedError } from './errors.js';
7
8
  import type { T3Install, T3ThreadRequest } from './t3-mode.js';
8
9
  /** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
9
10
  export declare function installDeviceKey(cell: CellClient, key: string, signal?: AbortSignal): Promise<void>;
@@ -35,5 +36,23 @@ export declare function openClaudeDesktopSession(ctx: Ctx, ws: Workspace, conver
35
36
  export declare function openDesktopWorkspace(ctx: Ctx, ws: Workspace, target: DesktopTarget, t3Version?: string, prewarmed?: boolean, thread?: T3ThreadRequest, install?: T3Install, conversation?: string | null): Promise<number>;
36
37
  /** T3 Code's Connections page (its t3code:// link), where a project linked with T3 Connect is listed. */
37
38
  export declare const T3_CONNECTIONS_PAGE = "t3code://app/settings/connections";
39
+ /** A workspace's link as `agents open t3` reports it (`t3_link`): the PUT answer, or the outcome the status read gave. */
40
+ export type T3LinkView = Omit<T3Link, 'status'> & {
41
+ status: T3Link['status'] | 'error';
42
+ last_error?: string;
43
+ needs_login?: boolean;
44
+ };
45
+ /** How long `agents open t3` waits for a link in progress to be linked or refused (at most the wake timeout). */
46
+ export declare const T3_LINK_WAIT_MS = 30000;
47
+ /**
48
+ * The link T3 Code needs is refused (`t3_link_failed`, with the API's remedy: for T3 Connect's environment limit, its
49
+ * Connections page) or still linking after T3_LINK_WAIT_MS (`t3_link_pending`). Both name the workspace, conversation
50
+ * and any started thread, so a client opens what exists again rather than starting another.
51
+ */
52
+ export declare function t3LinkNotReady(link: T3LinkView, ref: {
53
+ workspace_key: string;
54
+ conversation_id: string | null;
55
+ t3_thread_id: string | null;
56
+ }): CodedError;
38
57
  export declare function queueT3Prewarm(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
39
58
  export declare const TARGET_HANDLERS: Record<string, Handler>;
@@ -5,19 +5,20 @@ 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 { getConversation } from "./conversations.js";
8
+ import { conversationForOpen } from "./conversations.js";
9
9
  import { out, resolveWorkspace } from "./commands.js";
10
10
  import { MODEL_CHOICES } from "./agent-models.js";
11
11
  import { AgentTargets, installedTargets, targetLabel } from "./agent-targets.js";
12
12
  import { join, relative, resolve } from 'node:path';
13
13
  import { projectIdentity } from "./project-identity.js";
14
14
  import { projectWorkspacePath } from "./agent-repository.js";
15
- import { UsageError, describeError } from "./errors.js";
15
+ import { CodedError, UsageError, describeError } from "./errors.js";
16
16
  import { t3PhoneLink } from "./t3-phone.js";
17
17
  import { prepareDesktopRouter } from "./conversation-router.js";
18
18
  import { openUrl } from "./browser.js";
19
19
  import { agentMachineName, prepareAgentMachine } from "./agent-machine.js";
20
20
  import { prewarmT3 } from "./t3-runtime.js";
21
+ import { followT3ProxyInVm } from "./t3-proxy.js";
21
22
  import { AgentStateStore } from "./agent-state.js";
22
23
  import { workspaceRepositoryPath } from "./agent-repository.js";
23
24
  import { openInChoices, openInTargets, openT3, saveOpenIn, startSshThread, t3Install, t3NotInstalled, t3OpenFailed, t3Status } from "./t3-mode.js";
@@ -221,16 +222,23 @@ async function openLink(url, env) {
221
222
  });
222
223
  }
223
224
  const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
225
+ // A conversation started in T3 (its Shardflux provider) opens its own thread there (0.17.0+); a Codex Cloud one opens
226
+ // its Codex chat (0.23.0+). openT3Conversation checks T3 Code itself where it needs it.
227
+ let conversation = await conversationForOpen(ctx, /^conv_[a-z0-9]{26}$/.test(ref) ? ref : keyOf(ref));
228
+ if (target === 't3' && conversation)
229
+ return openT3Conversation(ctx, conversation.id, values);
230
+ // A Codex Cloud conversation opens its existing thread in the Codex app, not an SSH environment (0.23.0+).
231
+ if (target === 'codex-app' && conversation?.route === 'codex-cloud')
232
+ return (await import("./codex-cloud.js")).openCodexConversation(ctx, conversation);
224
233
  const detected = installedTargets(ctx.io.env);
225
234
  if (target === 't3' && !detected.targets.includes('t3'))
226
235
  throw t3NotInstalled();
236
+ if (target === 'codex-app' && !detected.targets.includes(target) && detected.clients.includes('codex-app')) {
237
+ throw new UsageError('On Linux the Codex app opens Codex Cloud conversations (shard agents open codex-app <conversation>).', `Open this one with: shard agents open ${ref}`);
238
+ }
227
239
  if (target !== 'web' && !detected.targets.includes(target))
228
240
  throw new UsageError(`${target} is not installed on this machine`);
229
- // A conversation started in T3 (its Shardflux provider) opens its own thread there (0.17.0+).
230
- if (target === 't3' && /^conv_[a-z0-9]{26}$/.test(ref))
231
- return openT3Conversation(ctx, ref, values);
232
241
  // A conversation id opens that conversation's own session on its computer (claude.ai, the desktop apps).
233
- let conversation = /^conv_[a-z0-9]{26}$/.test(ref) ? await getConversation(ctx, ref) : undefined;
234
242
  // A conversation whose VM was removed restores into a fresh one first (0.18.1+).
235
243
  let ws;
236
244
  if (conversation) {
@@ -269,6 +277,9 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
269
277
  await prepareAgentMachine(ctx, ws);
270
278
  if (target === 't3' && t3Version && !prewarmed)
271
279
  await queueT3Prewarm(ctx, ws, t3Version);
280
+ // The VM's T3 Code follows the user's T3 proxy setup, before its server starts for a thread or the T3 Connect link.
281
+ if (target === 't3')
282
+ await followT3ProxyInVm(ctx, ws);
272
283
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
273
284
  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);
274
285
  const instructions = target === 't3'
@@ -291,8 +302,13 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
291
302
  // The first time a project is set up for T3 Code, T3 opens on its Connections page once the project is linked: the
292
303
  // project is listed there (or T3's own Sign in to T3 Connect, when T3 Code is signed out).
293
304
  if (target === 't3') {
294
- if (fresh && phone?.link.status === 'linking')
295
- phone = await waitT3Linked(ctx, ws, phone);
305
+ // T3 Code lists the project only once its T3 Connect link is linked (0.23.0+): a refused or still pending link is
306
+ // reported as such, naming what exists, instead of an opened T3 that does not show it.
307
+ if (phone?.link.status === 'linking')
308
+ phone = await waitT3Link(ctx, ws, phone);
309
+ const ref = { workspace_key: ws.key, conversation_id: conversation ?? (ws.labels.shard_conversation || null), t3_thread_id: started?.t3_thread_id ?? null };
310
+ if (phone?.link.status === 'error' || phone?.link.status === 'linking')
311
+ throw t3LinkNotReady(phone.link, ref);
296
312
  const page = fresh && phone?.link.status === 'linked' ? T3_CONNECTIONS_PAGE : undefined;
297
313
  // The thread exists once startSshThread returns: a failure to show T3 names it rather than inviting a retry.
298
314
  try {
@@ -338,25 +354,40 @@ const t3LinkLine = (link, signIn, needsLogin) => signIn
338
354
  : needsLogin ? `To have ${link.label} appear in T3 Code and the T3 app, sign in with shard auth login, then run this again.\n`
339
355
  : link.status === 'linked' ? `${link.label} is linked to your T3 account.\n`
340
356
  : `Linking ${link.label} to your T3 account…\n`;
341
- /** A project linking for the first time: T3 lists it once linked, so its Connections page waits for that (at most 20 s). */
342
- async function waitT3Linked(ctx, ws, phone) {
343
- const until = Date.now() + 20_000;
344
- while (Date.now() < until && !ctx.signal?.aborted) {
345
- await delay(2000);
357
+ /** How long `agents open t3` waits for a link in progress to be linked or refused (at most the wake timeout). */
358
+ export const T3_LINK_WAIT_MS = 30_000;
359
+ /** A link in progress: its outcome from the account's T3 status, read every 2 s for at most T3_LINK_WAIT_MS. */
360
+ async function waitT3Link(ctx, ws, phone) {
361
+ const until = Date.now() + Math.min(T3_LINK_WAIT_MS, ctx.wakeTimeoutMs ?? T3_LINK_WAIT_MS);
362
+ for (;;) {
346
363
  try {
347
364
  const known = (await ctx.cloud().t3.status()).links.find(l => l.workspace_id === ws.id);
348
- if (known?.status === 'linked') {
349
- const link = { ...phone.link, status: 'linked' };
365
+ if (known && known.status !== 'linking') {
366
+ const link = { ...phone.link, status: known.status === 'linked' ? 'linked' : known.status === 'needs_sign_in' ? 'needs_sign_in' : 'error', ...(known.last_error ? { last_error: known.last_error } : {}) };
350
367
  return { line: t3LinkLine(link), link };
351
368
  }
352
- if (known && known.status !== 'linking')
353
- return phone;
354
369
  }
355
370
  catch {
356
371
  return phone;
357
372
  }
373
+ if (Date.now() >= until || ctx.signal?.aborted)
374
+ return phone;
375
+ await delay(2000);
376
+ }
377
+ }
378
+ /**
379
+ * The link T3 Code needs is refused (`t3_link_failed`, with the API's remedy: for T3 Connect's environment limit, its
380
+ * Connections page) or still linking after T3_LINK_WAIT_MS (`t3_link_pending`). Both name the workspace, conversation
381
+ * and any started thread, so a client opens what exists again rather than starting another.
382
+ */
383
+ export function t3LinkNotReady(link, ref) {
384
+ const again = `shard agents open t3 ${ref.conversation_id ?? ref.workspace_key}`;
385
+ const details = { ...ref, t3_link: link };
386
+ if (link.status !== 'error') {
387
+ return new CodedError('t3_link_pending', `Linking ${link.label} to your T3 account. T3 Code lists it once it is linked.`, `Open it in a moment: ${again}`, details);
358
388
  }
359
- return phone;
389
+ const limit = /environment limit/i.test(link.last_error ?? '');
390
+ return new CodedError('t3_link_failed', link.last_error ?? `T3 Connect could not link ${link.label}.`, limit ? `In T3 Code open Settings → Connections (${T3_CONNECTIONS_PAGE}), remove an environment you no longer need, then: ${again}` : `Then open it again: ${again}`, limit ? { ...details, recovery: { title: 'Open T3 Code Connections', url: T3_CONNECTIONS_PAGE } } : details);
360
391
  }
361
392
  export async function queueT3Prewarm(ctx, ws, version) {
362
393
  try {
@@ -8,6 +8,7 @@ import { UsageError } from "./errors.js";
8
8
  import { projectWorkspacePath } from "./agent-repository.js";
9
9
  import { withTargetLock } from "./target-lock.js";
10
10
  import { linuxApplicationDirs, linuxSupportsAcp, linuxT3Apps } from "./t3-linux.js";
11
+ import { linuxCodexApps } from "./codex-linux.js";
11
12
  import { t3ArchiveSupportsAcp } from "./t3-asar.js";
12
13
  import { compareT3Versions } from "./t3-version.js";
13
14
  const PERSISTED_TARGETS = ['claude-desktop', 'codex-app'];
@@ -132,7 +133,11 @@ export function installedTargets(env, platform = process.platform, appDirs) {
132
133
  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);
133
134
  if (t3)
134
135
  clients.push('t3');
135
- // Linux has T3 Code (0.17.0+); Claude Desktop and the Codex app are macOS apps.
136
+ // The Codex app on Linux (0.23.0+): the entry that handles codex:// (OpenAI's ChatGPT app for Linux, or a community
137
+ // build). It opens Codex Cloud conversations; its SSH environments stay a macOS target.
138
+ if (platform === 'linux' && linuxCodexApps(dirs, env).length > 0)
139
+ clients.push('codex-app');
140
+ // Linux has T3 Code (0.17.0+); Claude Desktop and the Codex app's SSH environments are macOS targets.
136
141
  const desktop = platform === 'darwin' ? clients.filter(c => c.endsWith('-app') || c === 'claude-desktop' || c === 't3') : platform === 'linux' && t3 ? ['t3'] : [];
137
142
  return { targets: [...desktop, 'web'], clients, versions };
138
143
  }
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.22.0";
1
+ export declare const CLI_VERSION = "0.23.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
15
  import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
16
- export const CLI_VERSION = '0.22.0';
16
+ export const CLI_VERSION = '0.23.0';
17
17
  export const GLOBAL_OPTIONS = {
18
18
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
19
19
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -1289,8 +1289,8 @@ const AGENT_COMMANDS = [
1289
1289
  session: { type: 'string', value: '<id>', description: 'The session to move: a Claude Code session id or Codex thread id.' },
1290
1290
  path: { type: 'string', value: '<path>', description: 'The project folder (default: the current folder).' },
1291
1291
  plan: { type: 'boolean', description: 'Inspect all registered worktree paths, reference hits and file bytes before any upload.' },
1292
- 'preserve-all-worktrees': { type: 'boolean', description: 'Preserve every registered worktree, all local refs, indices and ignored files. Reports paths and bytes; keeps a durable recovery archive.' },
1293
- 'this-worktree-only': { type: 'boolean', description: 'Explicitly leave other registered worktrees and their local refs on this computer.' },
1292
+ 'preserve-all-worktrees': { type: 'boolean', description: 'Also bring every registered worktree, all local refs, indices and ignored files. Reports paths and bytes; keeps a durable recovery archive.' },
1293
+ 'this-worktree-only': { type: 'boolean', description: 'Move the session’s checkout only (the default): other worktrees and local refs stay on this computer.' },
1294
1294
  claude: { type: 'boolean', description: 'With --last or --list: Claude Code sessions only.' },
1295
1295
  codex: { type: 'boolean', description: 'With --last or --list: Codex sessions only.' },
1296
1296
  list: { type: 'boolean', description: 'List this project’s sessions on this computer, and where the moved ones continue.' },
@@ -1316,7 +1316,7 @@ const AGENT_COMMANDS = [
1316
1316
  },
1317
1317
  })),
1318
1318
  ...['status', 'enable', 'disable'].map(action => ({ path: ['agents', 'codex', action], summary: `${action} the account's billed Codex Cloud connector`, positionals: [], options: {} })),
1319
- { path: ['agents', 'open'], summary: 'Open a conversation (Codex Cloud or the native Claude session)', description: 'Codex opens its existing Cloud thread without waking its VM. Claude attaches its native session.', positionals: [{ name: 'agent', required: true }], options: { ...AGENT_OPTIONS, app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' }, in: { type: 'string', value: '<terminal|auto|claude-desktop|codex-app|web|t3>', description: 'Where to open it. auto (0.15.1+): the saved target, else a Claude conversation in Claude Desktop when it is installed, else in this terminal; web opens claude.ai. terminal (0.20.1+): a new tab of the terminal app agents targets --terminal chose, or this terminal when it is that one.' },
1319
+ { path: ['agents', 'open'], summary: 'Open a conversation (Codex Cloud or the native Claude session)', description: 'Codex opens its existing Cloud thread without waking its VM. Claude attaches its native session.', positionals: [{ name: 'agent', required: true }], options: { ...AGENT_OPTIONS, original: { type: 'boolean', description: 'Open the original Codex Cloud history after a connector change.' }, history: { type: 'string', value: '<thread-id>', description: 'Open an earlier thread listed in the Cloud conversation history.' }, app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' }, in: { type: 'string', value: '<terminal|auto|claude-desktop|codex-app|web|t3>', description: 'Where to open it. auto (0.15.1+): the saved target, else a Claude conversation in Claude Desktop when it is installed, else in this terminal; web opens claude.ai. terminal (0.20.1+): a new tab of the terminal app agents targets --terminal chose, or this terminal when it is that one.' },
1320
1320
  window: { type: 'boolean', description: 'With --in terminal: a new window of the terminal app instead of a tab (0.20.1+).' } } },
1321
1321
  { path: ['agents', 'terminal-run'], hidden: true, summary: 'Run a command in the terminal app agents targets --terminal chose (0.20.1+)', positionals: [{ name: 'command', required: true, variadic: true }], options: {
1322
1322
  cwd: { type: 'string', value: '<path>', description: 'The folder the new tab starts in (default the current folder).' },
@@ -1353,10 +1353,11 @@ const CLI_PROXY_COMMANDS = [
1353
1353
  { 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: {
1354
1354
  config: { type: 'string', value: '<path>', description: 'Your CLIProxyAPI config file (default: the one the running CLIProxyAPI uses, else the usual locations).' },
1355
1355
  yes: { type: 'boolean', description: 'Stop the local CLIProxyAPI without asking.' },
1356
- use: { type: 'string', multiple: true, value: '<email|file>', description: 'The login that is active for its provider when this computer has several (repeatable, one per provider). Default: the hub’s active login, else the oldest.' },
1356
+ use: { type: 'string', multiple: true, value: '<email|file>', description: 'Serve its provider with only this login; the provider’s others are kept, disabled (repeatable, one per provider). Default: every login as it is on this computer.' },
1357
1357
  }, examples: ['shard cli-proxy import', 'shard cli-proxy'] },
1358
- { path: ['cli-proxy', 'use'], summary: 'Make a login its provider’s active one in your hosted CLIProxyAPI', positionals: [{ name: 'login', required: true }], options: {},
1359
- examples: ['shard cli-proxy use ana@example.com'] },
1358
+ { path: ['cli-proxy', 'use'], summary: 'Choose which logins your hosted CLIProxyAPI uses: one for its provider, or all', description: 'Your hosted CLIProxyAPI routes requests across its enabled logins as its config says. `use <login>` keeps only that login enabled for its provider (the others stay in the hub, disabled); `use --all` enables every login again.', positionals: [{ name: 'login' }], options: {
1359
+ all: { type: 'boolean', description: 'Enable every login; the hub routes across them as its config says.' },
1360
+ }, examples: ['shard cli-proxy use ana@example.com', 'shard cli-proxy use --all'] },
1360
1361
  { 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: {
1361
1362
  yes: { type: 'boolean', description: 'Do it without asking.' },
1362
1363
  } },
@@ -13,8 +13,6 @@ export interface ForwarderOptions {
13
13
  * for once, so a URL that stays invalid is answered as is.
14
14
  */
15
15
  renew?: () => Promise<void>;
16
- /** Called as each request (or upgrade) arrives, before it is forwarded: the hub is about to be awake. */
17
- onRequest?: () => void;
18
16
  host?: string;
19
17
  port?: number;
20
18
  /** How long one request may be retried for (default 60 s, plus the time a repair takes). */
@@ -97,8 +97,8 @@ export class CliProxyForwarder {
97
97
  this.dropIdleConnections();
98
98
  tick = now;
99
99
  }, 5_000).unref();
100
- this.#server = createServer((req, res) => { this.#arrived(); void this.#handle(req, res); });
101
- this.#server.on('upgrade', (req, socket, head) => { this.#arrived(); void this.#upgrade(req, socket, head); });
100
+ this.#server = createServer((req, res) => void this.#handle(req, res));
101
+ this.#server.on('upgrade', (req, socket, head) => void this.#upgrade(req, socket, head));
102
102
  // Long streams (SSE, Responses) must not be cut by the server's own timeouts.
103
103
  this.#server.requestTimeout = 0;
104
104
  this.#server.headersTimeout = 60_000;
@@ -327,13 +327,6 @@ export class CliProxyForwarder {
327
327
  delay = Math.min(delay * 2, 8000);
328
328
  }
329
329
  }
330
- /** onRequest never fails or delays a request. */
331
- #arrived() {
332
- try {
333
- this.#o.onRequest?.();
334
- }
335
- catch { /* the hook is advisory */ }
336
- }
337
330
  async #handle(req, res) {
338
331
  if (pathOnly(req.url) === IDENTITY_PATH) {
339
332
  const info = this.#o.hub();