@shardflux/cli 0.20.0 → 0.22.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 (110) hide show
  1. package/CHANGELOG.md +214 -1
  2. package/README.md +215 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-return.d.ts +46 -2
  16. package/dist/agent-config-return.js +110 -4
  17. package/dist/agent-credentials.js +7 -3
  18. package/dist/agent-repository.d.ts +16 -3
  19. package/dist/agent-repository.js +149 -74
  20. package/dist/agent-setup.d.ts +14 -3
  21. package/dist/agent-setup.js +78 -36
  22. package/dist/agent-target-commands.js +24 -13
  23. package/dist/args.d.ts +1 -1
  24. package/dist/args.js +94 -54
  25. package/dist/bin.js +5 -1
  26. package/dist/browser.d.ts +13 -2
  27. package/dist/browser.js +30 -14
  28. package/dist/claude-cloud-login.d.ts +24 -2
  29. package/dist/claude-cloud-login.js +151 -11
  30. package/dist/cli-proxy-forward.d.ts +2 -0
  31. package/dist/cli-proxy-forward.js +9 -2
  32. package/dist/cli-proxy-hub.d.ts +69 -25
  33. package/dist/cli-proxy-hub.js +231 -54
  34. package/dist/cli-proxy.d.ts +70 -1
  35. package/dist/cli-proxy.js +298 -32
  36. package/dist/client-settings.d.ts +39 -0
  37. package/dist/client-settings.js +91 -0
  38. package/dist/codex-cloud.d.ts +5 -0
  39. package/dist/codex-cloud.js +88 -17
  40. package/dist/command-center-actions.js +5 -1
  41. package/dist/commands.js +171 -22
  42. package/dist/conversation-commands.d.ts +5 -0
  43. package/dist/conversation-commands.js +8 -5
  44. package/dist/conversation-terminal.d.ts +10 -2
  45. package/dist/conversation-terminal.js +77 -13
  46. package/dist/conversations.d.ts +73 -1
  47. package/dist/conversations.js +257 -23
  48. package/dist/device-bridge.d.ts +1 -1
  49. package/dist/device-bridge.js +6 -4
  50. package/dist/device-helper.js +43 -22
  51. package/dist/errors.d.ts +27 -1
  52. package/dist/errors.js +45 -5
  53. package/dist/filesystem-commands.d.ts +16 -9
  54. package/dist/filesystem-commands.js +108 -54
  55. package/dist/format.d.ts +2 -1
  56. package/dist/format.js +10 -1
  57. package/dist/handlers.js +3 -1
  58. package/dist/initial-prompt.d.ts +8 -0
  59. package/dist/initial-prompt.js +43 -1
  60. package/dist/large-media.d.ts +14 -0
  61. package/dist/large-media.js +100 -0
  62. package/dist/linux-terminal.d.ts +5 -3
  63. package/dist/linux-terminal.js +6 -20
  64. package/dist/local-sessions.d.ts +26 -2
  65. package/dist/local-sessions.js +113 -24
  66. package/dist/machine-setup.d.ts +11 -1
  67. package/dist/machine-setup.js +69 -22
  68. package/dist/machine-status.d.ts +67 -0
  69. package/dist/machine-status.js +140 -0
  70. package/dist/main.js +11 -13
  71. package/dist/move-worktrees-restore.py +144 -0
  72. package/dist/move-worktrees.d.ts +63 -0
  73. package/dist/move-worktrees.js +201 -0
  74. package/dist/move.d.ts +10 -1
  75. package/dist/move.js +116 -16
  76. package/dist/onboarding.d.ts +11 -7
  77. package/dist/onboarding.js +59 -27
  78. package/dist/prepared-repository.d.ts +202 -0
  79. package/dist/prepared-repository.js +860 -0
  80. package/dist/prepared-repository.py +1306 -0
  81. package/dist/project-preparation.d.ts +48 -0
  82. package/dist/project-preparation.js +304 -0
  83. package/dist/project-sync-manifest.d.ts +4 -0
  84. package/dist/project-sync-manifest.js +5 -1
  85. package/dist/project-sync-transfer.js +1 -1
  86. package/dist/project-sync.d.ts +19 -4
  87. package/dist/project-sync.js +31 -8
  88. package/dist/project-template-setup.d.ts +33 -1
  89. package/dist/project-template-setup.js +59 -2
  90. package/dist/secret-ask.d.ts +72 -0
  91. package/dist/secret-ask.js +351 -0
  92. package/dist/seed-phases.d.ts +8 -1
  93. package/dist/seed-phases.js +17 -2
  94. package/dist/setup-cache.d.ts +7 -0
  95. package/dist/setup-cache.js +32 -0
  96. package/dist/t3-launch.js +1 -4
  97. package/dist/t3-mode.d.ts +8 -2
  98. package/dist/t3-mode.js +19 -4
  99. package/dist/t3-phone.d.ts +4 -2
  100. package/dist/t3-phone.js +12 -4
  101. package/dist/terminal-apps.d.ts +157 -0
  102. package/dist/terminal-apps.js +561 -0
  103. package/dist/terminal-onboarding.js +6 -1
  104. package/dist/this-computer.d.ts +11 -0
  105. package/dist/this-computer.js +36 -0
  106. package/dist/thread-on-computer.js +4 -1
  107. package/dist/update.js +11 -3
  108. package/dist/worktree-twin.d.ts +10 -2
  109. package/dist/worktree-twin.js +27 -9
  110. package/package.json +5 -5
