@shardflux/cli 0.22.0 → 0.23.1

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 (61) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +47 -14
  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 +14 -14
  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-commands.js +16 -2
  31. package/dist/conversation-terminal.js +3 -2
  32. package/dist/conversations.d.ts +14 -1
  33. package/dist/conversations.js +61 -7
  34. package/dist/desktop-account.d.ts +16 -0
  35. package/dist/desktop-account.js +29 -0
  36. package/dist/desktop-linux.js +16 -3
  37. package/dist/errors.d.ts +2 -1
  38. package/dist/errors.js +5 -2
  39. package/dist/filesystem-commands.js +25 -3
  40. package/dist/menubar.d.ts +6 -5
  41. package/dist/menubar.js +40 -28
  42. package/dist/move-worktrees.d.ts +13 -7
  43. package/dist/move-worktrees.js +68 -17
  44. package/dist/move.js +20 -25
  45. package/dist/onboarding.js +4 -2
  46. package/dist/prepared-repository.d.ts +2 -0
  47. package/dist/prepared-repository.js +1 -1
  48. package/dist/prepared-repository.py +3 -2
  49. package/dist/project-preparation.d.ts +14 -0
  50. package/dist/project-preparation.js +16 -4
  51. package/dist/project-sync.d.ts +3 -0
  52. package/dist/project-sync.js +4 -1
  53. package/dist/t3-launch.d.ts +2 -1
  54. package/dist/t3-launch.js +11 -2
  55. package/dist/t3-proxy.d.ts +81 -0
  56. package/dist/t3-proxy.js +325 -0
  57. package/dist/terminal-apps.d.ts +11 -0
  58. package/dist/terminal-apps.js +18 -5
  59. package/dist/update.d.ts +2 -0
  60. package/dist/update.js +32 -6
  61. package/package.json +5 -5
package/dist/cli-proxy.js CHANGED
@@ -7,12 +7,12 @@
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
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.
10
+ * The hub is the user's own CLIProxyAPI: it routes requests across their logins as their config says, and each login
11
+ * stays enabled or disabled as they left it. `shard cli-proxy use` is their switch: one login for a provider, or all.
12
12
  */
13
13
  import { execFile } from 'node:child_process';
14
14
  import { createHash } from 'node:crypto';
15
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
16
16
  import { connect } from 'node:net';
17
17
  import { homedir, userInfo } from 'node:os';
18
18
  import { basename, dirname, join } from 'node:path';
@@ -21,9 +21,9 @@ import { parse as parseYaml } from 'yaml';
21
21
  import { AgentStateStore } from "./agent-state.js";
22
22
  import { syncAgentConfig } from "./agent-config-sync.js";
23
23
  import { CliProxyForwarder, IDENTITY_PATH, hubInfoSource } from "./cli-proxy-forward.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";
24
+ import { ADD_AUTH_SCRIPT, CLI_PROXY_PORT, CLI_PROXY_VM_PORT, HUB_AUTH_DIR, HUB_KEY, HubError, SET_ASIDE_SCRIPT, STOP_SCRIPT, ensureCliProxyHub, hubCallback, listHubLogins, readHubInfo, sdkHubCloud, useHubLogins, vmHubInfoText, writeHubInfo } from "./cli-proxy-hub.js";
25
25
  import { CLI_VERSION } from "./args.js";
26
- import { UsageError, describeError } from "./errors.js";
26
+ import { UsageError } from "./errors.js";
27
27
  import { json } from "./format.js";
28
28
  import { progress } from "./progress.js";
29
29
  const execute = promisify(execFile);
@@ -634,14 +634,6 @@ function entryFile(f) {
634
634
  return f.path.slice(HUB_AUTH_DIR.length + 1);
635
635
  return [f.id, f.name].find((v) => typeof v === 'string' && v !== '') ?? null;
636
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
- }
645
637
  /** The hub's logins from its management API (wakes the hub). */
646
638
  export async function hubAccounts(info, timeoutMs = 45_000) {
647
639
  const res = await fetch(new URL('v0/management/auth-files', info.callback_url), {
@@ -658,7 +650,7 @@ export async function hubAccounts(info, timeoutMs = 45_000) {
658
650
  status: typeof f.status === 'string' && f.status ? f.status : null,
659
651
  disabled: f.disabled === true,
660
652
  file: entryFile(f),
661
- active: false,
653
+ active: f.disabled !== true,
662
654
  }));
663
655
  }
