@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
package/dist/handlers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEVICE_HANDLERS } from "./device-approvals.js";
1
2
  import { CREDENTIAL_HANDLERS } from "./credential-prompts.js";
2
3
  import { CONVERSATION_HANDLERS } from "./conversation-commands.js";
3
4
  /**
@@ -15,5 +16,8 @@ import { MENUBAR_HANDLERS } from "./menubar.js";
15
16
  import { UPDATE_HANDLERS } from "./update.js";
16
17
  import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
17
18
  import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversation-local.js";
19
+ import { TWIN_HANDLERS } from "./worktree-twin-engine.js";
18
20
  import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
19
- export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
21
+ import { THREAD_HANDLERS } from "./thread-on-computer.js";
22
+ import { CLI_PROXY_HANDLERS } from "./cli-proxy.js";
23
+ export const HANDLERS = { ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
package/dist/index.d.ts CHANGED
@@ -24,4 +24,8 @@ export type { ErrorInfo } from './errors.js';
24
24
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
25
25
  export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from './timing.js';
26
26
  export { readAgentCredentials, syncAgentCredentials } from './agent-credentials.js';
27
+ export { detectLocalCliProxy, importCliProxy } from './cli-proxy.js';
28
+ export type { ImportOptions, ImportResult, LocalCliProxy, LocalLogin } from './cli-proxy.js';
29
+ export { ensureCliProxyHub, readHubInfo } from './cli-proxy-hub.js';
30
+ export type { EnsureOptions, EnsureResult, HubInfo } from './cli-proxy-hub.js';
27
31
  export type { CredentialSources } from './agent-credentials.js';
package/dist/index.js CHANGED
@@ -16,3 +16,6 @@ export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, Execut
16
16
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
17
17
  export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from "./timing.js";
18
18
  export { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
19
+ // Hosted CLIProxyAPI (onboarding offers the import when detectLocalCliProxy() finds logins).
20
+ export { detectLocalCliProxy, importCliProxy } from "./cli-proxy.js";
21
+ export { ensureCliProxyHub, readHubInfo } from "./cli-proxy-hub.js";
@@ -0,0 +1,12 @@
1
+ import type { Ctx } from './commands.js';
2
+ export declare const HELPER_UNIT = "shardflux-helper.service";
3
+ export declare const helperUnitPath: (home: string) => string;
4
+ /** No credentials in the unit: the helper reads the same private store as shard. */
5
+ export declare function helperUnit(ctx: Pick<Ctx, 'runtime' | 'store' | 'apiUrl' | 'io'>): string;
6
+ export type Systemctl = (args: string[]) => boolean;
7
+ export declare function installLinuxHelper(ctx: Ctx, deps?: {
8
+ platform?: NodeJS.Platform;
9
+ run?: Systemctl;
10
+ }): boolean;
11
+ /** The caller owns fallback lifetime; never leave an unmanaged background process behind. */
12
+ export declare function startLinuxHelper(ctx: Ctx): () => Promise<void>;
@@ -0,0 +1,88 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, realpathSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { CLI_VERSION } from "./args.js";
6
+ import { writePrivateFile } from "./credentials.js";
7
+ import { runDeviceHelper } from "./device-helper.js";
8
+ export const HELPER_UNIT = 'shardflux-helper.service';
9
+ export const helperUnitPath = (home) => join(home, '.config/systemd/user', HELPER_UNIT);
10
+ const quote = (s) => '"' + s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('\n', '\\n').replaceAll('\r', '\\r') + '"';
11
+ /** No credentials in the unit: the helper reads the same private store as shard. */
12
+ export function helperUnit(ctx) {
13
+ if (!ctx.runtime)
14
+ throw new Error('An installed shard is required.');
15
+ let script = ctx.runtime.script;
16
+ try {
17
+ script = realpathSync(script);
18
+ }
19
+ catch { /* preserve the supplied installed path */ }
20
+ const env = { HOME: ctx.io.env.HOME, PATH: ctx.io.env.PATH ?? '/usr/local/bin:/usr/bin:/bin', SHARDFLUX_CONFIG_DIR: ctx.store.dir,
21
+ SHARDFLUX_API_URL: ctx.apiUrl, SHARDFLUX_NO_UPDATE_CHECK: '1',
22
+ ...Object.fromEntries(['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].flatMap(k => ctx.io.env[k] ? [[k, ctx.io.env[k]]] : [])) };
23
+ return `# Shardflux CLI ${CLI_VERSION}\n[Unit]\nDescription=Shardflux background helper\nAfter=network-online.target\n\n[Service]\nType=simple\nExecStart=${[ctx.runtime.node, script, 'device-helper', '--service'].map(s => quote(s.replaceAll('$', '$$'))).join(' ')}\n${Object.entries(env).map(([k, v]) => `Environment=${quote(`${k}=${v}`)}`).join('\n')}\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=10\nUMask=0077\n\n[Install]\nWantedBy=default.target\n`;
24
+ }
25
+ export function installLinuxHelper(ctx, deps = {}) {
26
+ if ((deps.platform ?? process.platform) !== 'linux' || !ctx.runtime || !ctx.io.env.HOME)
27
+ return false;
28
+ const run = deps.run ?? (args => spawnSync('systemctl', ['--user', ...args], { env: ctx.io.env, stdio: 'ignore', timeout: 5000 }).status === 0);
29
+ // Probe before writing: containers without a user manager use the command-lifetime helper.
30
+ if (!run(['show-environment']))
31
+ return false;
32
+ const file = helperUnitPath(ctx.io.env.HOME), text = helperUnit(ctx);
33
+ const receipt = join(ctx.store.dir, 'linux-helper-started'), digest = createHash('sha256').update(text).digest('hex');
34
+ let before = '', started = '';
35
+ try {
36
+ before = readFileSync(file, 'utf8');
37
+ }
38
+ catch { /* first sign-in */ }
39
+ try {
40
+ started = readFileSync(receipt, 'utf8');
41
+ }
42
+ catch { /* no successful start yet */ }
43
+ if (before !== text) {
44
+ writePrivateFile(dirname(file), file, text);
45
+ if (!run(['daemon-reload']) || !run(['enable', '--now', HELPER_UNIT]))
46
+ return false;
47
+ if (before && !run(['restart', HELPER_UNIT]))
48
+ return false;
49
+ }
50
+ else if (!run(['is-active', '--quiet', HELPER_UNIT])) {
51
+ if (!run(['enable', '--now', HELPER_UNIT]))
52
+ return false;
53
+ }
54
+ else if (started !== digest && !run(['restart', HELPER_UNIT]))
55
+ return false;
56
+ // A failed restart must be retried by the next command even though the new unit was already written.
57
+ if (started !== digest)
58
+ writePrivateFile(ctx.store.dir, receipt, digest);
59
+ return true;
60
+ }
61
+ /** The caller owns fallback lifetime; never leave an unmanaged background process behind. */
62
+ export function startLinuxHelper(ctx) {
63
+ const noop = () => Promise.resolve();
64
+ if (process.platform !== 'linux' || !ctx.runtime)
65
+ return noop;
66
+ try {
67
+ ctx.apiKey();
68
+ }
69
+ catch {
70
+ return noop;
71
+ }
72
+ try {
73
+ if (installLinuxHelper(ctx))
74
+ return noop;
75
+ }
76
+ catch { /* use the command-lifetime helper */ }
77
+ const notice = join(ctx.store.dir, 'linux-helper-notice');
78
+ try {
79
+ readFileSync(notice);
80
+ }
81
+ catch {
82
+ ctx.io.stderr.write('Shardflux helper runs while shard commands are open on this computer.\n');
83
+ writePrivateFile(ctx.store.dir, notice, '1\n');
84
+ }
85
+ const abort = new AbortController();
86
+ const work = runDeviceHelper({ ...ctx, signal: AbortSignal.any([ctx.signal, abort.signal]) }, { service: true }).catch(() => undefined);
87
+ return async () => { abort.abort(); await work; };
88
+ }
@@ -0,0 +1,11 @@
1
+ import type { Ctx } from './commands.js';
2
+ import type { Values } from './args.js';
3
+ /** Linux terminals use the existing native Codex harness; explicit app routes keep Cloud. */
4
+ export declare function linuxTerminalSession(ctx: Pick<Ctx, 'io' | 'json'>, values: Values, platform?: NodeJS.Platform): boolean;
5
+ export declare const shellWord: (s: string) => string;
6
+ export declare function linuxTerminalCommand(argv: string[], env: Record<string, string | undefined>): {
7
+ command: string;
8
+ args: string[];
9
+ } | null;
10
+ /** Invoked before cloud work: the new terminal owns the attach, credentials and any prompts. */
11
+ export declare function openLinuxTerminal(ctx: Ctx, ref: string, app?: string): Promise<number>;
@@ -0,0 +1,42 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ /** Linux terminals use the existing native Codex harness; explicit app routes keep Cloud. */
5
+ export function linuxTerminalSession(ctx, values, platform = process.platform) {
6
+ return platform === 'linux' && (values.in === 'terminal' || (values.in === undefined && !!ctx.io.stdin.isTTY && !ctx.json));
7
+ }
8
+ export const shellWord = (s) => `'${s.replaceAll("'", "'\\''")}'`;
9
+ export function linuxTerminalCommand(argv, env) {
10
+ const executable = (name) => name.includes('/') ? [name] : (env.PATH ?? '').split(':').filter(Boolean).map(p => join(p, name));
11
+ const exists = (name) => executable(name).some(p => { try {
12
+ accessSync(p, constants.X_OK);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ } });
18
+ // The shell keeps the window open on an attach error so the user can read and retry it.
19
+ const attach = ['sh', '-c', `${argv.map(shellWord).join(' ')}; status=$?; if [ "$status" -ne 0 ]; then printf '\nPress Enter to close.'; read answer; fi; exit "$status"`];
20
+ for (const command of [...new Set([env.TERMINAL?.trim(), 'x-terminal-emulator', 'gnome-terminal'].filter((s) => !!s))]) {
21
+ if (exists(command))
22
+ return { command, args: [command === 'gnome-terminal' ? '--' : '-e', ...attach] };
23
+ }
24
+ return null;
25
+ }
26
+ /** Invoked before cloud work: the new terminal owns the attach, credentials and any prompts. */
27
+ export async function openLinuxTerminal(ctx, ref, app) {
28
+ const words = ['agents', 'open', ref, '--in', 'terminal', ...(app ? ['--app', app] : [])];
29
+ const argv = ctx.runtime ? [ctx.runtime.node, ctx.runtime.script, '--api-url', ctx.apiUrl, ...words] : ['shard', ...words];
30
+ const choice = linuxTerminalCommand(argv, ctx.io.env);
31
+ if (choice) {
32
+ const child = spawn(choice.command, choice.args, { env: ctx.io.env, cwd: process.cwd(), detached: true, stdio: 'ignore' });
33
+ try {
34
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
35
+ child.unref();
36
+ return 0;
37
+ }
38
+ catch { /* print a runnable command if the terminal could not start */ }
39
+ }
40
+ ctx.io.stdout.write(`${['shard', '--api-url', ctx.apiUrl, ...words].map(shellWord).join(' ')}\n`);
41
+ return 0;
42
+ }
package/dist/main.js CHANGED
@@ -13,12 +13,15 @@
13
13
  import { homedir, hostname } from 'node:os';
14
14
  import { statSync } from 'node:fs';
15
15
  import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
16
- import { CLI_VERSION, CENTER_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
16
+ import { CLI_VERSION, CENTER_COMMAND, ONBOARDING_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
17
17
  import { commandCenter } from "./command-center.js";
18
- import { launchConversationProject } from "./conversation-terminal.js";
18
+ import { launchProjectCommand } from "./conversation-terminal.js";
19
19
  import { runAcp } from "./acp.js";
20
20
  import { registerInstalledT3 } from "./t3-provider.js";
21
+ import { refreshForwarderAgent } from "./cli-proxy.js";
21
22
  import { runAcpLogin } from "./acp-login.js";
23
+ import { terminalOnboarding } from "./terminal-onboarding.js";
24
+ import { startLinuxHelper } from "./linux-helper.js";
22
25
  import { runDeviceHelper } from "./device-helper.js";
23
26
  import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
24
27
  import { CredentialStore, configDir } from "./credentials.js";
@@ -31,6 +34,7 @@ import { HANDLERS } from "./handlers.js";
31
34
  import { makeFetch } from "./http.js";
32
35
  import { Input } from "./input.js";
33
36
  import { TimingRecorder, notices, timingJson, timingText } from "./timing.js";
37
+ import { waitStatusListener } from "./wait-status.js";
34
38
  import { CLI_DISTRIBUTION, NOTICE_WAIT_MS, UpdateCheck, noticeOf, updateCheckDisabled, updateView } from "./update-check.js";
35
39
  const KEY_SHAPE = /^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/;
36
40
  // Printable (no C0 controls or DEL), as the API's AgentLabel pattern.
@@ -126,7 +130,7 @@ function hintFor(info, cred, file, command = '') {
126
130
  if (reason === 'edit_not_text')
127
131
  return 'the file is not UTF-8 text; use files write for binary data';
128
132
  if (reason === 'host_capacity')
129
- return 'the workspace cannot be woken right now and the call did not run; retry in a few seconds';
133
+ return "the workspace's host is making room to wake it and the call did not run (it was already retried); run the command again";
130
134
  if (reason === 'host_feature_unavailable')
131
135
  return hostFeatureHint(info.details?.feature);
132
136
  // Inbound ports (0.9.0): the next step names the command.
@@ -534,6 +538,7 @@ export async function run(argv, io, opts = {}) {
534
538
  agentLabel,
535
539
  wake,
536
540
  wakeTimeoutMs,
541
+ wakeTimeoutExplicit: parsed.global.wakeTimeout !== undefined || Boolean(io.env.SHARDFLUX_WAKE_TIMEOUT_MS?.trim()),
537
542
  signal,
538
543
  timing: showTiming,
539
544
  traces,
@@ -569,13 +574,18 @@ export async function run(argv, io, opts = {}) {
569
574
  if (cloud)
570
575
  return cloud;
571
576
  const key = apiKey();
577
+ // Timings, and (0.16.0+) where a wait stands: a queued start's position, a call waiting for room (stderr).
578
+ const waitStatus = waitStatusListener(io.stderr, parsed.global.json);
572
579
  cloud = new Shardflux({
573
580
  apiKey: key.token,
574
581
  baseUrl: apiUrl,
575
582
  userAgent,
576
583
  fetch: watchedFetch,
577
584
  sleep,
578
- onProgress: traces.listener,
585
+ onProgress: (e) => {
586
+ traces.listener(e);
587
+ waitStatus(e);
588
+ },
579
589
  versionCheck: false,
580
590
  });
581
591
  return cloud;
@@ -653,7 +663,7 @@ export async function run(argv, io, opts = {}) {
653
663
  refreshMenubarInBackground({ dir: store.dir, home: io.env.HOME || homedir(), runtime: opts.runtime, env: { ...process.env, ...io.env } });
654
664
  }
655
665
  // The update check runs next to the command (not for `version`, which reports it itself).
656
- if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh') {
666
+ if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh' && command !== 'cli-proxy forward') {
657
667
  try {
658
668
  updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent, registry: npmRegistry(io.env) });
659
669
  updates.start();
@@ -666,11 +676,28 @@ export async function run(argv, io, opts = {}) {
666
676
  catch (err) {
667
677
  return report(err);
668
678
  }
669
- const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : command === 'menubar refresh' ? runMenubarRefresh : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
679
+ let stopHelper;
680
+ const startHelper = () => {
681
+ if (stopHelper)
682
+ return;
683
+ try {
684
+ ctx.apiKey();
685
+ }
686
+ catch {
687
+ return;
688
+ }
689
+ stopHelper = startLinuxHelper(ctx);
690
+ };
691
+ const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? (ctx) => runDeviceHelper(ctx, { service: parsed.values.service === true }) : command === 'menubar refresh' ? runMenubarRefresh : parsed.command === PROJECT_COMMAND ? launchProjectCommand : parsed.command === ONBOARDING_COMMAND ? (ctx, values) => terminalOnboarding(ctx, values, { startHelper }) : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
670
692
  if (!handler)
671
693
  return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
672
- if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh')
694
+ // Long-running helpers other programs start (an editor, launchd, the menu bar) skip the per-command housekeeping.
695
+ if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh' && command !== 'cli-proxy forward') {
696
+ // An updated shard restarts this computer's CLIProxyAPI forwarder on itself (a no-op unless the version changed).
697
+ await refreshForwarderAgent(io.env, ctx.store.dir).catch(() => false);
673
698
  await housekeeping(ctx);
699
+ startHelper();
700
+ }
674
701
  const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
675
702
  // On Ctrl-C give the command a moment to clean up (exec sends a cancel), then stop regardless.
676
703
  const interrupted = new Promise((resolve) => {
@@ -705,6 +732,9 @@ export async function run(argv, io, opts = {}) {
705
732
  if (notice)
706
733
  io.stderr.write(notice);
707
734
  }
735
+ if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh' && command !== 'cli-proxy forward')
736
+ startHelper();
737
+ await stopHelper?.();
708
738
  return code;
709
739
  }
710
740
  /**
@@ -731,7 +761,7 @@ export async function main(opts = {}) {
731
761
  const runtime = { node: process.execPath, script: process.argv[1] ?? '' };
732
762
  // The menu bar's update button runs this shard; it finds it here (macOS only, written when it changes).
733
763
  if (process.platform === 'darwin' && runtime.script)
734
- recordInstall(configDir(process.env), runtime, opts.distribution ?? CLI_DISTRIBUTION);
764
+ recordInstall(configDir(process.env), runtime, opts.distribution ?? CLI_DISTRIBUTION, process.env);
735
765
  const code = await run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr, stdin: process.stdin, env: process.env }, { signal: controller.signal, runtime, ...(opts.distribution ? { distribution: opts.distribution } : {}) });
736
766
  await Promise.all([flush(process.stdout), flush(process.stderr)]);
737
767
  process.exit(code);
@@ -26,10 +26,12 @@ type CredentialRow = {
26
26
  checked_at: string;
27
27
  };
28
28
  };
29
+ /** `url`: a page the person opens (the T3 Connect sign-in), instead of a command. */
29
30
  type Action = {
30
31
  title: string;
31
32
  command?: string[];
32
33
  retry?: boolean;
34
+ url?: string;
33
35
  };
34
36
  export interface Connection {
35
37
  id: 'account' | AgentApp | 't3';
@@ -1,7 +1,11 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
2
  import { delimiter, join, resolve, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { ShardfluxApiError } from '@shardflux/sdk';
4
5
  import { resolveWorkspace } from "./commands.js";
6
+ import { AgentStateStore } from "./agent-state.js";
7
+ import { describeError } from "./errors.js";
8
+ import { t3PhoneLink } from "./t3-phone.js";
5
9
  import { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
6
10
  import { getConversation } from "./conversations.js";
7
11
  import { codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
@@ -79,15 +83,59 @@ export async function t3Connection(ctx, signedIn, appDirs = applicationDirs(ctx.
79
83
  return { ...base, mode: 'provider', state: 'action', fact: error instanceof Error ? error.message : 'T3 Code’s settings could not be updated.', action: { title: 'Check again' } };
80
84
  }
81
85
  }
82
- // Stable T3: an SSH environment per project; the newest one this API set up is the one to add.
86
+ // Stable T3: an SSH environment per project; the newest one this API set up is the one to add. They are recorded in
87
+ // agents.json (targets.json keeps T3 out of its manifest); a manifest entry from an older CLI still counts.
88
+ const recorded = new AgentStateStore(ctx.store.dir).t3Environments(ctx.apiUrl).at(-1);
83
89
  const manifest = await jsonFile(join(ctx.store.dir, 'targets.json'));
84
90
  const entries = Array.isArray(manifest?.entries) ? manifest.entries : [];
85
- const host = entries.filter(e => e.api_url === ctx.apiUrl && Array.isArray(e.targets) && e.targets.includes('t3') && typeof e.host === 'string').map(e => e.host).at(-1);
86
- if (host)
87
- return { ...base, mode: 'ssh', state: 'verified', fact: `In T3 Code: Add environment → SSH → ${host}` };
91
+ const legacy = entries.filter(e => e.api_url === ctx.apiUrl && Array.isArray(e.targets) && e.targets.includes('t3') && typeof e.host === 'string').at(-1);
92
+ const host = recorded?.host ?? legacy?.host;
93
+ const workspaceKey = recorded?.workspace ?? (typeof legacy?.workspace === 'string' ? legacy.workspace : undefined);
94
+ if (host) {
95
+ const desktop = `In T3 Code: Add environment → SSH → ${host}`;
96
+ const phone = await t3Phone(ctx, workspaceKey);
97
+ if (!phone)
98
+ return { ...base, mode: 'ssh', state: 'verified', fact: desktop };
99
+ return { ...base, mode: 'ssh', ...phone, fact: phone.state === 'verified' ? `${desktop}. On your phone: ${phone.fact}` : phone.fact };
100
+ }
88
101
  // An SSH environment is per project (shardflux-<project>-main): it is set up once a project is picked.
89
102
  return { ...base, mode: 'ssh', state: 'info', fact: 'Available after you pick a project.', next: shard(['agents', 'new', '--in', 't3', '--path', '<project>']) };
90
103
  }
104
+ /**
105
+ * The T3 app on the phone (T3 Connect, 0.17.0+): the workspace is linked with the person's one T3 Connect sign-in. The
106
+ * sign-in is a page to open once; afterwards every project appears in the T3 app by itself. Undefined when the API has
107
+ * no T3 Connect: the desktop route is the whole card then.
108
+ */
109
+ async function t3Phone(ctx, key) {
110
+ if (!key)
111
+ return undefined;
112
+ try {
113
+ const ws = await resolveWorkspace(ctx, key);
114
+ const cloud = ctx.cloud();
115
+ const known = (await cloud.t3.status()).links.find(l => l.workspace_id === ws.id);
116
+ // A workspace connected to T3 before T3 Connect, or one whose link failed, is linked now: it is the person's choice.
117
+ const { link, signIn, needsLogin } = !known || known.status === 'error' || known.status === 'needs_sign_in'
118
+ ? await t3PhoneLink(ctx, ws.id) : { link: known, signIn: undefined, needsLogin: false };
119
+ if (link.status === 'linked')
120
+ return { state: 'verified', fact: `${link.label} in the T3 app.` };
121
+ if (signIn) {
122
+ return { state: 'action', fact: `To use it from the T3 app on your phone, confirm ${signIn.user_code} once.`,
123
+ action: { title: 'Connect phone', url: signIn.verification_uri } };
124
+ }
125
+ if (needsLogin)
126
+ return { state: 'info', fact: 'Sign in to Shardflux on this Mac to use it from the T3 app.' };
127
+ return { state: 'pending', fact: `Adding ${link.label} to the T3 app on your phone…` };
128
+ }
129
+ catch (error) {
130
+ // No T3 Connect on this API, or nothing to ask it: the desktop route stands on its own.
131
+ if (!(error instanceof ShardfluxApiError) || describeError(error).code === 'not_found')
132
+ return undefined;
133
+ // Another member's T3 Connect sign-in links this project: it is in their T3 app, not something to fix here.
134
+ if (error.details?.reason === 't3_linked_by_other_member')
135
+ return { state: 'info', fact: 'In the T3 app of the member who linked it.' };
136
+ return { state: 'unknown', fact: 'The T3 app link could not be checked just now.', action: { title: 'Retry', retry: true } };
137
+ }
138
+ }
91
139
  const shard = (words) => ['shard', ...words];
92
140
  /** Claude: the account login is ready and Claude itself accepts it right now. */
93
141
  export function claudeConnection(row) {
@@ -105,7 +153,7 @@ export function codexConnection(local, synced, connector, health) {
105
153
  const enable = { title: 'Connect Codex', command: shard(['agents', 'codex', 'enable']) };
106
154
  if (!synced) {
107
155
  return local ? { state: 'action', fact: 'Available on web and phone once connected.', action: enable }
108
- : { state: 'action', fact: 'Sign in to Codex on this Mac first.', action: { title: 'Sign in to Codex', command: ['codex', 'login'] } };
156
+ : { state: 'action', fact: 'Sign in to Codex on this computer first.', action: { title: 'Sign in to Codex', command: ['codex', 'login'] } };
109
157
  }
110
158
  const retry = { title: 'Retry', retry: true, command: shard(['setup', '--inspect', '--apps', 'codex', '--connect', '--retry-codex']) };
111
159
  switch (health) {
@@ -1,48 +1,8 @@
1
- export declare const NPM_REGISTRY = "https://registry.npmjs.org";
2
- /** Each registry request's timeout (ms). */
3
- export declare const REGISTRY_TIMEOUT_MS = 3000;
1
+ import type { RegistryOptions } from '@shardflux/sdk';
2
+ export { NPM_REGISTRY, npmLatest, withRegistryLatest } from '@shardflux/sdk';
4
3
  /** The npm packages whose versions `shard update` compares. */
5
4
  export declare const NPM_CLIENTS: readonly ["shardflux", "@shardflux/cli", "@shardflux/sdk", "@shardflux/mcp", "@shardflux/menubar-darwin"];
6
- export interface RegistryAnswer {
7
- latest: string | null;
8
- /** The registry's validator for the next request (npm's dist-tags endpoint sends none today). */
9
- etag: string | null;
10
- /** 304: what the caller cached under `etag` is still current. */
11
- notModified: boolean;
12
- }
13
- export interface RegistryOptions {
14
- fetch: typeof fetch;
15
- /** Default https://registry.npmjs.org (SHARDFLUX_NPM_REGISTRY overrides it for the CLI). */
16
- registry?: string;
17
- etag?: string | null;
18
- timeoutMs?: number;
19
- signal?: AbortSignal;
20
- userAgent?: string;
21
- }
22
5
  /** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
23
6
  export declare function npmRegistry(env: Record<string, string | undefined>): string;
24
- /** `latest` of an npm package from `<registry>/-/package/<name>/dist-tags`, or null when the registry did not answer. */
25
- export declare function npmLatest(pkg: string, opts: RegistryOptions): Promise<RegistryAnswer | null>;
26
7
  /** `latest` of every package in `packages`, asked in parallel; a package the registry did not answer is absent. */
27
8
  export declare function npmLatestAll(packages: readonly string[], opts: RegistryOptions): Promise<Record<string, string>>;
28
- export interface CatalogEntry {
29
- package: string;
30
- ecosystem: string;
31
- latest: string | null;
32
- minimum_supported: string | null;
33
- upgrade_command: string | null;
34
- release_notes_url: string | null;
35
- hold?: string | null;
36
- [key: string]: unknown;
37
- }
38
- /**
39
- * A GET /v1/client-versions body whose npm entries carry the registry's `latest` (`registry`: package -> version; an
40
- * absent package keeps the catalog's). Works with every catalog shape the API has served: no `hold` field, `latest`
41
- * null, a package missing from the list, or no catalog at all (`catalog` null: the API did not answer).
42
- *
43
- * `hold`: when the registry's latest is that version, it is not recommended; the catalog's own `latest` is, when it is
44
- * older, else `minimum_supported` (so "no longer supported" still works and nobody is told to update).
45
- */
46
- export declare function withRegistryLatest(catalog: unknown, registry: Record<string, string | null | undefined>): {
47
- clients: CatalogEntry[];
48
- };
package/dist/registry.js CHANGED
@@ -1,102 +1,18 @@
1
1
  /**
2
- * The latest version of each Shardflux client comes from its registry (0.15.2+), so a release is known the moment it
3
- * is published: npm's `latest` dist-tag, which is what `npm i -g shardflux` and `shard update` install. (`stable` is an
4
- * opt-in tag for installs behind a release-age gate, docs/progress/packaging.md; it never drives the update notice.)
5
- *
6
- * The API catalog (GET /v1/client-versions) still says which versions it serves (`minimum_supported`) and may name a
7
- * version to stop recommending (`hold`, an emergency brake). Its `latest` is only the fallback when the registry does
8
- * not answer. Every request here is bounded and never throws: offline means "no answer".
2
+ * The latest version of each Shardflux client comes from npm (0.15.2+; the SDK's registry helpers): its `latest`
3
+ * dist-tag is what `npm i -g shardflux` and `shard update` install. The API catalog adds `minimum_supported` and
4
+ * `hold` (see withRegistryLatest).
9
5
  */
10
- import { compareVersions } from '@shardflux/sdk';
11
- export const NPM_REGISTRY = 'https://registry.npmjs.org';
12
- /** Each registry request's timeout (ms). */
13
- export const REGISTRY_TIMEOUT_MS = 3_000;
6
+ import { npmLatest, registryFromEnv } from '@shardflux/sdk';
7
+ export { NPM_REGISTRY, npmLatest, withRegistryLatest } from '@shardflux/sdk';
14
8
  /** The npm packages whose versions `shard update` compares. */
15
9
  export const NPM_CLIENTS = ['shardflux', '@shardflux/cli', '@shardflux/sdk', '@shardflux/mcp', '@shardflux/menubar-darwin'];
16
- const str = (v) => (typeof v === 'string' && v.length > 0 ? v : null);
17
- const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
18
10
  /** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
19
11
  export function npmRegistry(env) {
20
- const own = env.SHARDFLUX_NPM_REGISTRY?.trim();
21
- return own ? own.replace(/\/+$/, '') : NPM_REGISTRY;
22
- }
23
- /** `latest` of an npm package from `<registry>/-/package/<name>/dist-tags`, or null when the registry did not answer. */
24
- export async function npmLatest(pkg, opts) {
25
- try {
26
- const timeout = AbortSignal.timeout(opts.timeoutMs ?? REGISTRY_TIMEOUT_MS);
27
- const headers = { accept: 'application/json' };
28
- if (opts.userAgent)
29
- headers['user-agent'] = opts.userAgent;
30
- if (opts.etag)
31
- headers['if-none-match'] = opts.etag;
32
- const res = await opts.fetch(`${(opts.registry ?? NPM_REGISTRY).replace(/\/+$/, '')}/-/package/${encodeURIComponent(pkg)}/dist-tags`, {
33
- method: 'GET',
34
- headers,
35
- signal: opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout,
36
- });
37
- const etag = res.headers.get('etag');
38
- if (res.status === 304) {
39
- await res.body?.cancel().catch(() => undefined);
40
- return { latest: null, etag: etag ?? opts.etag ?? null, notModified: true };
41
- }
42
- if (!res.ok) {
43
- await res.body?.cancel().catch(() => undefined);
44
- return null;
45
- }
46
- const body = JSON.parse(await res.text());
47
- const latest = isObj(body) ? str(body.latest) : null;
48
- // A tag that is not a version (a broken registry mirror) is no answer.
49
- if (latest === null || compareVersions(latest, latest) === null)
50
- return null;
51
- return { latest, etag, notModified: false };
52
- }
53
- catch {
54
- return null;
55
- }
12
+ return registryFromEnv(env, 'npm');
56
13
  }
57
14
  /** `latest` of every package in `packages`, asked in parallel; a package the registry did not answer is absent. */
58
15
  export async function npmLatestAll(packages, opts) {
59
16
  const answers = await Promise.all(packages.map(async (pkg) => [pkg, await npmLatest(pkg, { ...opts, etag: null })]));
60
17
  return Object.fromEntries(answers.flatMap(([pkg, a]) => (a?.latest ? [[pkg, a.latest]] : [])));
61
18
  }
62
- const DEFAULT_UPGRADE = {
63
- shardflux: 'npm i -g shardflux',
64
- '@shardflux/cli': 'npm i -g shardflux',
65
- '@shardflux/menubar-darwin': 'shard update',
66
- };
67
- /**
68
- * A GET /v1/client-versions body whose npm entries carry the registry's `latest` (`registry`: package -> version; an
69
- * absent package keeps the catalog's). Works with every catalog shape the API has served: no `hold` field, `latest`
70
- * null, a package missing from the list, or no catalog at all (`catalog` null: the API did not answer).
71
- *
72
- * `hold`: when the registry's latest is that version, it is not recommended; the catalog's own `latest` is, when it is
73
- * older, else `minimum_supported` (so "no longer supported" still works and nobody is told to update).
74
- */
75
- export function withRegistryLatest(catalog, registry) {
76
- const listed = isObj(catalog) && Array.isArray(catalog.clients) ? catalog.clients.filter(isObj) : [];
77
- const entries = listed.map((c) => ({
78
- ...c,
79
- package: String(c.package),
80
- ecosystem: String(c.ecosystem),
81
- latest: str(c.latest),
82
- minimum_supported: str(c.minimum_supported),
83
- upgrade_command: str(c.upgrade_command),
84
- release_notes_url: str(c.release_notes_url),
85
- hold: str(c.hold),
86
- }));
87
- for (const pkg of Object.keys(registry)) {
88
- if (!entries.some((e) => e.ecosystem === 'npm' && e.package === pkg)) {
89
- entries.push({ package: pkg, ecosystem: 'npm', latest: null, minimum_supported: null, upgrade_command: DEFAULT_UPGRADE[pkg] ?? null, release_notes_url: `https://www.npmjs.com/package/${pkg}?activeTab=versions`, hold: null });
90
- }
91
- }
92
- return {
93
- clients: entries.map((e) => {
94
- const fromRegistry = e.ecosystem === 'npm' ? str(registry[e.package]) : null;
95
- let latest = fromRegistry ?? e.latest;
96
- if (latest !== null && e.hold && compareVersions(latest, e.hold) === 0) {
97
- latest = e.latest !== null && compareVersions(e.latest, e.hold) === -1 ? e.latest : e.minimum_supported;
98
- }
99
- return { ...e, latest };
100
- }),
101
- };
102
- }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The CLI side of T3 Code mode (0.17.0+): `shard agents new --in t3 [--prompt…] [--on <ref>]`, `shard ./` and
3
+ * `--in auto` when T3 is where new conversations open, and `shard agents open t3 <conversation|workspace>`.
4
+ * --json results (contract shared with the menu bar):
5
+ * new: { target: "t3", mode, project, t3_thread_id, conversation_id, workspace: { workspace_key, id } | null,
6
+ * opened: true, focused: false, model, effort, ... }
7
+ * open: { opened: true, t3_thread_id, focused: false, ... }
8
+ * `focused` is false: T3 has no way to show a given thread from outside, so T3 is brought to the front instead.
9
+ */
10
+ import type { Workspace } from '@shardflux/sdk';
11
+ import type { Ctx } from './commands.js';
12
+ import type { Values } from './args.js';
13
+ import type { AgentApp, T3Install, T3ThreadRequest, T3ThreadResult } from './t3-mode.js';
14
+ /**
15
+ * A `t3_*` refusal raised once the workspace, conversation or thread exists names them (details.workspace_key,
16
+ * conversation_id, t3_thread_id), so a client opens what exists instead of starting it again.
17
+ */
18
+ export declare function withT3Context(error: unknown, ref: {
19
+ workspace_key?: string | null;
20
+ conversation_id?: string | null;
21
+ t3_thread_id?: string | null;
22
+ }): unknown;
23
+ /** The thread to start, when the command carries a first message (`--prompt`, `--prompt-file`, `--image`, `--model`, `--effort`). */
24
+ export declare function t3ThreadRequest(ctx: Ctx, values: Values, app: AgentApp): Promise<T3ThreadRequest | undefined>;
25
+ /**
26
+ * `agents new --in t3`: with a first message in ACP mode, the thread starts on T3's Shardflux provider (its own VM);
27
+ * otherwise the project VM is opened as T3's SSH environment (with its phone link), and a first message becomes a
28
+ * thread on the T3 server in that VM. `--on <ref>` uses that existing workspace instead of the project VM.
29
+ */
30
+ export declare function launchT3(ctx: Ctx, values: Values, path: string, explicitApp: string | undefined): Promise<number>;
31
+ /** The T3 fields of an SSH-mode result (the thread, when one was started in the VM). */
32
+ export declare function t3Fields(ws: Workspace, project: string, install: T3Install | undefined, started: T3ThreadResult | undefined, conversation?: string | null): Record<string, unknown>;
33
+ /**
34
+ * `agents open t3 <conversation>` for a conversation that runs on T3's Shardflux provider (ACP mode): T3 is brought to
35
+ * the front with that thread's id. A conversation of another route opens its VM as T3's SSH environment.
36
+ */
37
+ export declare function openT3Conversation(ctx: Ctx, conversationId: string, values?: Values): Promise<number>;