package/dist/cli-proxy.js CHANGED
@@ -1,15 +1,18 @@
1
1
  /**
2
- * `shard cli-proxy` (status), `shard cli-proxy import` and the hidden `shard cli-proxy forward`: one hosted
2
+ * `shard cli-proxy` (status), `shard cli-proxy import`, `shard cli-proxy use` and the hidden `shard cli-proxy forward`: one hosted
3
3
  * CLIProxyAPI per account (cli-proxy-hub.ts), reached at http://127.0.0.1:8317 on this computer (a launchd agent runs
4
4
  * the forwarder, cli-proxy-forward.ts) and in every VM (the hub info in the machine template; sf-agent forwards there).
5
5
  *
6
6
  * Import moves the user's CLIProxyAPI from this computer: the hub is made from their config, the local copy stops
7
7
  * (only one copy may refresh a login), their auth files go to the hub (never printed), the forwarder takes 127.0.0.1:8317
8
8
  * and the machine template carries the hub info. Onboarding calls `detectLocalCliProxy()` and `importCliProxy(ctx)`.
9
+ *
10
+ * One login per provider serves requests (the user's active one, `shard cli-proxy use` switches it); the provider's
11
+ * other logins stay in the hub, disabled, so CLIProxyAPI never moves a request to another account by itself.
9
12
  */
10
13
  import { execFile } from 'node:child_process';
11
14
  import { createHash } from 'node:crypto';
12
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
16
  import { connect } from 'node:net';
14
17
  import { homedir, userInfo } from 'node:os';
15
18
  import { basename, dirname, join } from 'node:path';
@@ -18,9 +21,9 @@ import { parse as parseYaml } from 'yaml';
18
21
  import { AgentStateStore } from "./agent-state.js";
19
22
  import { syncAgentConfig } from "./agent-config-sync.js";
20
23
  import { CliProxyForwarder, IDENTITY_PATH, hubInfoSource } from "./cli-proxy-forward.js";
21
- import { ADD_AUTH_SCRIPT, CLI_PROXY_PORT, CLI_PROXY_VM_PORT, HUB_AUTH_DIR, HUB_KEY, HubError, SET_ASIDE_SCRIPT, STOP_SCRIPT, ensureCliProxyHub, hubCallback, readHubInfo, sdkHubCloud, vmHubInfoText, writeHubInfo } from "./cli-proxy-hub.js";
24
+ import { ADD_AUTH_SCRIPT, CLI_PROXY_PORT, CLI_PROXY_VM_PORT, HUB_ACTIVE_FILE, HUB_AUTH_DIR, HUB_KEY, HubError, SET_ASIDE_SCRIPT, STOP_SCRIPT, enforceActiveLogins, enforceHubLoginsIfRunning, ensureCliProxyHub, hubCallback, listHubLogins, parseActive, readHubInfo, sdkHubCloud, vmHubInfoText, withDisabled, writeHubInfo } from "./cli-proxy-hub.js";
22
25
  import { CLI_VERSION } from "./args.js";
23
- import { UsageError } from "./errors.js";
26
+ import { UsageError, describeError } from "./errors.js";
24
27
  import { json } from "./format.js";
25
28
  import { progress } from "./progress.js";
26
29
  const execute = promisify(execFile);
@@ -625,6 +628,20 @@ export function sdkMachine(ctx) {
625
628
  },
626
629
  };
627
630
  }
631
+ /** The auth file a management API entry names, relative to the hub's auth directory. */
632
+ function entryFile(f) {
633
+ if (typeof f.path === 'string' && f.path.startsWith(`${HUB_AUTH_DIR}/`))
634
+ return f.path.slice(HUB_AUTH_DIR.length + 1);
635
+ return [f.id, f.name].find((v) => typeof v === 'string' && v !== '') ?? null;
636
+ }
637
+ /**
638
+ * Marks each provider's active login: an enabled login whose file is one of `activeFiles` (the hub's active.json).
639
+ * Without them (unreadable), a provider's only enabled login is its active one.
640
+ */
641
+ export function markActive(accounts, activeFiles) {
642
+ const enabled = (p) => accounts.filter((a) => a.provider === p && !a.disabled).length;
643
+ return accounts.map((a) => ({ ...a, active: !a.disabled && (activeFiles ? a.file !== null && activeFiles.has(a.file) : enabled(a.provider) === 1) }));
644
+ }
628
645
  /** The hub's logins from its management API (wakes the hub). */
629
646
  export async function hubAccounts(info, timeoutMs = 45_000) {
630
647
  const res = await fetch(new URL('v0/management/auth-files', info.callback_url), {
@@ -640,6 +657,8 @@ export async function hubAccounts(info, timeoutMs = 45_000) {
640
657
  email: typeof f.email === 'string' && f.email ? f.email : null,
641
658
  status: typeof f.status === 'string' && f.status ? f.status : null,
642
659
  disabled: f.disabled === true,
660
+ file: entryFile(f),
661
+ active: false,
643
662
  }));
644
663
  }
645
664
  /** Whether the hub answers /healthz through its callback URL, and the edge's reason when it does not. */
@@ -653,12 +672,71 @@ export async function hubHealth(info, timeoutMs = 45_000) {
653
672
  return { ok: false, reason: err.name === 'TimeoutError' ? 'timeout' : 'unreachable' };
654
673
  }
655
674
  }