664
656
  /** Whether the hub answers /healthz through its callback URL, and the edge's reason when it does not. */
@@ -672,7 +664,7 @@ export async function hubHealth(info, timeoutMs = 45_000) {
672
664
  return { ok: false, reason: err.name === 'TimeoutError' ? 'timeout' : 'unreachable' };
673
665
  }
674
666
  }
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}` : ''}`;
667
+ const accountLine = (a) => ` ${a.provider.padEnd(8)} ${a.email ?? '(no email)'}${a.disabled ? ' disabled' : a.status && a.status !== 'active' ? ` ${a.status}` : ''}`;
676
668
  /** The logins a `--use` or `shard cli-proxy use` value names: by email (any case), path in the auth folder, or file name. */
677
669
  export function matchLogins(spec, logins) {
678
670
  const want = spec.trim().toLowerCase();
@@ -681,7 +673,8 @@ export function matchLogins(spec, logins) {
681
673
  const loginName = (l) => l.email ?? l.file;
682
674
  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
675
  /**
684
- * Resolves `--use` (repeatable): each value names one login per provider, on this computer or already in the hub.
676
+ * Resolves `--use` (repeatable): each value names the one login that serves its provider, on this computer or already
677
+ * in the hub.
685
678
  * Checked before anything changes, so a typo stops the import with the logins to pick from.
686
679
  */
687
680
  function resolveUse(specs, local, hub) {
@@ -710,23 +703,13 @@ function resolveUse(specs, local, hub) {
710
703
  const choice = here[0] ? { local: here[0].file, identity: here[0].identity } : { hub: there[0].file };
711
704
  const earlier = by.get(provider);
712
705
  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');
706
+ throw new UsageError(`--use ${earlier} and --use ${spec} both name a ${provider} login; --use names one login per provider.`, 'keep one --use per provider');
714
707
  by.set(provider, spec);
715
708
  out.set(provider, choice);
716
709
  }
717
710
  }
718
711
  return out;
719
712
  }
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
713
  const useSpecs = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : []).filter((x) => x.trim() !== '');
731
714
  /**
732
715
  * Moves CLIProxyAPI from this computer into the account (or connects this computer to the account's hub): ensure the
@@ -734,9 +717,9 @@ const useSpecs = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : []
734
717
  * 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
735
718
  * the machine template.
736
719
  *
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.
720
+ * Each login arrives as it is on this computer (enabled or disabled), and the hub routes across the enabled ones as
721
+ * the user's config says. `--use <email|file>` (repeatable, one per provider) makes that login the only one serving
722
+ * its provider (the provider's others are kept in the hub, disabled), as `shard cli-proxy use` does.
740
723
  */
741
724
  export async function importCliProxy(ctx, opts = {}) {
742
725
  if (cliProxyTools.inVm()) {
@@ -817,8 +800,7 @@ export async function importCliProxy(ctx, opts = {}) {
817
800
  machine = { error: err instanceof Error ? err.message : String(err) };
818
801
  }
819
802
  }
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);
803
+ const accounts = await hubAccounts(ensured.info).catch(() => null);
822
804
  return {
823
805
  hub: { workspace_id: ensured.info.workspace_id, created: ensured.created, process: ensured.process },
824
806
  logins: { uploaded, kept, added, already, moved_aside: movedAside.map((m) => m.to), active: refs(hubLogins, true), disabled: refs(hubLogins, false) },
@@ -826,57 +808,23 @@ export async function importCliProxy(ctx, opts = {}) {
826
808
  };
827
809
  }
828
810
  /**
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.
811
+ * Under the hub's logins lock: uploads this computer's logins the hub lacks, each as it is (enabled or disabled), then
812
+ * applies --use. Returns the hub's logins after.
831
813
  */
832
814
  async function uploadLogins(ws, local, use, out) {
833
815
  const inHub = new Map();
834
- const providersInHub = new Set();
835
816
  const present = await ws.authFiles();
836
817
  for (const rel of present) {
837
818
  const text = await ws.read(`${HUB_AUTH_DIR}/${rel}`);
838
819
  const id = text === null ? `file:${rel}` : loginIdentity(text, rel);
839
820
  if (!inHub.has(id))
840
821
  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
822
  }
851
823
  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 });
