@shardflux/cli 0.19.1 → 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 (115) hide show
  1. package/CHANGELOG.md +278 -1
  2. package/README.md +225 -73
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +20 -15
  6. package/dist/account-pool.js +51 -97
  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-files.d.ts +19 -0
  16. package/dist/agent-config-files.js +70 -1
  17. package/dist/agent-config-return.d.ts +194 -0
  18. package/dist/agent-config-return.js +724 -0
  19. package/dist/agent-config-sync.d.ts +10 -2
  20. package/dist/agent-config-sync.js +45 -51
  21. package/dist/agent-credentials.js +7 -3
  22. package/dist/agent-repository.d.ts +16 -3
  23. package/dist/agent-repository.js +149 -74
  24. package/dist/agent-setup.d.ts +14 -3
  25. package/dist/agent-setup.js +78 -36
  26. package/dist/agent-target-commands.js +24 -13
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +104 -55
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-forward.d.ts +2 -0
  35. package/dist/cli-proxy-forward.js +9 -2
  36. package/dist/cli-proxy-hub.d.ts +69 -25
  37. package/dist/cli-proxy-hub.js +231 -54
  38. package/dist/cli-proxy.d.ts +70 -1
  39. package/dist/cli-proxy.js +298 -32
  40. package/dist/client-settings.d.ts +39 -0
  41. package/dist/client-settings.js +91 -0
  42. package/dist/codex-cloud.d.ts +5 -0
  43. package/dist/codex-cloud.js +88 -17
  44. package/dist/command-center-actions.js +5 -1
  45. package/dist/commands.js +171 -22
  46. package/dist/conversation-commands.d.ts +5 -0
  47. package/dist/conversation-commands.js +8 -5
  48. package/dist/conversation-terminal.d.ts +10 -2
  49. package/dist/conversation-terminal.js +81 -15
  50. package/dist/conversations.d.ts +77 -1
  51. package/dist/conversations.js +257 -23
  52. package/dist/device-bridge.d.ts +1 -1
  53. package/dist/device-bridge.js +6 -4
  54. package/dist/device-helper.js +52 -4
  55. package/dist/errors.d.ts +34 -1
  56. package/dist/errors.js +56 -5
  57. package/dist/filesystem-commands.d.ts +22 -3
  58. package/dist/filesystem-commands.js +132 -16
  59. package/dist/format.d.ts +2 -1
  60. package/dist/format.js +10 -1
  61. package/dist/handlers.js +4 -1
  62. package/dist/initial-prompt.d.ts +8 -0
  63. package/dist/initial-prompt.js +43 -1
  64. package/dist/large-media.d.ts +14 -0
  65. package/dist/large-media.js +100 -0
  66. package/dist/linux-terminal.d.ts +5 -3
  67. package/dist/linux-terminal.js +6 -20
  68. package/dist/local-sessions.d.ts +26 -2
  69. package/dist/local-sessions.js +113 -24
  70. package/dist/machine-setup.d.ts +190 -0
  71. package/dist/machine-setup.js +849 -0
  72. package/dist/machine-status.d.ts +67 -0
  73. package/dist/machine-status.js +140 -0
  74. package/dist/main.js +23 -13
  75. package/dist/move-worktrees-restore.py +144 -0
  76. package/dist/move-worktrees.d.ts +63 -0
  77. package/dist/move-worktrees.js +201 -0
  78. package/dist/move.d.ts +10 -1
  79. package/dist/move.js +116 -16
  80. package/dist/onboarding.d.ts +11 -7
  81. package/dist/onboarding.js +59 -27
  82. package/dist/prepared-repository.d.ts +202 -0
  83. package/dist/prepared-repository.js +860 -0
  84. package/dist/prepared-repository.py +1306 -0
  85. package/dist/project-preparation.d.ts +48 -0
  86. package/dist/project-preparation.js +304 -0
  87. package/dist/project-sync-manifest.d.ts +4 -0
  88. package/dist/project-sync-manifest.js +5 -1
  89. package/dist/project-sync-transfer.js +1 -1
  90. package/dist/project-sync.d.ts +19 -4
  91. package/dist/project-sync.js +31 -8
  92. package/dist/project-template-setup.d.ts +33 -1
  93. package/dist/project-template-setup.js +59 -2
  94. package/dist/secret-ask.d.ts +72 -0
  95. package/dist/secret-ask.js +351 -0
  96. package/dist/seed-phases.d.ts +13 -3
  97. package/dist/seed-phases.js +21 -3
  98. package/dist/setup-cache.d.ts +7 -0
  99. package/dist/setup-cache.js +32 -0
  100. package/dist/t3-launch.js +1 -4
  101. package/dist/t3-mode.d.ts +8 -2
  102. package/dist/t3-mode.js +19 -4
  103. package/dist/t3-phone.d.ts +4 -2
  104. package/dist/t3-phone.js +12 -4
  105. package/dist/terminal-apps.d.ts +157 -0
  106. package/dist/terminal-apps.js +561 -0
  107. package/dist/terminal-onboarding.d.ts +2 -0
  108. package/dist/terminal-onboarding.js +11 -1
  109. package/dist/this-computer.d.ts +11 -0
  110. package/dist/this-computer.js +36 -0
  111. package/dist/thread-on-computer.js +4 -1
  112. package/dist/update.js +11 -3
  113. package/dist/worktree-twin.d.ts +10 -2
  114. package/dist/worktree-twin.js +27 -9
  115. package/package.json +5 -5