656
- const accountLine = (a) => ` ${a.provider.padEnd(8)} ${a.email ?? '(no email)'}${a.disabled ? ' disabled' : a.status && a.status !== 'active' ? ` ${a.status}` : ''}`;
675
+ const accountLine = (a) => ` ${a.provider.padEnd(8)} ${a.email ?? '(no email)'}${a.active ? ' active' : ''}${a.disabled ? ' disabled' : a.status && a.status !== 'active' ? ` ${a.status}` : ''}`;
676
+ /** The logins a `--use` or `shard cli-proxy use` value names: by email (any case), path in the auth folder, or file name. */
677
+ export function matchLogins(spec, logins) {
678
+ const want = spec.trim().toLowerCase();
679
+ return logins.filter((l) => l.provider !== null && want !== '' && (l.email?.toLowerCase() === want || l.file.toLowerCase() === want || basename(l.file).toLowerCase() === want));
680
+ }
681
+ const loginName = (l) => l.email ?? l.file;
682
+ const refs = (logins, active) => logins.filter((l) => l.provider !== null && l.active === active).map((l) => ({ provider: l.provider, email: l.email, file: l.file }));
683
+ /**
684
+ * Resolves `--use` (repeatable): each value names one login per provider, on this computer or already in the hub.
685
+ * Checked before anything changes, so a typo stops the import with the logins to pick from.
686
+ */
687
+ function resolveUse(specs, local, hub) {
688
+ const out = new Map();
689
+ const localLogins = (local?.logins ?? []).map((l) => {
690
+ let text = '';
691
+ try {
692
+ text = readFileSync(join(local.authDir, l.file), 'utf8');
693
+ }
694
+ catch { /* unreadable: matched by name only */ }
695
+ return { ...l, identity: loginIdentity(text, l.file) };
696
+ });
697
+ const by = new Map();
698
+ for (const spec of specs) {
699
+ const mineHere = matchLogins(spec, localLogins), inHub = matchLogins(spec, hub);
700
+ if (!mineHere.length && !inHub.length) {
701
+ const all = [...localLogins.filter((l) => l.provider), ...hub.filter((l) => l.provider)].map((l) => `${l.provider} ${loginName(l)}`);
702
+ throw new UsageError(`No login matches --use ${spec}.`, all.length ? `pick one of: ${[...new Set(all)].join(', ')}` : 'name a login by its email');
703
+ }
704
+ for (const provider of new Set([...mineHere, ...inHub].map((l) => l.provider))) {
705
+ const here = mineHere.filter((l) => l.provider === provider), there = inHub.filter((l) => l.provider === provider);
706
+ const ids = new Set(here.map((l) => l.identity));
707
+ if (ids.size > 1 || (!here.length && there.length > 1)) {
708
+ throw new UsageError(`--use ${spec} matches more than one ${provider} login (${[...here, ...there].map((l) => l.file).join(', ')}).`, 'name one by its file name: --use <file>');
709
+ }
710
+ const choice = here[0] ? { local: here[0].file, identity: here[0].identity } : { hub: there[0].file };
711
+ const earlier = by.get(provider);
712
+ if (earlier !== undefined && earlier !== spec)
713
+ throw new UsageError(`--use ${earlier} and --use ${spec} both name a ${provider} login; one login per provider is active.`, 'keep one --use per provider');
714
+ by.set(provider, spec);
715
+ out.set(provider, choice);
716
+ }
717
+ }
718
+ return out;
719
+ }
720
+ /** When a local login was added: its birth time where the file system keeps one, else its last change. */
721
+ function addedAt(path) {
722
+ try {
723
+ const st = statSync(path);
724
+ return st.birthtimeMs > 0 ? st.birthtimeMs : st.mtimeMs;
725
+ }
726
+ catch {
727
+ return Number.MAX_SAFE_INTEGER;
728
+ }
729
+ }
730
+ const useSpecs = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : []).filter((x) => x.trim() !== '');
657
731
  /**
658
732
  * Moves CLIProxyAPI from this computer into the account (or connects this computer to the account's hub): ensure the
659
733
  * hub from the user's config, stop the local copy, upload the auth files the hub does not have yet (never overwriting
660
734
  * one: the hub's copy may hold a newer refresh token), start the forwarder on 127.0.0.1:8317 and put the hub info into
661
735
  * the machine template.
736
+ *
737
+ * One login per provider serves requests. `--use <email|file>` (repeatable, one per provider) picks it; otherwise a
738
+ * provider the hub already serves keeps its active login, and a provider new to the hub starts with this computer's
739
+ * oldest login of it. Every other login is uploaded with `"disabled": true` and kept in the hub.
662
740
  */