824
+ for (const login of local?.logins ?? []) {
825
+ const text = readFileSync(join(local.authDir, login.file), 'utf8');
826
+ const identity = loginIdentity(text, login.file);
827
+ const r = inHub.has(identity) || names.has(login.file) ? null : await ws.sh(ADD_AUTH_SCRIPT, [login.file], { stdin: text });
880
828
  if (r && r.code !== 0)
881
829
  throw new HubError(`Could not add ${login.file} to the hub: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.code}`}`, `run: ${AGAIN}`);
882
830
  if (!r || r.stdout.trim() === 'exists') {
@@ -886,19 +834,18 @@ async function uploadLogins(ws, local, use, out) {
886
834
  }
887
835
  out.uploaded.push(login.file);
888
836
  out.added.push({ provider: login.provider, email: login.email });
889
- inHub.set(login.identity, login.file);
837
+ inHub.set(identity, login.file);
890
838
  names.add(login.file);
891
839
  }
892
- const prefer = {};
840
+ if (!use.size)
841
+ return listHubLogins(ws);
842
+ const only = {};
893
843
  for (const [provider, choice] of use) {
894
844
  const file = 'hub' in choice ? choice.hub : inHub.get(choice.identity);
895
845
  if (file !== undefined)
896
- prefer[provider] = file;
846
+ only[provider] = file;
897
847
  }
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);
848
+ return useHubLogins(ws, { only });
902
849
  }
903
850
  /**
904
851
  * Brings the hub's logins back to this computer: the hub stops first (from then on only this computer refreshes
@@ -978,72 +925,68 @@ async function exportHandler(ctx, values) {
978
925
  return 0;
979
926
  }
980
927
  /**
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.
928
+ * `shard cli-proxy use <email|file>`: that login alone serves its provider (an email with a Claude and a Codex login
929
+ * sets both); the provider's other logins stay in the hub, disabled. `--all`: every login serves again, and the hub
930
+ * routes across them as its config says. Under the hub's logins lock.
983
931
  */
984
932
  export async function useCliProxyLogin(ctx, spec, opts = {}) {
985
933
  if (cliProxyTools.inVm())
986
934
  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>');
935
+ if (!opts.all && !spec.trim())
936
+ throw new UsageError('Name the login to use, or --all for every login.', 'run: shard cli-proxy use <email>');
937
+ if (opts.all && spec.trim())
938
+ throw new UsageError('Name a login or use --all, not both.', 'run: shard cli-proxy use --all');
989
939
  const hub = opts.hub ?? sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel });
990
940
  const opened = await hub.open(false);
991
941
  if (!opened)
992
942
  throw new UsageError('This account has no hosted CLIProxyAPI yet.', `move yours: ${AGAIN}`);
993
943
  return opened.ws.withLock('logins', async () => {
994
944
  const before = await listHubLogins(opened.ws);
945
+ if (opts.all) {
946
+ const after = await useHubLogins(opened.ws, { all: true });
947
+ return { switched: [], active: refs(after, true), disabled: refs(after, false) };
948
+ }
995
949
  const matches = matchLogins(spec, before);
996
950
  if (!matches.length) {
997
951
  const all = before.filter((l) => l.provider).map((l) => `${l.provider} ${loginName(l)}`);
998
952
  throw new UsageError(`No login in your hosted CLIProxyAPI matches ${spec}.`, all.length ? `pick one of: ${all.join(', ')}` : `add yours: ${AGAIN}`);
999
953
  }
1000
- const prefer = {};
954
+ const only = {};
1001
955
  for (const provider of new Set(matches.map((l) => l.provider))) {
1002
956
  const mine = matches.filter((l) => l.provider === provider);
1003
957
  if (mine.length > 1)
1004
958
  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;
959
+ only[provider] = mine[0].file;
1006
960
  }
1007
- const after = await enforceActiveLogins(opened.ws, prefer);
961
+ const after = await useHubLogins(opened.ws, { only });
1008
962
  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)) };
963
+ const switched = Object.entries(only).map(([provider, file]) => {
964
+ const was = before.filter((l) => l.provider === provider && l.active);
965
+ return { provider, from: was.length === 1 ? ref(was[0]) : null, to: ref(after.find((l) => l.file === file)) };
1012
966
  });
1013
967
  return { switched, active: refs(after, true), disabled: refs(after, false) };
1014
968
  });
1015
969
  }
1016
970
  async function useHandler(ctx, values, positionals) {
1017
971
  const spec = positionals[0] ?? (typeof values.login === 'string' ? values.login : '');
1018
- const r = await useCliProxyLogin(ctx, spec);
972
+ const r = await useCliProxyLogin(ctx, spec, { all: values.all === true });
1019
973
  if (ctx.json) {
1020
974
  ctx.io.stdout.write(json(r));
1021
975
  return 0;
1022
976
  }
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.`));
977
+ const lines = values.all === true
978
+ ? [`Every login serves requests, routed as your CLIProxyAPI config says: ${r.active.map((l) => `${l.provider} ${loginName(l)}`).join(', ') || 'none yet'}.`]
979
+ : r.switched.map((s) => (s.from?.file === s.to.file
980
+ ? `${loginName(s.to)} already serves your ${s.provider} requests.`
981
+ : `${loginName(s.to)} serves your ${s.provider} requests now. Claude Code, Codex and T3 Code use it from their next request.`));
1026
982
  for (const provider of new Set(r.switched.map((s) => s.provider))) {
1027
983
  const others = r.disabled.filter((l) => l.provider === provider);
1028
984
  if (others.length)
1029
- lines.push(`Kept in the hub, disabled: ${others.map(loginName).join(', ')}.`);
985
+ lines.push(`Kept in the hub, disabled: ${others.map(loginName).join(', ')}. Use them all again: shard cli-proxy use --all`);
1030
986
  }