package/dist/browser.js CHANGED
@@ -15,25 +15,41 @@ export function openerFor(url, env, platform = process.platform) {
15
15
  return { command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] };
16
16
  return { command: 'xdg-open', args: [url] };
17
17
  }
18
- /** Starts the browser detached and returns whether it could be started; only http(s) URLs are opened. */
19
- export function openUrl(url, env) {
18
+ /** How long an opener that has not exited counts as a browser that started in the foreground ($BROWSER=firefox, or
19
+ * xdg-open running one): opened. xdg-open hands a link to the desktop and exits well within it. */
20
+ export const OPENER_SETTLE_MS = 3000;
21
+ /**
22
+ * Starts the browser detached and resolves whether it took the URL: false when the opener cannot start (no xdg-open:
23
+ * ENOENT) or exits with a failure (xdg-open exits 3 or 4 when nothing on the desktop opens the link, `open` 1), so the
24
+ * caller says to open the printed link; true when it exits 0 or is still running after `settleMs`. Only http(s) URLs
25
+ * are opened.
26
+ */
27
+ export function openUrl(url, env, options = {}) {
20
28
  let u;
21
29
  try {
22
30
  u = new URL(url);
23
31
  }
24
32
  catch {
25
- return false;
33
+ return Promise.resolve(false);
26
34
  }
27
35
  if (u.protocol !== 'https:' && u.protocol !== 'http:')
28
- return false;
29
- const { command, args } = openerFor(u.toString(), env);
30
- try {
31
- const child = spawn(command, args, { stdio: 'ignore', detached: true, env });
32
- child.on('error', () => undefined);
33
- child.unref();
34
- return true;
35
- }
36
- catch {
37
- return false;
38
- }
36
+ return Promise.resolve(false);
37
+ const { command, args } = openerFor(u.toString(), env, options.platform);
38
+ return new Promise((resolve) => {
39
+ let timer;
40
+ const done = (opened) => {
41
+ clearTimeout(timer);
42
+ resolve(opened);
43
+ };
44
+ try {
45
+ const child = spawn(command, args, { stdio: 'ignore', detached: true, env });
46
+ child.once('error', () => done(false));
47
+ child.once('exit', (code) => done(code === 0));
48
+ child.unref();
49
+ timer = setTimeout(() => done(true), options.settleMs ?? OPENER_SETTLE_MS);
50
+ }
51
+ catch {
52
+ done(false);
53
+ }
54
+ });
39
55
  }
@@ -1,5 +1,7 @@
1
1
  import type { AgentCredentialMetadata, CellClient, Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
+ /** Someone types into this terminal: not --json, a TTY with raw mode, and not a coding agent's shell. */
4
+ export declare function claudeTerminal(ctx: Ctx): boolean;
3
5
  export declare function requireClaudeTerminal(ctx: Ctx): void;
4
6
  export declare function cloudClaudeLogin(ctx: Ctx): Promise<AgentCredentialMetadata | undefined>;
5
7
  /** Parse only complete provider authorize links, including the native OSC-8 link. */
@@ -21,6 +23,26 @@ export declare function runWorkspaceLogin(ctx: Ctx, ws: Workspace, cell: CellCli
21
23
  cwd: string;
22
24
  urls: (output: string) => string[];
23
25
  }): Promise<boolean>;
24
- /** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
25
- export declare function loginClaudeInWorkspace(ctx: Ctx, ws: Workspace): Promise<AgentCredentialMetadata>;
26
+ /**
27
+ * The same sign-in without an attached terminal (0.20.1+: a coding agent's shell, --json, no TTY). The workspace PTY
28
+ * runs the login exactly as runWorkspaceLogin does; shard reads its output, opens the claude.ai link it prints, and
29
+ * opens a page on this computer where the person pastes the code claude.ai shows (the agent never sees it). The code
30
+ * and Enter go into the PTY; the page says "Signed in" once the login exited 0. True when it did.
31
+ */
32
+ export declare function runWorkspaceLoginHeadless(ctx: Ctx, cell: CellClient, login: {
33
+ session: string;
34
+ argv: string[];
35
+ env: Record<string, string>;
36
+ cwd: string;
37
+ urls: (output: string) => string[];
38
+ }, timeoutMs: number): Promise<boolean>;
39
+ /**
40
+ * A transient Linux profile holds the initial pair; it is removed before an agent starts. `headless` (0.20.1+): the
41
+ * sign-in runs through the pages shard opens (runWorkspaceLoginHeadless) instead of an attached terminal.
42
+ */
43
+ export declare function loginClaudeInWorkspace(ctx: Ctx, ws: Workspace, opts?: {
44
+ headless?: {
45
+ timeoutMs: number;
46
+ };
47
+ }): Promise<AgentCredentialMetadata>;
26
48
  export declare function ensureCloudClaude(ctx: Ctx, ws: Workspace): Promise<void>;
@@ -1,15 +1,21 @@
1
+ import { StringDecoder } from 'node:string_decoder';
1
2
  import { isDeepStrictEqual } from 'node:util';
2
3
  import { ShardfluxApiError } from '@shardflux/sdk';
3
- import { UsageError } from "./errors.js";
4
+ import { CodedError, UsageError } from "./errors.js";
4
5
  import { openUrl } from "./browser.js";
5
- import { attachTerminal, cellConnector, PtyOutputCursor, workspaceAsleep } from "./agent-terminal.js";
6
+ import { attachTerminal, cellConnector, PtyOutputCursor, reattachDelayMs, retryableAttach, workspaceAsleep } from "./agent-terminal.js";
6
7
  import { codingAgentShell } from "./agent-setup.js";
7
- const NEED_TERMINAL = 'Claude sign-in needed: run shard agents credentials login claude in a terminal, once for the account (a claude.ai link, then its code).';
8
- const NEED_TERMINAL_HINT = 'a coding agent asks its user to run it in their own terminal, then checks it with shard setup --inspect --json --apps claude';
8
+ import { announce, askInBrowser, openInBrowser } from "./secret-ask.js";
9
+ const NEED_TERMINAL = 'Claude sign-in needed: run shard agents credentials login claude, once for the account (it opens the claude.ai sign-in and a page for its code).';
10
+ const NEED_TERMINAL_HINT = 'a coding agent runs shard agents credentials login claude --json itself; its user signs in and pastes the code into the page it opens';
9
11
  const VERSION = '2.1.289';
10
- export function requireClaudeTerminal(ctx) {
12
+ /** Someone types into this terminal: not --json, a TTY with raw mode, and not a coding agent's shell. */
13
+ export function claudeTerminal(ctx) {
11
14
  // A coding agent's shell (0.17.1+) may be a PTY, but the person who reads the code is not typing into it.
12
- if (ctx.json || !ctx.io.stdin.isTTY || !ctx.io.stdin.setRawMode || !ctx.io.stdin.on || codingAgentShell(ctx.io.env))
15
+ return !ctx.json && ctx.io.stdin.isTTY === true && typeof ctx.io.stdin.setRawMode === 'function' && typeof ctx.io.stdin.on === 'function' && !codingAgentShell(ctx.io.env);
16
+ }
17
+ export function requireClaudeTerminal(ctx) {
18
+ if (!claudeTerminal(ctx))
13
19
  throw new UsageError(NEED_TERMINAL, NEED_TERMINAL_HINT);
14
20
  }
15
21
  export async function cloudClaudeLogin(ctx) {
@@ -109,7 +115,7 @@ export async function runWorkspaceLogin(ctx, ws, cell, login) {
109
115
  for (const url of login.urls(output))
110
116
  if (!opened.has(url)) {
111
117
  opened.add(url);
112
- openUrl(url, ctx.io.env);
118
+ void openUrl(url, ctx.io.env);
113
119
  }
114
120
  }
115
121
  yield message;
@@ -121,9 +127,141 @@ export async function runWorkspaceLogin(ctx, ws, cell, login) {
121
127
  const ended = await cell.pty.get(login.session);
122
128
  return code === 0 && ended.state === 'exited' && ended.exit_code === 0;
123
129
  }
124
- /** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
125
- export async function loginClaudeInWorkspace(ctx, ws) {
126
- requireClaudeTerminal(ctx);
130
+ /** How long Claude may take to exchange the pasted code and exit. */
131
+ const CODE_EXCHANGE_MS = 60_000;
132
+ /**
133
+ * The same sign-in without an attached terminal (0.20.1+: a coding agent's shell, --json, no TTY). The workspace PTY
134
+ * runs the login exactly as runWorkspaceLogin does; shard reads its output, opens the claude.ai link it prints, and
135
+ * opens a page on this computer where the person pastes the code claude.ai shows (the agent never sees it). The code
136
+ * and Enter go into the PTY; the page says "Signed in" once the login exited 0. True when it did.
137
+ */
138
+ export async function runWorkspaceLoginHeadless(ctx, cell, login, timeoutMs) {
139
+ await cell.pty.open({ session_id: login.session, argv: login.argv, env: login.env, cwd: login.cwd, rows: 24, cols: 100 });
140
+ const deadline = Date.now() + timeoutMs;
141
+ const stop = new AbortController();
142
+ const signal = AbortSignal.any([ctx.signal, stop.signal]);
143
+ // The PTY ended (or could not be read) before the code went in: the page closes with that.
144
+ const ended = new AbortController();
145
+ const connect = cellConnector(cell, login.session);
146
+ const cursor = new PtyOutputCursor();
147
+ const decoder = new StringDecoder('utf8');
148
+ const waiters = new Set();
149
+ const notify = () => { for (const wake of waiters)
150
+ wake(); waiters.clear(); };
151
+ let output = '';
152
+ let exit;
153
+ let failure;
154
+ let reading = true;
155
+ let codeSent = false;
156
+ const early = () => new CodedError('claude_sign_in_ended', 'The Claude sign-in ended before its code arrived.', 'run shard agents credentials login claude again');
157
+ const reader = (async () => {
158
+ let failures = 0;
159
+ while (!signal.aborted && exit === undefined) {
160
+ let connection;
161
+ try {
162
+ connection = await connect(cursor.offset, signal);
163
+ for await (const message of connection.messages()) {
164
+ failures = 0;
165
+ const bytes = cursor.consume(message);
166
+ if (bytes?.length)
167
+ output = (output + decoder.write(bytes)).slice(-16384);
168
+ if (message.type === 'exit') {
169
+ exit = message.session?.exit_code ?? null;
170
+ if (!codeSent)
171
+ ended.abort(early());
172
+ }
173
+ notify();
174
+ if (exit !== undefined || signal.aborted)
175
+ return;
176
+ }
177
+ }
178
+ catch (error) {
179
+ if (signal.aborted)
180
+ return;
181
+ if (!retryableAttach(error))
182
+ throw error;
183
+ failures += 1;
184
+ await new Promise((resolve) => {
185
+ const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
186
+ const timer = setTimeout(done, reattachDelayMs(error, failures));
187
+ signal.addEventListener('abort', done, { once: true });
188
+ });
189
+ }
190
+ finally {
191
+ await connection?.close().catch(() => undefined);
192
+ }
193
+ }
194
+ })().catch((error) => {
195
+ failure = error;
196
+ if (!codeSent)
197
+ ended.abort(error instanceof Error ? error : early());
198
+ }).finally(() => { reading = false; notify(); });
199
+ /** Until `ready()`, at most `ms`: false when it did not happen (time out, or the reader stopped). */
200
+ const until = async (ready, ms) => {
201
+ const end = Date.now() + ms;
202
+ for (;;) {
203
+ if (ready())
204
+ return true;
205
+ if (failure !== undefined)
206
+ throw failure instanceof Error ? failure : new Error('The Claude sign-in terminal failed.');
207
+ if (!reading || Date.now() >= end)
208
+ return false;
209
+ await new Promise((resolve) => {
210
+ const timer = setTimeout(wake, end - Date.now());
211
+ function wake() { clearTimeout(timer); waiters.delete(wake); resolve(); }
212
+ waiters.add(wake);
213
+ });
214
+ }
215
+ };
216
+ try {
217
+ await until(() => login.urls(output).length > 0 || exit !== undefined, deadline - Date.now());
218
+ const url = login.urls(output)[0];
219
+ if (url === undefined)
220
+ return false;
221
+ const opened = await openInBrowser(ctx, url);
222
+ announce(ctx, { event: 'open', url, opened }, opened ? `Opened the Claude sign-in in your browser (or open: ${url})` : `Open the Claude sign-in: ${url}`);
223
+ await askInBrowser(ctx, {
224
+ kind: 'code',
225
+ title: 'Sign in to Claude',
226
+ purpose: 'Paste the code claude.ai shows after you sign in. Your coding agent never sees it.',
227
+ label: 'Code',
228
+ submit: 'Sign in',
229
+ done: 'Signed in to Claude',
230
+ failed: 'Not signed in',
231
+ link: { href: url, text: 'Open the Claude sign-in again' },
232
+ doing: 'to paste the Claude code',
233
+ event: 'ask',
234
+ maxBytes: 4096,
235
+ timeoutMs: Math.max(1_000, deadline - Date.now()),
236
+ signal: ended.signal,
237
+ // Once the code went in, the login either finishes or ends: there is nothing to retry on the page.
238
+ retry: () => false,
239
+ use: async (code) => {
240
+ if (exit !== undefined)
241
+ throw early();
242
+ codeSent = true;
243
+ await cell.pty.input(login.session, `${code}\r`);
244
+ if (!await until(() => exit !== undefined, CODE_EXCHANGE_MS))
245
+ throw new CodedError('claude_sign_in_timeout', 'Claude did not finish the sign-in within a minute of the code.', 'run shard agents credentials login claude again');
246
+ if (exit !== 0)
247
+ throw new CodedError('claude_code_refused', `Claude did not accept the code (exit ${exit ?? 'by signal'}).`, 'run shard agents credentials login claude again for a new code');
248
+ },
249
+ });
250
+ }
251
+ finally {
252
+ stop.abort();
253
+ await reader;
254
+ }
255
+ const session = await cell.pty.get(login.session);
256
+ return session.state === 'exited' && session.exit_code === 0;
257
+ }
258
+ /**
259
+ * A transient Linux profile holds the initial pair; it is removed before an agent starts. `headless` (0.20.1+): the
260
+ * sign-in runs through the pages shard opens (runWorkspaceLoginHeadless) instead of an attached terminal.
261
+ */
262
+ export async function loginClaudeInWorkspace(ctx, ws, opts = {}) {
263
+ if (!opts.headless)
264
+ requireClaudeTerminal(ctx);
127
265
  const api = ctx.cloud().agentCredentials;
128
266
  let claim;
129
267
  try {
@@ -147,7 +285,9 @@ export async function loginClaudeInWorkspace(ctx, ws) {
147
285
  const version = await cell.exec.run(['claude', '--version'], { env, cwd: root, signal: ctx.signal, timeoutMs: 10_000 });
148
286
  if (version.exitCode !== 0 || version.stdout.trim() !== `${VERSION} (Claude Code)`)
149
287
  throw new UsageError(`Cloud Claude sign-in requires Claude Code ${VERSION} in the workspace image.`);
150
- if (!await runWorkspaceLogin(ctx, ws, cell, { session, argv: ['claude', 'auth', 'login', '--claudeai'], env, cwd: root, urls: claudeLoginUrls }))
288
+ const login = { session, argv: ['claude', 'auth', 'login', '--claudeai'], env, cwd: root, urls: claudeLoginUrls };
289
+ const signedIn = opts.headless ? await runWorkspaceLoginHeadless(ctx, cell, login, opts.headless.timeoutMs) : await runWorkspaceLogin(ctx, ws, cell, login);
290
+ if (!signedIn)
151
291
  throw new UsageError('Claude cloud sign-in was interrupted. Run shard agents credentials login claude again.');
152
292
  const credential = JSON.parse(await cell.files.readText(`${root}/.claude/.credentials.json`));
153
293
  const config = JSON.parse(await cell.files.readText(`${root}/.claude/.claude.json`));
@@ -13,6 +13,8 @@ export interface ForwarderOptions {
13
13
  * for once, so a URL that stays invalid is answered as is.
14
14
  */
15
15
  renew?: () => Promise<void>;
16
+ /** Called as each request (or upgrade) arrives, before it is forwarded: the hub is about to be awake. */
17
+ onRequest?: () => void;
16
18
  host?: string;
17
19
  port?: number;
18
20
  /** How long one request may be retried for (default 60 s, plus the time a repair takes). */
@@ -97,8 +97,8 @@ export class CliProxyForwarder {
97
97
  this.dropIdleConnections();
98
98
  tick = now;
99
99
  }, 5_000).unref();
100
- this.#server = createServer((req, res) => void this.#handle(req, res));
101
- this.#server.on('upgrade', (req, socket, head) => void this.#upgrade(req, socket, head));
100
+ this.#server = createServer((req, res) => { this.#arrived(); void this.#handle(req, res); });
101
+ this.#server.on('upgrade', (req, socket, head) => { this.#arrived(); void this.#upgrade(req, socket, head); });
102
102
  // Long streams (SSE, Responses) must not be cut by the server's own timeouts.
103
103
  this.#server.requestTimeout = 0;
104
104
  this.#server.headersTimeout = 60_000;
@@ -327,6 +327,13 @@ export class CliProxyForwarder {
327
327
  delay = Math.min(delay * 2, 8000);
328
328
  }
329
329
  }
330
+ /** onRequest never fails or delays a request. */
331
+ #arrived() {
332
+ try {
333
+ this.#o.onRequest?.();
334
+ }
335
+ catch { /* the hook is advisory */ }
336
+ }
330
337
  async #handle(req, res) {
331
338
  if (pathOnly(req.url) === IDENTITY_PATH) {
332
339
  const info = this.#o.hub();
@@ -32,24 +32,20 @@ export declare const HUB_TEMPLATE = "codex-connector";
32
32
  /**
33
33
  * The codex-connector version a hub runs at least: the first with the hub's boot service and an sf-agent that serves
34
34
  * the VM port (cli-proxy-filter). New hubs are created on it; an older hub is upgraded in place (its home, and so its
35
- * logins and settings, stay) before VMs get the hub's address.
35
+ * logins and settings, stay) before VMs get the hub's address. A hub on a newer version keeps it (never downgraded).
36
36
  */
37
37
  export declare const HUB_TEMPLATE_VERSION = 4;
38
- /**
39
- * The codex-connector version whose sf-agent runs the account pool router: a hub set up for pooled accounts is created
40
- * on it, or upgraded to it. HUB_TEMPLATE_VERSION moves to it once it is registered in every environment.
41
- */
42
- export declare const POOL_HUB_TEMPLATE_VERSION = 5;
43
38
  export declare const HUB_CAPS: Caps;
44
39
  export declare const HUB_INFO_FILE = "cli-proxy.json";
45
40
  /** Paths in the hub workspace. */
46
41
  export declare const HUB_DIR = "/home/user/.cli-proxy-hub";
47
42
  export declare const HUB_AUTH_DIR = "/home/user/.cli-proxy-api";
48
- /** Pool mode: the router reads the API URL and the hub token (`sfh_…`) from these hub files (0600). */
49
- export declare const HUB_POOL_FILES: {
50
- readonly apiUrl: "/home/user/.cli-proxy-hub/api-url";
51
- readonly apiToken: "/home/user/.cli-proxy-hub/api-token";
52
- };
43
+ /**
44
+ * Each provider's active login: `{ "<provider type>": "<auth file, relative to the auth directory>" }` (0600). The one
45
+ * login of each provider that stays enabled in the hub; every other login of that provider is kept there with
46
+ * `"disabled": true`. Written by the CLI and by the hub's sf-agent, both under the hub's `logins.lock`.
47
+ */
48
+ export declare const HUB_ACTIVE_FILE = "/home/user/.cli-proxy-hub/active.json";
53
49
  export interface HubInfo {
54
50
  version: 1;
55
51
  api_url: string;
@@ -112,7 +108,10 @@ export interface HubWorkspace {
112
108
  write(path: string, data: string, mode: '0600' | '0700'): Promise<void>;
113
109
  /** Names in a directory, or null when it does not exist. */
114
110
  list(dir: string): Promise<string[] | null>;
115
- /** The hub's auth files, as paths relative to its auth directory (subdirectories included, as CLIProxyAPI reads them). */
111
+ /**
112
+ * The hub's auth files, as paths relative to its auth directory (subdirectories included, as CLIProxyAPI reads them),
113
+ * oldest first: by modification time, then by path.
114
+ */
116
115
  authFiles(): Promise<string[]>;
117
116
  /** Exposes the port (idempotent): when its callback URL was created, null without one. */
118
117
  exposePort(port: number): Promise<{
@@ -167,6 +166,8 @@ export declare function sdkHubCloud(cloud: Shardflux, opts?: {
167
166
  agentLabel?: string;
168
167
  homeProject?: (organizationId: string) => Promise<HomeProject | null>;
169
168
  }): HubCloud;
169
+ /** The hub's auth files, oldest first (modification time, then path in byte order), one relative path per line. */
170
+ export declare const AUTH_FILES_SCRIPT = "set -euo pipefail\n[ -d /home/user/.cli-proxy-api ] || exit 0; cd /home/user/.cli-proxy-api\nfind . -type f -name '*.json' -printf '%T@ %P\\n' | LC_ALL=C sort -t ' ' -k1,1n -k2 | cut -d ' ' -f 2-\n";
170
171
  /** Installs the pinned release once: download, verify its SHA-256, then move the binary into place. */
171
172
  export declare const INSTALL_SCRIPT: string;
172
173
  /**
@@ -228,15 +229,6 @@ export interface EnsureOptions {
228
229
  progress?: (message: string) => void;
229
230
  /** Random management key (tests). */
230
231
  randomKey?: () => string;
231
- /**
232
- * Pool mode (account pool): the hub's router gets the API URL and a hub token, and CLIProxyAPI passes the providers'
233
- * rate-limit headers through so the router reads usage on every answer. `hubToken` mints a token for the hub
234
- * workspace (`POST /v1/agent-pool/hub-token`); it is called when the hub has none, or always with `renew`.
235
- */
236
- pool?: {
237
- hubToken: (workspaceId: string) => Promise<string>;
238
- renew?: boolean;
239
- };
240
232
  }
241
233
  export interface EnsureResult {
242
234
  info: HubInfo;
@@ -246,6 +238,8 @@ export interface EnsureResult {
246
238
  callbackReplaced: boolean;
247
239
  /** The config written now; null when the hub kept its own. */
248
240
  config: HubConfig | null;
241
+ /** The hub's logins, one active per provider (the others kept disabled). */
242
+ logins: HubLogin[];
249
243
  }
250
244
  /** The hub could not be made ready (exit 1, code `cli_proxy_hub`). */
251
245
  export declare class HubError extends CodedError {
@@ -258,11 +252,61 @@ export declare class HubError extends CodedError {
258
252
  */
259
253
  export declare function ensureCliProxyHub(cloud: Shardflux | HubCloud, opts: EnsureOptions): Promise<EnsureResult>;
260
254
  /**
261
- * The hub config with the providers' rate-limit headers passed through (`requests.passthrough-headers`, or the legacy
262
- * top-level key): CLIProxyAPI 8.0.23 records them per login only for non-streaming answers, so the pool router reads
263
- * them on its own answers. Returns the text unchanged when it already passes them.
255
+ * A login in the hub: one auth file. Only its provider, email and state are ever shown, never its tokens.
256
+ *
257
+ * CLIProxyAPI spreads requests across every enabled login of a provider (round-robin, fill-first) and moves to another
258
+ * one on a 429, so the hub keeps exactly one enabled per provider: the user's active login. Every other login of that
259
+ * provider stays in the hub with `"disabled": true` (CLIProxyAPI 8.0.23 never uses a disabled auth file) until the
260
+ * user makes it active (`shard cli-proxy use`).
261
+ */
262
+ export interface HubLogin {
263
+ /** Path relative to the auth directory. */
264
+ file: string;
265
+ /** The auth file's `type` (claude, codex, gemini…), lowercase; null when it has none. */
266
+ provider: string | null;
267
+ email: string | null;
268
+ /** The file carries `"disabled": true`. */
269
+ disabled: boolean;
270
+ /** The one login of its provider that serves requests. */
271
+ active: boolean;
272
+ }
273
+ /** Parses active.json: provider type → auth file. Anything else (missing, not JSON, other values) counts as empty. */
274
+ export declare function parseActive(text: string | null): Record<string, string>;
275
+ /**
276
+ * The hub's logins, oldest first, as they are (nothing changes): `active` marks the one active.json names while it is
277
+ * enabled. Files the retired account pool router left are not listed (they stay disabled).
278
+ */
279
+ export declare function listHubLogins(ws: HubWorkspace): Promise<HubLogin[]>;
280
+ /** The auth file's text with `disabled` set (true) or removed (false), every other field kept; null when unchanged. */
281
+ export declare function withDisabled(text: string, disabled: boolean): string | null;
282
+ /**
283
+ * Replaces one auth file atomically (0600, a temporary file in its folder then rename), only while it still holds what
284
+ * the device read ($2, its SHA-256): CLIProxyAPI rewrites a file when it refreshes the login, and a newer token must
285
+ * never be overwritten. The modification time stays, so the order of the hub's logins (oldest first) does not change.
286
+ * $1 is the path relative to the auth directory, the new content comes on standard input. Prints `written`, `changed`
287
+ * or `gone`.
288
+ */
289
+ export declare const SET_AUTH_SCRIPT = "set -euo pipefail\na=/home/user/.cli-proxy-api; rel=$1; want=$2\ncase \"$rel\" in ''|/*|*..*) echo \"not a path inside the auth folder\" >&2; exit 2 ;; esac\nf=\"$a/$rel\"\n[ -f \"$f\" ] || { echo gone; exit 0; }\ntmp=$(mktemp \"$(dirname \"$f\")/.writing.XXXXXX\"); trap 'rm -f \"$tmp\"' EXIT\nchmod 0600 \"$tmp\"; cat > \"$tmp\"; touch -r \"$f\" \"$tmp\"\nif [ \"$(sha256sum < \"$f\" | cut -d' ' -f1)\" != \"$want\" ]; then echo changed; exit 0; fi\nmv -f \"$tmp\" \"$f\"; trap - EXIT\necho written\n";
290
+ /** Writes active.json atomically (0600) from standard input. */
291
+ export declare const ACTIVE_SCRIPT = "set -euo pipefail\ndir=/home/user/.cli-proxy-hub; mkdir -p \"$dir\"; umask 077\ntmp=$(mktemp \"$dir/.active.XXXXXX\"); trap 'rm -f \"$tmp\"' EXIT\ncat > \"$tmp\"; mv -f \"$tmp\" \"$dir/active.json\"; trap - EXIT\necho written\n";
292
+ /**
293
+ * Keeps exactly one login enabled per provider (call it under the hub's `logins` lock, so two computers do not race):
294
+ * `prefer[provider]` when given, else the one active.json names while it is present and enabled, else the oldest.
295
+ * active.json records the choice first, then every other login of that provider is disabled (all other fields kept), then
296
+ * the chosen one is enabled. Files without a `type` are left alone; the retired pool router's files are
297
+ * disabled and never chosen. Returns the logins as they are now, oldest first (without the pool router's).
298
+ */
299
+ export declare function enforceActiveLogins(ws: HubWorkspace, prefer?: Record<string, string>): Promise<HubLogin[]>;
300
+ /**
301
+ * One login per provider on a hub that may predate it, without waking the hub: a hub that is not running is left
302
+ * alone (it is checked again later). The forwarder on every connected computer runs this at start and every 15
303
+ * minutes, so a hub set up before this rule stops spreading requests across logins without anyone running a command.
264
304
  */
265
- export declare function poolHubConfig(text: string): string;
305
+ export declare function enforceHubLoginsIfRunning(hub: HubCloud, workspaceId: string): Promise<'enforced' | 'asleep' | 'missing'>;
306
+ /** Removes the router's files; prints `retired` when its hub token was there (its sf-agent then starts again without it). */
307
+ export declare const RETIRE_POOL_SCRIPT: string;
308
+ /** The config without the router's own client key (`api-keys`, or v8 `access.api-keys`); the text itself when absent. */
309
+ export declare function withoutClientKey(text: string, key: string): string;
266
310
  /** The callback URL as the hub stores it (`callback.json`, 0600): its secret is shown only when it is created. */
267
311
  export interface StoredCallback {
268
312
  callback_url: string;