663
741
  export async function importCliProxy(ctx, opts = {}) {
664
742
  if (cliProxyTools.inVm()) {
@@ -676,6 +754,8 @@ export async function importCliProxy(ctx, opts = {}) {
676
754
  const ensured = await ensureCliProxyHub(hub, { dir: ctx.store.dir, apiUrl: ctx.apiUrl, ...(userConfig !== undefined ? { userConfig } : {}), progress: (m) => progress(ctx, m) });
677
755
  for (const value of [ensured.info.callback_url, ensured.info.management_key])
678
756
  ctx.addSecret(value);
757
+ // Which login of each provider is active: checked against this computer's and the hub's before anything is stopped.
758
+ const use = resolveUse(useSpecs(values.use), local, ensured.logins);
679
759
  // The forwarder takes 127.0.0.1:8317: refuse before stopping anything when another program holds it.
680
760
  if (local && local.port !== CLI_PROXY_PORT) {
681
761
  const answers = opts.stop?.answers ?? cliProxyTools.answers;
@@ -689,7 +769,8 @@ export async function importCliProxy(ctx, opts = {}) {
689
769
  const uploaded = [], kept = [];
690
770
  const added = [], already = [];
691
771
  let movedAside = [];
692
- if (local?.logins.length) {
772
+ let hubLogins = ensured.logins;
773
+ if (local?.logins.length || use.size) {
693
774
  const opened = await hub.open(false);
694
775
  if (!opened)
695
776
  throw new HubError('The hub was deleted during the import.', `run: ${AGAIN}`);
@@ -697,34 +778,13 @@ export async function importCliProxy(ctx, opts = {}) {
697
778
  // auth file's JSON, the hub's and this computer's), under the hub's logins lock, so two computers importing at
698
779
  // once never add one account twice and an account the hub holds keeps its newer tokens, whatever the file names.
699
780
  await opened.ws.withLock('logins', async () => {
700
- const inHub = new Set();
701
- const present = await opened.ws.authFiles();
702
- for (const rel of present) {
703
- const text = await opened.ws.read(`${HUB_AUTH_DIR}/${rel}`);
704
- inHub.add(text === null ? `file:${rel}` : loginIdentity(text, rel));
705
- }
706
- const names = new Set(present);
707
- for (const login of local.logins) {
708
- const text = readFileSync(join(local.authDir, login.file), 'utf8');
709
- const account = loginIdentity(text, login.file);
710
- const r = inHub.has(account) || names.has(login.file) ? null : await opened.ws.sh(ADD_AUTH_SCRIPT, [login.file], { stdin: text });
711
- if (r && r.code !== 0)
712
- throw new HubError(`Could not add ${login.file} to the hub: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.code}`}`, `run: ${AGAIN}`);
713
- if (!r || r.stdout.trim() === 'exists') {
714
- kept.push(login.file);
715
- already.push({ provider: login.provider, email: login.email });
716
- continue;
717
- }
718
- uploaded.push(login.file);
719
- added.push({ provider: login.provider, email: login.email });
720
- inHub.add(account);
721
- names.add(login.file);
722
- }
781
+ hubLogins = await uploadLogins(opened.ws, local, use, { uploaded, kept, added, already });
723
782
  });
724
783
  if (uploaded.length)
725
784
  progress(ctx, `Moved ${uploaded.length} login${uploaded.length === 1 ? '' : 's'} to the hub`);
726
785
  // The hub holds every login now: no local copy keeps tokens it could refresh.
727
- movedAside = moveAuthFilesAside(local.authDirs);
786
+ if (local?.logins.length)
787
+ movedAside = moveAuthFilesAside(local.authDirs);
728
788
  }
729
789
  if (local) {
730
790
  new AgentStateStore(ctx.store.dir).update((doc) => {
@@ -757,12 +817,89 @@ export async function importCliProxy(ctx, opts = {}) {
757
817
  machine = { error: err instanceof Error ? err.message : String(err) };
758
818
  }
759
819
  }
760
- const accounts = await hubAccounts(ensured.info).catch(() => null);
820
+ const activeFiles = new Set(hubLogins.filter((l) => l.active).map((l) => l.file));
821
+ const accounts = await hubAccounts(ensured.info).then((a) => markActive(a, activeFiles), () => null);
761
822
  return {
762
823
  hub: { workspace_id: ensured.info.workspace_id, created: ensured.created, process: ensured.process },
763
- logins: { uploaded, kept, added, already, moved_aside: movedAside.map((m) => m.to) }, config: ensured.config ? 'from_this_computer' : 'kept', local: stop.result, port: local?.port ?? CLI_PROXY_PORT, forwarder: { launchd }, machine, dropped: ensured.config?.dropped ?? [], accounts,
824
+ logins: { uploaded, kept, added, already, moved_aside: movedAside.map((m) => m.to), active: refs(hubLogins, true), disabled: refs(hubLogins, false) },
825
+ config: ensured.config ? 'from_this_computer' : 'kept', local: stop.result, port: local?.port ?? CLI_PROXY_PORT, forwarder: { launchd }, machine, dropped: ensured.config?.dropped ?? [], accounts,
764
826
  };
765
827
  }
828
+ /**
829
+ * Under the hub's logins lock: uploads this computer's logins the hub lacks (a login that is not its provider's active
830
+ * one arrives with `"disabled": true`), then keeps one login per provider enabled. Returns the hub's logins after.
831
+ */
832
+ async function uploadLogins(ws, local, use, out) {
833
+ const inHub = new Map();
834
+ const providersInHub = new Set();
835
+ const present = await ws.authFiles();
836
+ for (const rel of present) {
837
+ const text = await ws.read(`${HUB_AUTH_DIR}/${rel}`);
838
+ const id = text === null ? `file:${rel}` : loginIdentity(text, rel);
839
+ if (!inHub.has(id))
840
+ inHub.set(id, rel);
841
+ const type = (() => { try {
842
+ const t = JSON.parse(text ?? '').type;
843
+ return typeof t === 'string' && t.trim() ? t.trim().toLowerCase() : null;
844
+ }
845
+ catch {
846
+ return null;
847
+ } })();
848
+ if (type && !/^sfa-[^/]+\.json$/.test(rel))
849
+ providersInHub.add(type);
850
+ }
851
+ const names = new Set(present);
852
+ const logins = (local?.logins ?? []).map((login) => {
853
+ const path = join(local.authDir, login.file);
854
+ const text = readFileSync(path, 'utf8');
855
+ let disabled = false;
856
+ try {
857
+ disabled = JSON.parse(text).disabled === true;
858
+ }
859
+ catch { /* not JSON */ }
860
+ return { ...login, text, identity: loginIdentity(text, login.file), at: addedAt(path), disabled };
861
+ });
862
+ // The identity each provider's active login has after this import: --use, else (a provider new to the hub) this
863
+ // computer's oldest enabled login of it, else its oldest. A provider the hub serves keeps its active login.
864
+ const target = new Map();
865
+ for (const [provider, choice] of use)
866
+ if ('local' in choice)
867
+ target.set(provider, choice.identity);
868
+ for (const l of [...logins].sort((a, b) => Number(a.disabled) - Number(b.disabled) || a.at - b.at || (a.file < b.file ? -1 : 1))) {
869
+ const p = l.provider?.toLowerCase();
870
+ if (p && !use.has(p) && !providersInHub.has(p) && !target.has(p))
871
+ target.set(p, l.identity);
872
+ }
873
+ for (const login of logins) {
874
+ const p = login.provider?.toLowerCase() ?? null;
875
+ // Only a provider new to the hub gets its chosen login enabled on upload. Next to a login the hub already serves,
876
+ // every upload arrives disabled (two enabled logins would fail over between each other); enforceActiveLogins below
877
+ // then disables the old one before it enables the chosen one.
878
+ const content = p === null || (target.get(p) === login.identity && !providersInHub.has(p)) ? login.text : (withDisabled(login.text, true) ?? login.text);
879
+ const r = inHub.has(login.identity) || names.has(login.file) ? null : await ws.sh(ADD_AUTH_SCRIPT, [login.file], { stdin: content });
880
+ if (r && r.code !== 0)
881
+ throw new HubError(`Could not add ${login.file} to the hub: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.code}`}`, `run: ${AGAIN}`);
882
+ if (!r || r.stdout.trim() === 'exists') {
883
+ out.kept.push(login.file);
884
+ out.already.push({ provider: login.provider, email: login.email });
885
+ continue;
886
+ }
887
+ out.uploaded.push(login.file);
888
+ out.added.push({ provider: login.provider, email: login.email });
889
+ inHub.set(login.identity, login.file);
890
+ names.add(login.file);
891
+ }
892
+ const prefer = {};
893
+ for (const [provider, choice] of use) {
894
+ const file = 'hub' in choice ? choice.hub : inHub.get(choice.identity);
895
+ if (file !== undefined)
896
+ prefer[provider] = file;
897
+ }
898
+ for (const [provider, identity] of target)
899
+ if (prefer[provider] === undefined && inHub.has(identity))
900
+ prefer[provider] = inHub.get(identity);
901
+ return enforceActiveLogins(ws, prefer);
902
+ }
766
903
  /**
767
904
  * Brings the hub's logins back to this computer: the hub stops first (from then on only this computer refreshes
768
905
  * them), its auth files as last refreshed are written to the local auth directory (0600), the hub sets its copies
@@ -840,6 +977,73 @@ async function exportHandler(ctx, values) {
840
977
  ].join('\n') + '\n');
841
978
  return 0;
842
979
  }
980
+ /**
981
+ * `shard cli-proxy use <email|file>`: makes that login its provider's active one in the hub (an email with a Claude
982
+ * and a Codex login sets both); the provider's other logins stay in the hub, disabled. Under the hub's logins lock.
983
+ */
984
+ export async function useCliProxyLogin(ctx, spec, opts = {}) {
985
+ if (cliProxyTools.inVm())
986
+ throw new UsageError('shard cli-proxy use runs on a computer connected to your hosted CLIProxyAPI; this is a Shardflux VM.', 'run it on that computer: shard cli-proxy use <email>');
987
+ if (!spec.trim())
988
+ throw new UsageError('Name the login to make active.', 'run: shard cli-proxy use <email>');
989
+ const hub = opts.hub ?? sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel });
990
+ const opened = await hub.open(false);
991
+ if (!opened)
992
+ throw new UsageError('This account has no hosted CLIProxyAPI yet.', `move yours: ${AGAIN}`);
993
+ return opened.ws.withLock('logins', async () => {
994
+ const before = await listHubLogins(opened.ws);
995
+ const matches = matchLogins(spec, before);
996
+ if (!matches.length) {
997
+ const all = before.filter((l) => l.provider).map((l) => `${l.provider} ${loginName(l)}`);
998
+ throw new UsageError(`No login in your hosted CLIProxyAPI matches ${spec}.`, all.length ? `pick one of: ${all.join(', ')}` : `add yours: ${AGAIN}`);
999
+ }
1000
+ const prefer = {};
1001
+ for (const provider of new Set(matches.map((l) => l.provider))) {
1002
+ const mine = matches.filter((l) => l.provider === provider);
1003
+ if (mine.length > 1)
1004
+ throw new UsageError(`${spec} matches more than one ${provider} login (${mine.map((l) => l.file).join(', ')}).`, 'name one by its file name: shard cli-proxy use <file>');
1005
+ prefer[provider] = mine[0].file;
1006
+ }
1007
+ const after = await enforceActiveLogins(opened.ws, prefer);
1008
+ const ref = (l) => ({ provider: l.provider, email: l.email, file: l.file });
1009
+ const switched = Object.entries(prefer).map(([provider, file]) => {
1010
+ const was = before.find((l) => l.provider === provider && l.active);
1011
+ return { provider, from: was ? ref(was) : null, to: ref(after.find((l) => l.file === file)) };
1012
+ });
1013
+ return { switched, active: refs(after, true), disabled: refs(after, false) };
1014
+ });
1015
+ }
1016
+ async function useHandler(ctx, values, positionals) {
1017
+ const spec = positionals[0] ?? (typeof values.login === 'string' ? values.login : '');
1018
+ const r = await useCliProxyLogin(ctx, spec);
1019
+ if (ctx.json) {
1020
+ ctx.io.stdout.write(json(r));
1021
+ return 0;
1022
+ }
1023
+ const lines = r.switched.map((s) => (s.from?.file === s.to.file
1024
+ ? `${loginName(s.to)} is already your active ${s.provider} login.`
1025
+ : `Active ${s.provider} login: ${loginName(s.to)}. Claude Code, Codex and T3 Code use it from their next request.`));
1026
+ for (const provider of new Set(r.switched.map((s) => s.provider))) {
1027
+ const others = r.disabled.filter((l) => l.provider === provider);
1028
+ if (others.length)
1029
+ lines.push(`Kept in the hub, disabled: ${others.map(loginName).join(', ')}.`);
1030
+ }
1031
+ ctx.io.stdout.write(`${lines.join('\n')}\n`);
1032
+ return 0;
1033
+ }
1034
+ /** The hub's active.json as a set of files (null when it cannot be read: status then marks a provider's only enabled login). */
1035
+ async function activeFilesOf(hub) {
1036
+ try {
1037
+ const opened = await hub.open(false);
1038
+ if (!opened)
1039
+ return null;
1040
+ const text = await opened.ws.read(HUB_ACTIVE_FILE);
1041
+ return text === null ? null : new Set(Object.values(parseActive(text)));
1042
+ }
1043
+ catch {
1044
+ return null;
1045
+ }
1046
+ }
843
1047
  // ---- handlers ------------------------------------------------------------------------------------------------------
844
1048
  function statusLines(state, accounts, forwarding, launchd, machineVersion) {
845
1049
  const hub = state === null ? 'missing (run shard cli-proxy import)' : state === 'running' ? 'running' : ['suspended', 'parked', 'stopped'].includes(state) ? 'sleeping (wakes on the next request)' : state;
@@ -921,6 +1125,8 @@ async function status(ctx, values) {
921
1125
  }
922
1126
  if (health.ok)
923
1127
  accounts = await hubAccounts(info).catch(() => null);
1128
+ if (accounts)
1129
+ accounts = markActive(accounts, await activeFilesOf(hub));
924
1130
  }
925
1131
  const identity = await cliProxyTools.identity();
926
1132
  const launchd = existsSync(forwarderPlistPath(ctx.io.env.HOME || homedir()));
@@ -948,6 +1154,8 @@ async function importHandler(ctx, values) {
948
1154
  ...(r.config === 'kept' ? ['The hub keeps its settings (client keys, routing, upstream keys).'] : []),
949
1155
  ...r.logins.moved_aside.map((dir) => `This computer’s auth files are in ${dir} now, where no CLIProxyAPI reads them.`),
950
1156
  ...(r.port !== CLI_PROXY_PORT ? [`Point tools that used http://127.0.0.1:${r.port} at http://127.0.0.1:${CLI_PROXY_PORT}.`] : []),
1157
+ ...(r.logins.active.length ? [`Active logins: ${r.logins.active.map((l) => `${l.provider} ${loginName(l)}`).join(', ')}.`] : []),
1158
+ ...(r.logins.disabled.length ? [`Kept in the hub, disabled: ${r.logins.disabled.map((l) => `${l.provider} ${loginName(l)}`).join(', ')}. Switch any time: shard cli-proxy use <email>`] : []),
951
1159
  ...(r.accounts ? [`Accounts: ${r.accounts.length}`, ...r.accounts.map(accountLine)] : []),
952
1160
  r.forwarder.launchd ? `This computer: http://127.0.0.1:${CLI_PROXY_PORT} forwards to it (starts at login).` : `This computer: run shard cli-proxy forward to reach it at http://127.0.0.1:${CLI_PROXY_PORT}.`,
953
1161
  r.machine && 'version' in r.machine ? `VMs: every new VM reaches it at http://127.0.0.1:${CLI_PROXY_PORT} (machine version ${r.machine.version}).` : '',
@@ -961,9 +1169,12 @@ async function importHandler(ctx, values) {
961
1169
  async function forwardHandler(ctx) {
962
1170
  const source = hubInfoSource(ctx.store.dir);
963
1171
  const log = (line) => void ctx.io.stderr.write(`${new Date().toISOString()} ${line}\n`);
1172
+ let checks = null;
964
1173
  const forwarder = new CliProxyForwarder({
965
1174
  hub: source,
966
1175
  log,
1176
+ // The first request after the hub slept: check its logins as it wakes.
1177
+ onRequest: () => checks?.nudge(),
967
1178
  repair: async () => {
968
1179
  const info = source();
969
1180
  if (!info || info.api_url !== ctx.apiUrl)
@@ -981,14 +1192,68 @@ async function forwardHandler(ctx) {
981
1192
  if (!source())
982
1193
  log('not connected to a hosted CLIProxyAPI yet; answering 503 until shard cli-proxy import connects this computer');
983
1194
  await forwarder.listen();
1195
+ checks = hubLoginChecks(ctx, source, log);
984
1196
  await new Promise((resolve) => {
985
1197
  if (ctx.signal.aborted)
986
1198
  return resolve();
987
1199
  ctx.signal.addEventListener('abort', () => resolve(), { once: true });
988
1200
  });
1201
+ checks.stop();
989
1202
  await forwarder.close();
990
1203
  return 0;
991
1204
  }
1205
+ /** How often the forwarder checks that the hub serves one login per provider while it runs. */
1206
+ export const HUB_LOGIN_CHECK_MS = 15 * 60_000;
1207
+ /** How soon it looks again when the hub was asleep (a VM's request may wake it any moment; looking never wakes it). */
1208
+ export const HUB_LOGIN_ASLEEP_MS = 60_000;
1209
+ /** The least time between two checks a request asks for (requests come in bursts). */
1210
+ export const HUB_LOGIN_NUDGE_MS = 10_000;
1211
+ /**
1212
+ * The forwarder's check that the hub serves one login per provider (enforceHubLoginsIfRunning), never waking the hub:
1213
+ * at start; then every HUB_LOGIN_CHECK_MS while it runs and every HUB_LOGIN_ASLEEP_MS while it sleeps; and at the first
1214
+ * request after it slept (nudge), so a hub set up before one login per provider cannot fail over between logins for
1215
+ * long after it wakes. One check at a time.
1216
+ */
1217
+ export function hubLoginChecks(ctx, source, log, opts = {}) {
1218
+ const everyMs = opts.everyMs ?? HUB_LOGIN_CHECK_MS, asleepMs = opts.asleepMs ?? HUB_LOGIN_ASLEEP_MS, nudgeMs = opts.nudgeMs ?? HUB_LOGIN_NUDGE_MS;
1219
+ let running = false, stopped = false, last = null, lastAt = 0;
1220
+ let timer;
1221
+ const schedule = (ms) => { clearTimeout(timer); if (!stopped) {
1222
+ timer = setTimeout(() => void check(), ms);
1223
+ timer.unref?.();
1224
+ } };
1225
+ const check = async () => {
1226
+ const info = source();
1227
+ if (running || stopped)
1228
+ return;
1229
+ if (!info || info.api_url !== ctx.apiUrl) {
1230
+ schedule(everyMs);
1231
+ return;
1232
+ }
1233
+ running = true;
1234
+ try {
1235
+ const hub = opts.hub ?? sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel });
1236
+ last = await enforceHubLoginsIfRunning(hub, info.workspace_id);
1237
+ if (last === 'enforced')
1238
+ log('the hub serves one login per provider');
1239
+ }
1240
+ catch (err) {
1241
+ last = 'failed';
1242
+ log(`could not check the hub's logins (${describeError(err).message}); checking again soon`);
1243
+ }
1244
+ finally {
1245
+ running = false;
1246
+ lastAt = Date.now();
1247
+ schedule(last === 'enforced' || last === 'missing' ? everyMs : asleepMs);
1248
+ }
1249
+ };
1250
+ void check();
1251
+ return {
1252
+ nudge() { if (!running && !stopped && (last !== 'enforced' || Date.now() - lastAt >= everyMs) && Date.now() - lastAt >= nudgeMs)
1253
+ void check(); },
1254
+ stop() { stopped = true; clearTimeout(timer); },
1255
+ };
1256
+ }
992
1257
  /**
993
1258
  * After the edge refused the hub's callback URL (`callback_invalid`: the API key that created it was revoked):
994
1259
  * replaces the URLs the edge refuses (under the hub's lock), saves them locally and gives VMs the new VM address.
@@ -1012,5 +1277,6 @@ export const CLI_PROXY_HANDLERS = {
1012
1277
  'cli-proxy': status,
1013
1278
  'cli-proxy import': importHandler,
1014
1279
  'cli-proxy export': exportHandler,
1280
+ 'cli-proxy use': useHandler,
1015
1281
  'cli-proxy forward': forwardHandler,
1016
1282
  };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `shard settings` (0.20.1+): this computer's settings for its cloud conversations, kept in agents.json next to
3
+ * open_in and terminal_app. It sets the title marker (title_prefix), and shows where conversations open and the
4
+ * terminal app (set with `shard agents targets --open-in/--terminal`). The menu bar and the Linux app read and write
5
+ * the marker through it.
6
+ *
7
+ * The marker is what a conversation's Claude session title starts with in claude.ai and the Claude app
8
+ * ("☁ Fix login bug · todo-app"). Stored: absent for the platform's default, "" for none, else the text; the guest
9
+ * adds the space after it. New conversations send it as `title_prefix`, legacy agent launches as SF_TITLE_PREFIX.
10
+ */
11
+ import type { Handler } from './commands.js';
12
+ import type { OpenIn } from './t3-mode.js';
13
+ /** The platform's marker (the guest's "☁ " without its space). */
14
+ export declare const DEFAULT_TITLE_PREFIX = "\u2601";
15
+ /** At most this many characters on one line, as the API takes it (POST /v1/conversations title_prefix). */
16
+ export declare const TITLE_PREFIX_MAX = 16;
17
+ /** `--title-prefix <text|none|default>`: null for the default (the stored choice is removed), "" for none. */
18
+ export declare function parseTitlePrefix(spec: string): string | null;
19
+ /** The marker this computer chose: undefined for the default (nothing is sent), "" for none. An unreadable or invalid
20
+ * stored value counts as the default, so it never blocks a start. */
21
+ export declare function storedTitlePrefix(dir: string): string | undefined;
22
+ export declare function saveTitlePrefix(dir: string, choice: string | null): void;
23
+ /** `shard settings --json`. title_prefix: null is the default, "" none. open_in and terminal_app are the saved
24
+ * choices (null: automatic). */
25
+ export declare function clientSettings(dir: string): {
26
+ title_prefix: string | null;
27
+ title_prefix_default: string;
28
+ open_in: {
29
+ claude: OpenIn | null;
30
+ codex: OpenIn | null;
31
+ };
32
+ terminal_app: {
33
+ name: string;
34
+ id: import("./terminal-apps.js").TerminalId;
35
+ mode: import("./terminal-apps.js").TerminalMode;
36
+ } | null;
37
+ };
38
+ export declare function settingsText(s: ReturnType<typeof clientSettings>): string;
39
+ export declare const SETTINGS_HANDLERS: Record<string, Handler>;
@@ -0,0 +1,91 @@
1
+ import { out } from "./commands.js";
2
+ import { AgentStateStore } from "./agent-state.js";
3
+ import { UsageError } from "./errors.js";
4
+ import { objectValue } from "./acp-rpc.js";
5
+ import { openInTargets } from "./t3-mode.js";
6
+ import { storedTerminal, terminalName } from "./terminal-apps.js";
7
+ /** The platform's marker (the guest's "☁ " without its space). */
8
+ export const DEFAULT_TITLE_PREFIX = '☁';
9
+ /** At most this many characters on one line, as the API takes it (POST /v1/conversations title_prefix). */
10
+ export const TITLE_PREFIX_MAX = 16;
11
+ const SAMPLE = 'Fix login bug · todo-app';
12
+ // The API's pattern: no C0 control characters and no DEL.
13
+ const control = (value) => [...value].some(c => { const n = c.codePointAt(0); return n < 0x20 || n === 0x7f; });
14
+ const validPrefix = (value) => !control(value) && [...value].length <= TITLE_PREFIX_MAX;
15
+ /** `--title-prefix <text|none|default>`: null for the default (the stored choice is removed), "" for none. */
16
+ export function parseTitlePrefix(spec) {
17
+ const text = spec.trim();
18
+ if (text === 'default')
19
+ return null;
20
+ if (text === 'none' || text === '')
21
+ return '';
22
+ if (control(text))
23
+ throw new UsageError('--title-prefix is one line of text: no tabs, line breaks or control characters.');
24
+ if ([...text].length > TITLE_PREFIX_MAX)
25
+ throw new UsageError(`--title-prefix is at most ${TITLE_PREFIX_MAX} characters (none for no marker, default for ${DEFAULT_TITLE_PREFIX}).`);
26
+ return text;
27
+ }
28
+ /** The marker this computer chose: undefined for the default (nothing is sent), "" for none. An unreadable or invalid
29
+ * stored value counts as the default, so it never blocks a start. */
30
+ export function storedTitlePrefix(dir) {
31
+ try {
32
+ const value = new AgentStateStore(dir).read().title_prefix;
33
+ return typeof value === 'string' && validPrefix(value) ? value : undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ export function saveTitlePrefix(dir, choice) {
40
+ new AgentStateStore(dir).update(doc => { if (choice === null)
41
+ delete doc.title_prefix;
42
+ else
43
+ doc.title_prefix = choice; });
44
+ }
45
+ /** `shard settings --json`. title_prefix: null is the default, "" none. open_in and terminal_app are the saved
46
+ * choices (null: automatic). */
47
+ export function clientSettings(dir) {
48
+ const doc = new AgentStateStore(dir).read();
49
+ const saved = objectValue(doc.open_in);
50
+ const openIn = (app) => {
51
+ const value = saved[app];
52
+ return typeof value === 'string' && openInTargets(app).includes(value) ? value : null;
53
+ };
54
+ const terminal = storedTerminal(dir);
55
+ const prefix = typeof doc.title_prefix === 'string' && validPrefix(doc.title_prefix) ? doc.title_prefix : null;
56
+ return {
57
+ title_prefix: prefix,
58
+ title_prefix_default: DEFAULT_TITLE_PREFIX,
59
+ open_in: { claude: openIn('claude'), codex: openIn('codex') },
60
+ terminal_app: terminal ? { ...terminal, name: terminalName(terminal.id) } : null,
61
+ };
62
+ }
63
+ const sampleTitle = (prefix) => `${prefix === null ? DEFAULT_TITLE_PREFIX + ' ' : prefix ? prefix + ' ' : ''}${SAMPLE}`;
64
+ const markerLine = (prefix) => prefix === null ? `${DEFAULT_TITLE_PREFIX} (default)` : prefix === '' ? 'none' : prefix;
65
+ export function settingsText(s) {
66
+ const target = (t) => t ?? 'automatic';
67
+ return [
68
+ `Title marker ${markerLine(s.title_prefix)} "${sampleTitle(s.title_prefix)}"`,
69
+ `Open in Claude: ${target(s.open_in.claude)} · Codex: ${target(s.open_in.codex)}`,
70
+ `Terminal app ${s.terminal_app ? `${s.terminal_app.name}, ${s.terminal_app.mode === 'tab' ? 'new tab' : 'new window'}` : 'automatic'}`,
71
+ '',
72
+ 'Change the marker with shard settings --title-prefix <text|none|default>; where conversations open with shard agents targets --open-in and --terminal.',
73
+ ].join('\n') + '\n';
74
+ }
75
+ function settings(ctx, values) {
76
+ const spec = values['title-prefix'];
77
+ if (spec !== undefined && typeof spec !== 'string')
78
+ throw new UsageError('--title-prefix takes text, none or default.');
79
+ if (typeof spec === 'string') {
80
+ const choice = parseTitlePrefix(spec);
81
+ saveTitlePrefix(ctx.store.dir, choice);
82
+ const s = clientSettings(ctx.store.dir);
83
+ out(ctx, `${s.title_prefix === '' ? 'New conversations have no title marker' : `New conversations’ titles start with ${markerLine(s.title_prefix)}`}: "${sampleTitle(s.title_prefix)}".\n`, s);
84
+ return 0;
85
+ }
86
+ const s = clientSettings(ctx.store.dir);
87
+ out(ctx, settingsText(s), s);
88
+ return 0;
89
+ }
90
+ // A refused --title-prefix rejects the command's promise (a usage error, exit 2) rather than throwing.
91
+ export const SETTINGS_HANDLERS = { settings: (ctx, values) => Promise.resolve().then(() => settings(ctx, values)) };
@@ -3,6 +3,7 @@ import type { Ctx, Handler } from './commands.js';
3
3
  import type { Values } from './args.js';
4
4
  import type { ModelChoice } from './agent-models.js';
5
5
  import type { Conversation } from './conversations.js';
6
+ import type { LocalConversation } from './conversations.js';
6
7
  import type { InitialImage } from './initial-prompt.js';
7
8
  /** Only provider-returned task ids enter the registered Codex deep-link route. */
8
9
  export declare function openCodexThread(task: CodexCloudTask, env: Ctx['io']['env']): Promise<void>;
@@ -48,5 +49,9 @@ export declare function ensureCodexCloud(ctx: Ctx): Promise<{
48
49
  last_seen_at: string | null;
49
50
  }>;
50
51
  export declare function launchCodexCloud(ctx: Ctx, values: Values, path: string, images?: readonly InitialImage[]): Promise<number>;
52
+ /** A start whose carrying process said so in the last 45 s. */
53
+ export declare const startCarried: (start: NonNullable<LocalConversation["cloud_start"]>) => boolean;
51
54
  export declare function openCodexConversation(ctx: Ctx, c: Conversation): Promise<number>;
55
+ /** Continues the same accepted identity, never creates a replacement VM/chat. */
56
+ export declare function resumeCloudStartup(ctx: Ctx, c: Conversation): Promise<CodexCloudTask>;
52
57
  export declare const CODEX_CLOUD_HANDLERS: Record<string, Handler>;