1031
987
  ctx.io.stdout.write(`${lines.join('\n')}\n`);
1032
988
  return 0;
1033
989
  }
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
- }
1047
990
  // ---- handlers ------------------------------------------------------------------------------------------------------
1048
991
  function statusLines(state, accounts, forwarding, launchd, machineVersion) {
1049
992
  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;
@@ -1125,8 +1068,6 @@ async function status(ctx, values) {
1125
1068
  }
1126
1069
  if (health.ok)
1127
1070
  accounts = await hubAccounts(info).catch(() => null);
1128
- if (accounts)
1129
- accounts = markActive(accounts, await activeFilesOf(hub));
1130
1071
  }
1131
1072
  const identity = await cliProxyTools.identity();
1132
1073
  const launchd = existsSync(forwarderPlistPath(ctx.io.env.HOME || homedir()));
@@ -1154,8 +1095,7 @@ async function importHandler(ctx, values) {
1154
1095
  ...(r.config === 'kept' ? ['The hub keeps its settings (client keys, routing, upstream keys).'] : []),
1155
1096
  ...r.logins.moved_aside.map((dir) => `This computer’s auth files are in ${dir} now, where no CLIProxyAPI reads them.`),
1156
1097
  ...(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>`] : []),
1098
+ ...(r.logins.disabled.length ? [`Kept in the hub, disabled: ${r.logins.disabled.map((l) => `${l.provider} ${loginName(l)}`).join(', ')}. Use them too: shard cli-proxy use --all`] : []),
1159
1099
  ...(r.accounts ? [`Accounts: ${r.accounts.length}`, ...r.accounts.map(accountLine)] : []),
1160
1100
  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}.`,
1161
1101
  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}).` : '',
@@ -1169,12 +1109,9 @@ async function importHandler(ctx, values) {
1169
1109
  async function forwardHandler(ctx) {
1170
1110
  const source = hubInfoSource(ctx.store.dir);
1171
1111
  const log = (line) => void ctx.io.stderr.write(`${new Date().toISOString()} ${line}\n`);
1172
- let checks = null;
1173
1112
  const forwarder = new CliProxyForwarder({
1174
1113
  hub: source,
1175
1114
  log,
1176
- // The first request after the hub slept: check its logins as it wakes.
1177
- onRequest: () => checks?.nudge(),
1178
1115
  repair: async () => {
1179
1116
  const info = source();
1180
1117
  if (!info || info.api_url !== ctx.apiUrl)
@@ -1192,68 +1129,14 @@ async function forwardHandler(ctx) {
1192
1129
  if (!source())
1193
1130
  log('not connected to a hosted CLIProxyAPI yet; answering 503 until shard cli-proxy import connects this computer');
1194
1131
  await forwarder.listen();
1195
- checks = hubLoginChecks(ctx, source, log);
1196
1132
  await new Promise((resolve) => {
1197
1133
  if (ctx.signal.aborted)
1198
1134
  return resolve();
1199
1135
  ctx.signal.addEventListener('abort', () => resolve(), { once: true });
1200
1136
  });
1201
- checks.stop();
1202
1137
  await forwarder.close();
1203
1138
  return 0;
1204
1139
  }
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
- }
1257
1140
  /**
1258
1141
  * After the edge refused the hub's callback URL (`callback_invalid`: the API key that created it was revoked):
1259
1142
  * replaces the URLs the edge refuses (under the hub's lock), saves them locally and gives VMs the new VM address.
@@ -5,11 +5,16 @@ import type { ModelChoice } from './agent-models.js';
5
5
  import type { Conversation } from './conversations.js';
6
6
  import type { LocalConversation } from './conversations.js';
7
7
  import type { InitialImage } from './initial-prompt.js';
8
- /** Only provider-returned task ids enter the registered Codex deep-link route. */
9
- export declare function openCodexThread(task: CodexCloudTask, env: Ctx['io']['env']): Promise<void>;
8
+ /** Cloud threads open in the browser, preserving the durable provider destination on every OS. */
9
+ export declare function openCodexThread(task: CodexCloudTask, env: Ctx['io']['env'], platform?: NodeJS.Platform): Promise<void>;
10
10
  export declare function submitCloudInitialPrompt(ctx: Ctx, c: Conversation, prompt: string, workspaceKey: string): Promise<void>;
11
- /** Prints the Cloud thread (and its link) and opens it in the Codex app; --json prints it instead. */
11
+ /** Prints the Cloud thread (and its link) and opens it in your browser; --json prints it instead. */
12
12
  export declare function printCloudThread(ctx: Ctx, c: Conversation, task: CodexCloudTask, submitted?: boolean, choice?: ModelChoice): Promise<void>;
13
+ /**
14
+ * A Cloud conversation where the Codex app cannot show it (0.23.0+): where its chat lives, whether it waits for an
15
+ * answer, and how to answer and follow it from here. Never a new start: the conversation exists.
16
+ */
17
+ export declare function cloudChatElsewhere(c: Pick<Conversation, 'id' | 'key' | 'state'>, url: string): string;
13
18
  /** How long the account's Codex remote device may take to come up before it counts as stuck. */
14
19
  export declare const CODEX_STARTING_LIMIT_MS: number;
15
20
  export type CodexConnectorView = {
@@ -51,7 +56,7 @@ export declare function ensureCodexCloud(ctx: Ctx): Promise<{
51
56
  export declare function launchCodexCloud(ctx: Ctx, values: Values, path: string, images?: readonly InitialImage[]): Promise<number>;
52
57
  /** A start whose carrying process said so in the last 45 s. */
53
58
  export declare const startCarried: (start: NonNullable<LocalConversation["cloud_start"]>) => boolean;
54
- export declare function openCodexConversation(ctx: Ctx, c: Conversation): Promise<number>;
59
+ export declare function openCodexConversation(ctx: Ctx, c: Conversation, history?: true | string): Promise<number>;
55
60
  /** Continues the same accepted identity, never creates a replacement VM/chat. */
56
61
  export declare function resumeCloudStartup(ctx: Ctx, c: Conversation): Promise<CodexCloudTask>;
57
62
  export declare const CODEX_CLOUD_HANDLERS: Record<string, Handler>;
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { spawn } from 'node:child_process';
2
3
  import { CodedError, UsageError } from "./errors.js";
3
4
  import { json } from "./format.js";
@@ -5,25 +6,29 @@ import { syncAgentCredentials } from "./agent-credentials.js";
5
6
  import { createConversation, conversationWorkspace, getConversation, localConversations, recordedChoice, resumeConversation, seedConversation } from "./conversations.js";
6
7
  import { CHOICE_PENDING, choiceReport, hasChoice, modelChoice } from "./agent-models.js";
7
8
  import { AgentStateStore } from "./agent-state.js";
9
+ import { installedTargets } from "./agent-targets.js";
8
10
  import { ensureProjectGit } from "./project-git.js";
9
11
  import { gitRunner } from "./agent-git.js";
10
12
  import { projectIdentity } from "./project-identity.js";
11
13
  import { uploadInitialImages } from "./initial-prompt.js";
12
- /** Only provider-returned task ids enter the registered Codex deep-link route. */
13
- export async function openCodexThread(task, env) {
14
+ /** Cloud threads open in the browser, preserving the durable provider destination on every OS. */
15
+ export async function openCodexThread(task, env, platform = process.platform) {
14
16
  const url = threadLink(task);
15
- const program = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
16
- const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
17
+ const program = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open';
18
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
17
19
  const child = spawn(program, args, { env, stdio: 'ignore' });
18
- await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Codex app did not open in time')); }, 10000); child.once('error', error => { clearTimeout(timer); reject(error); }); child.once('exit', code => { clearTimeout(timer); if (code === 0)
20
+ await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Cloud chat did not open in time')); }, 10000); child.once('error', error => { clearTimeout(timer); reject(error); }); child.once('exit', code => { clearTimeout(timer); if (code === 0)
19
21
  resolve();
20
22
  else
21
- reject(new Error(`Open this Cloud thread in Codex: ${task.desktop_url}`)); }); });
23
+ reject(new Error(`Open this Cloud thread: ${url}`)); }); });
22
24
  }
23
25
  function threadLink(task) {
24
- if (!task.thread_id || !/^[a-zA-Z0-9_-]{1,128}$/.test(task.thread_id) || task.desktop_url !== `codex://threads/${encodeURIComponent(task.thread_id)}?hostId=durable`)
26
+ if (!task.thread_id || !/^[a-zA-Z0-9_-]{1,128}$/.test(task.thread_id))
25
27
  throw new Error('Cloud returned an invalid Codex thread link');
26
- return task.desktop_url;
28
+ const url = `https://chatgpt.com/local/${encodeURIComponent(task.thread_id)}`;
29
+ if (task.web_url != null && task.web_url !== url || task.desktop_url != null && task.desktop_url !== `codex://threads/${encodeURIComponent(task.thread_id)}?hostId=durable`)
30
+ throw new Error('Cloud returned an invalid Codex thread link');
31
+ return url;
27
32
  }
28
33
  export async function submitCloudInitialPrompt(ctx, c, prompt, workspaceKey) {
29
34
  try {
@@ -33,19 +38,32 @@ export async function submitCloudInitialPrompt(ctx, c, prompt, workspaceKey) {
33
38
  throw new CodedError('initial_prompt_failed', `Created ${workspaceKey}, but the initial prompt could not be confirmed. Open that conversation before starting another.`, undefined, { workspace_key: workspaceKey, conversation_id: c.id, submitted: null });
34
39
  }
35
40
  }
36
- /** Prints the Cloud thread (and its link) and opens it in the Codex app; --json prints it instead. */
41
+ /** Prints the Cloud thread (and its link) and opens it in your browser; --json prints it instead. */
37
42
  export async function printCloudThread(ctx, c, task, submitted = false, choice) { return output(ctx, c, task, submitted, choice); }
43
+ /** Whether this computer has the Codex app, which opens a Cloud thread's codex:// link (on Linux 0.23.0+). */
44
+ const codexAppHere = (env) => installedTargets(env ?? {}).clients.includes('codex-app');
45
+ /**
46
+ * A Cloud conversation where the Codex app cannot show it (0.23.0+): where its chat lives, whether it waits for an
47
+ * answer, and how to answer and follow it from here. Never a new start: the conversation exists.
48
+ */
49
+ export function cloudChatElsewhere(c, url) {
50
+ return `${c.key} runs in Codex Cloud${c.state === 'needs_you' ? ' and is waiting for your answer' : ''}. Its chat opens in your browser: ${url}\n` +
51
+ `Answer it from here: shard conversations send ${c.id} "<your answer>"\nWhere it stands: shard conversations get ${c.id}\n`;
52
+ }
38
53
  async function output(ctx, c, task, submitted = false, choice) {
39
- const url = threadLink(task);
54
+ const url = threadLink(task), app = codexAppHere(ctx.io.env);
40
55
  // model_choice (0.17.0+): an admitted thread's record keeps the model and effort only when Cloud started it with them.
41
56
  const applied = hasChoice(choice) ? await cloudChoiceApplied(ctx, c, choice) : true;
57
+ // codex_app and reply_command (0.23.0+): without the Codex app, the conversation is answered with that command.
42
58
  if (ctx.json) {
43
- ctx.io.stdout.write(json({ conversation: c, cloud: task, url, ...(submitted ? { initial_prompt: { submitted: true } } : {}), ...(hasChoice(choice) ? { model_choice: choiceReport(choice, applied) } : {}) }));
59
+ ctx.io.stdout.write(json({ conversation: c, cloud: task, url, previous_threads: (task.previous_thread_ids ?? []).map(id => ({ thread_id: id, url: threadLink({ thread_id: id }) })), codex_app: app, reply_command: ['shard', 'conversations', 'send', c.id, '<message>'], ...(submitted ? { initial_prompt: { submitted: true } } : {}), ...(hasChoice(choice) ? { model_choice: choiceReport(choice, applied) } : {}) }));
44
60
  return;
45
61
  }
46
62
  if (!applied)
47
63
  ctx.io.stderr.write(`${CHOICE_PENDING}\n`);
48
- ctx.io.stdout.write(`${c.key}\n${task.desktop_url}\n`);
64
+ ctx.io.stdout.write(`${c.key}\n${url}\n`);
65
+ for (const id of task.previous_thread_ids ?? [])
66
+ ctx.io.stdout.write(`Earlier history: https://chatgpt.com/local/${encodeURIComponent(id)}\n`);
49
67
  await openCodexThread(task, ctx.io.env);
50
68
  }
51
69
  async function cloudChoiceApplied(ctx, c, choice) {
@@ -183,7 +201,14 @@ function carryStart(ctx, id) {
183
201
  }
184
202
  /** A start whose carrying process said so in the last 45 s. */
185
203
  export const startCarried = (start) => Date.now() - (start.heartbeat_at ?? 0) < 45_000;
186
- export async function openCodexConversation(ctx, c) {
204
+ export async function openCodexConversation(ctx, c, history) {
205
+ if (history) {
206
+ const task = await ctx.cloud().codex.task(c.id), ids = task.previous_thread_ids ?? [];
207
+ const id = history === true ? ids[0] : history;
208
+ if (!id || !ids.includes(id))
209
+ throw new UsageError('This conversation has no matching earlier Cloud history.');
210
+ return output(ctx, c, { ...task, thread_id: id, desktop_url: null, web_url: null }, false).then(() => 0);
211
+ }
187
212
  const local = localConversations(ctx)[c.id];
188
213
  if (local?.cloud_start && local.cloud_start.state !== 'active' && !c.ended_at) {
189
214
  const task = await resumeCloudStartup(ctx, c);
@@ -197,7 +222,15 @@ export async function openCodexConversation(ctx, c) {
197
222
  await output(ctx, c, task);
198
223
  return 0;
199
224
  }
200
- const task = await ctx.cloud().codex.task(c.id);
225
+ let task = await ctx.cloud().codex.task(c.id);
226
+ // A thread a connector change stopped (Codex Cloud turned off and on, or the active Codex account switched) continues
227
+ // in a new Cloud thread of this same conversation, on its VM and branch: an API that admits it again does so on
228
+ // request (0.23.0+); an older one answers with the stopped thread.
229
+ if (task.state === 'canceled') {
230
+ task = await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-readmit-${c.id}-${randomUUID()}`, signal: ctx.signal });
231
+ if (task.state !== 'canceled')
232
+ task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
233
+ }
201
234
  if (task.state === 'canceled')
202
235
  throw new UsageError('This conversation’s Codex executor was removed. Start a new Cloud conversation; its saved branch and history remain available.');
203
236
  if (task.state !== 'active' || !task.thread_id)
@@ -0,0 +1,14 @@
1
+ type Env = Record<string, string | undefined>;
2
+ /** A Codex app install: its desktop entry and that entry's Exec as argv (field codes removed). */
3
+ export interface LinuxCodexApp {
4
+ desktop_id: string;
5
+ exec: string[];
6
+ }
7
+ /** The installed Codex apps: entries that handle codex:// and whose program exists. The first entry with an id wins (XDG precedence). */
8
+ export declare function linuxCodexApps(dirs: string[], env: Env): LinuxCodexApp[];
9
+ /**
10
+ * Opens a codex:// link in that install, as the desktop does: its Exec with the link. Detached, so a cold start keeps
11
+ * running after `shard` exits; a running app takes the link in its window.
12
+ */
13
+ export declare function openLinuxCodexLink(app: LinuxCodexApp, url: string, env: Env): Promise<void>;
14
+ export {};
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The Codex app on Linux (0.23.0+): OpenAI's ChatGPT app for Linux (package `chatgpt`: `chatgpt.desktop`,
3
+ * `Exec=chatgpt %U`) and community builds (`codex-desktop.desktop`) register the codex:// scheme, which opens a Codex
4
+ * thread (`codex://threads/<id>?hostId=durable` for a Cloud conversation). The app is the desktop entry in the XDG
5
+ * application folders (linuxApplicationDirs) that handles x-scheme-handler/codex.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs';
9
+ import { delimiter, isAbsolute, join } from 'node:path';
10
+ import { desktopEntry, desktopExec, t3OpenEnv } from "./t3-linux.js";
11
+ /** Entry names worth reading (every entry in the folders would be hundreds of files on each `shard` run). */
12
+ const MAYBE_ENTRY = /chatgpt|codex|openai/i;
13
+ const executable = (path) => { try {
14
+ accessSync(path, constants.X_OK);
15
+ return statSync(path).isFile();
16
+ }
17
+ catch {
18
+ return false;
19
+ } };
20
+ const onPath = (name, env) => isAbsolute(name) ? executable(name) : (env.PATH ?? '').split(delimiter).filter(Boolean).some(dir => executable(join(dir, name)));
21
+ /** The installed Codex apps: entries that handle codex:// and whose program exists. The first entry with an id wins (XDG precedence). */
22
+ export function linuxCodexApps(dirs, env) {
23
+ const found = [], ids = new Set();
24
+ for (const dir of dirs) {
25
+ let names;
26
+ try {
27
+ names = readdirSync(dir);
28
+ }
29
+ catch {
30
+ continue;
31
+ }
32
+ for (const name of names) {
33
+ if (!name.endsWith('.desktop') || !MAYBE_ENTRY.test(name) || ids.has(name))
34
+ continue;
35
+ let text;
36
+ try {
37
+ text = readFileSync(join(dir, name), 'utf8');
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ ids.add(name);
43
+ const e = desktopEntry(text);
44
+ if (e.Hidden === 'true' || (e.Type ?? 'Application') !== 'Application' || !(e.MimeType ?? '').split(';').includes('x-scheme-handler/codex'))
45
+ continue;
46
+ if (e.TryExec && !onPath(e.TryExec, env))
47
+ continue;
48
+ const exec = desktopExec(e.Exec ?? '');
49
+ // `env VAR=value /usr/bin/codex-desktop %u` (a community entry): the program after the assignments must exist.
50
+ const program = exec?.[0] === 'env' ? exec.slice(1).find(a => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) : exec?.[0];
51
+ if (!exec?.length || !program || !onPath(program, env))
52
+ continue;
53
+ found.push({ desktop_id: name, exec });
54
+ }
55
+ }
56
+ return found;
57
+ }
58
+ /**
59
+ * Opens a codex:// link in that install, as the desktop does: its Exec with the link. Detached, so a cold start keeps
60
+ * running after `shard` exits; a running app takes the link in its window.
61
+ */
62
+ export async function openLinuxCodexLink(app, url, env) {
63
+ const [command, ...args] = app.exec;
64
+ await new Promise((resolve, reject) => {
65
+ const child = spawn(command, [...args, url], { env: t3OpenEnv(env), stdio: 'ignore', detached: true });
66
+ child.once('spawn', () => { child.unref(); resolve(); });
67
+ child.once('error', reject);
68
+ });
69
+ }