@shardflux/cli 0.18.0-next.4 → 0.18.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 (42) hide show
  1. package/CHANGELOG.md +61 -8
  2. package/README.md +92 -33
  3. package/dist/account-args.js +5 -3
  4. package/dist/account-commands.d.ts +4 -1
  5. package/dist/account-commands.js +104 -14
  6. package/dist/account-pool.d.ts +31 -0
  7. package/dist/account-pool.js +313 -0
  8. package/dist/agent-setup.d.ts +38 -0
  9. package/dist/agent-setup.js +116 -0
  10. package/dist/agent-target-commands.js +10 -1
  11. package/dist/agent-targets.d.ts +11 -0
  12. package/dist/agent-targets.js +22 -4
  13. package/dist/args.d.ts +3 -1
  14. package/dist/args.js +50 -5
  15. package/dist/claude-cloud-login.d.ts +11 -0
  16. package/dist/claude-cloud-login.js +49 -39
  17. package/dist/cli-proxy-hub.d.ts +26 -1
  18. package/dist/cli-proxy-hub.js +60 -4
  19. package/dist/commands.d.ts +3 -0
  20. package/dist/commands.js +7 -3
  21. package/dist/conversation-commands.d.ts +5 -0
  22. package/dist/conversation-commands.js +8 -2
  23. package/dist/conversations.d.ts +9 -0
  24. package/dist/desktop-linux.js +6 -5
  25. package/dist/filesystem-commands.d.ts +12 -2
  26. package/dist/filesystem-commands.js +46 -18
  27. package/dist/git-remote-bin.js +0 -0
  28. package/dist/handlers.js +2 -1
  29. package/dist/input.d.ts +4 -2
  30. package/dist/input.js +6 -3
  31. package/dist/main.js +19 -8
  32. package/dist/menubar.js +1 -1
  33. package/dist/t3-asar.d.ts +12 -2
  34. package/dist/t3-asar.js +14 -5
  35. package/dist/t3-launch.js +4 -1
  36. package/dist/t3-linux.d.ts +12 -13
  37. package/dist/t3-linux.js +81 -87
  38. package/dist/t3-mode.d.ts +4 -3
  39. package/dist/t3-mode.js +15 -14
  40. package/dist/t3-version.d.ts +8 -0
  41. package/dist/t3-version.js +42 -0
  42. package/package.json +21 -7
package/dist/args.js CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
15
  import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
16
- export const CLI_VERSION = '0.18.0-next.4';
16
+ export const CLI_VERSION = '0.18.0';
17
17
  export const GLOBAL_OPTIONS = {
18
18
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
19
19
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -173,6 +173,9 @@ const KEY_COMMANDS = [
173
173
  } },
174
174
  { path: [level, 'rollback'], summary: `Restore a saved ${level} version`, positionals: [], options: { to: { type: 'string', value: '<version>', description: 'Version to restore (default previous saved version).' } } },
175
175
  ]),
176
+ { path: ['machine', 'open'], summary: 'Create or resume your machine VM without attaching to it, and print its workspace key (0.17.1+)', positionals: [], options: {},
177
+ description: 'For an agent building your machine from a terminal it does not attach to: install with "shard ws exec <key> -- bash -lc ...", write dotfiles and secret files with "shard files write <key> <path> --from -" (contents on standard input), then "shard machine save --wait". The first open copies your Claude and Codex settings into it, as "shard machine" does.',
178
+ examples: ['shard machine open --json', "shard ws exec \"$(shard machine open)\" -- bash -lc 'sudo apt-get install -y ripgrep'", 'shard machine save --wait --json'] },
176
179
  { path: ['agents', 'list'], summary: 'List coding agent sessions.', positionals: [], options: {
177
180
  ...PAGE,
178
181
  state: { type: 'string', value: '<working|waiting|exited>', description: 'Agent state filter.' },
@@ -1181,6 +1184,25 @@ const AGENT_CREDENTIAL_COMMANDS = [
1181
1184
  ...['sync', 'status', 'forget'].map((action) => ({ path: ['agents', 'credentials', action], summary: `${action} Codex copies and the account's cloud Claude login (contents never printed)`, positionals: [], options: {} })),
1182
1185
  { path: ['agents', 'credentials', 'login'], summary: 'Sign in once to cloud Claude for this account', positionals: [{ name: 'app', required: true }], options: { workspace: { type: 'string', value: '<id|key>', description: 'Use this workspace for the cloud sign-in.' } } },
1183
1186
  ];
1187
+ const ACCOUNT_PROVIDER = { type: 'string', value: '<claude|codex>', description: 'Only this provider’s accounts.' };
1188
+ /** Claude and ChatGPT/Codex accounts with their usage windows, resets and threads (0.18.0+). */
1189
+ const AGENT_ACCOUNT_COMMANDS = [
1190
+ { path: ['agents', 'accounts'], summary: 'Claude and ChatGPT/Codex accounts with usage, resets and status (0.18.0+)', positionals: [], options: { provider: ACCOUNT_PROVIDER },
1191
+ examples: ['shard agents accounts', 'shard agents accounts usage', 'shard agents accounts add claude', 'pbpaste | shard agents accounts add codex --api-key-stdin'] },
1192
+ { path: ['agents', 'accounts', 'list'], summary: 'List the accounts: status, usage windows and when they reset (0.18.0+)', positionals: [], options: { provider: ACCOUNT_PROVIDER } },
1193
+ { path: ['agents', 'accounts', 'usage'], summary: 'Every usage window of every account, with its reset time (0.18.0+)', positionals: [], options: { provider: ACCOUNT_PROVIDER } },
1194
+ { path: ['agents', 'accounts', 'add'], summary: 'Sign in a Claude or ChatGPT account, or add an API key (0.18.0+)', positionals: [{ name: 'provider', required: true }], options: {
1195
+ 'api-key-stdin': { type: 'boolean', description: 'Add an API key read from standard input instead of signing in.' },
1196
+ label: { type: 'string', value: '<name>', description: 'The account’s name in lists (default its email).' },
1197
+ scope: { type: 'string', value: '<personal|team>', description: 'personal: only your threads use it; team: any member’s (default: team for team plans and API keys, else personal).' },
1198
+ workspace: { type: 'string', value: '<id|key>', description: 'Run the sign-in in this workspace (default your machine VM, from any folder).' },
1199
+ } },
1200
+ { path: ['agents', 'accounts', 'remove'], summary: 'Remove an account and its login (0.18.0+)', positionals: [{ name: 'account', required: true }], options: {} },
1201
+ { path: ['agents', 'accounts', 'move'], summary: 'Move an account up or down in its provider’s order (0.18.0+)', positionals: [{ name: 'account', required: true }, { name: 'direction', required: true }], options: {} },
1202
+ { path: ['agents', 'accounts', 'pool'], hidden: true, summary: 'Pool accounts: each new thread on the account with the most room', positionals: [{ name: 'state', required: true }], options: {} },
1203
+ ];
1204
+ /** `shard accounts …`: the same commands, as the menu bar and the design name them. */
1205
+ const ACCOUNT_ALIASES = AGENT_ACCOUNT_COMMANDS.map(c => ({ ...c, path: c.path.slice(1), hidden: true }));
1184
1206
  const COMPUTER_OPTION = { type: 'boolean', description: 'Give the new conversation a desktop with a browser its agent drives (computer use).' };
1185
1207
  const AGENT_OPTIONS = {
1186
1208
  prompt: { type: 'string', value: '<text>', description: 'First user message for a new conversation.' },
@@ -1227,6 +1249,8 @@ function projectExample(rest, app) {
1227
1249
  }
1228
1250
  export const ONBOARDING_COMMAND = { path: ['<onboarding>'], summary: 'Set up Shardflux in your terminal', positionals: [], options: { 'accept-terms': { type: 'boolean', description: 'Accept the Terms of Service and Privacy Policy.' }, apps: { type: 'string', value: '<claude,codex>', description: 'Connect Claude, Codex or both.' } } };
1229
1251
  export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud agent command center', positionals: [], options: {} };
1252
+ /** Bare `shard` without a terminal, or in a coding agent's shell (0.17.1+): the setup question and both paths' steps. */
1253
+ export const GUIDE_COMMAND = { path: ['<guide>'], summary: 'Show how to set up Shardflux with a coding agent', positionals: [], options: {} };
1230
1254
  const AGENT_COMMANDS = [
1231
1255
  { path: ['device', 'requests'], summary: 'Review local device requests waiting for approval', positionals: [], options: {} },
1232
1256
  ...['approve', 'deny'].map((action) => ({ path: ['device', action], summary: `${action} a local device request`, positionals: [{ name: 'id', required: true }], options: action === 'approve' ? { always: { type: 'boolean', description: 'Allow matching requests for this project; never for outside-folder access.' } } : {} })),
@@ -1289,7 +1313,7 @@ const MENUBAR_COMMANDS = ['start', 'stop', 'status'].map(action => ({
1289
1313
  path: ['menubar', action], summary: `${action === 'start' ? 'Install and start' : action === 'stop' ? 'Stop' : 'Show'} the macOS menu bar app`,
1290
1314
  positionals: [], options: { login: { type: 'string', value: '<on|off>', description: 'Enable or disable launch at login.' } },
1291
1315
  }));
1292
- export const COMMANDS = [...ACCOUNT_COMMANDS.filter((c) => c.path[0] !== 'templates'), ...AGENT_COMMANDS, ...AGENT_CREDENTIAL_COMMANDS, ...CLI_PROXY_COMMANDS, ...MENUBAR_COMMANDS, ...KEY_COMMANDS, ...ACCOUNT_COMMANDS.filter((c) => c.path[0] === 'templates')];
1316
+ export const COMMANDS = [...ACCOUNT_COMMANDS.filter((c) => c.path[0] !== 'templates'), ...AGENT_COMMANDS, ...AGENT_CREDENTIAL_COMMANDS, ...AGENT_ACCOUNT_COMMANDS, ...ACCOUNT_ALIASES, ...CLI_PROXY_COMMANDS, ...MENUBAR_COMMANDS, ...KEY_COMMANDS, ...ACCOUNT_COMMANDS.filter((c) => c.path[0] === 'templates')];
1293
1317
  const GROUPS = {
1294
1318
  device: 'Local device approvals',
1295
1319
  'cli-proxy': 'Your hosted CLIProxyAPI, reachable at http://127.0.0.1:8317 on this computer and in every VM',
@@ -1534,8 +1558,22 @@ export function parseCommandLine(argv, options = {}) {
1534
1558
  const path = rest.find((r, i) => !r.startsWith('-') && !GLOBAL_VALUE_FLAGS.has(rest[i - 1] ?? ''));
1535
1559
  if (path !== undefined && (/^(?:\.|\/|~\/)/.test(path) || options.isProjectPath?.(path)))
1536
1560
  command = PROJECT_COMMAND;
1537
- else if (path === undefined && !wantsHelp && !head.includes('--version') && !head.includes('-V') && ((options.platform ?? process.platform) === 'linux' || options.tty || head.includes('--json')))
1538
- command = (options.platform ?? process.platform) === 'linux' && !head.includes('--json') ? ONBOARDING_COMMAND : CENTER_COMMAND;
1561
+ else if (path === undefined && !wantsHelp && !head.includes('--version') && !head.includes('-V')) {
1562
+ const linux = (options.platform ?? process.platform) === 'linux';
1563
+ // Linux's terminal onboarding takes --accept-terms and --apps; without them and without a terminal (or in a
1564
+ // coding agent's shell, where options.tty is false) bare shard prints the setup guide (0.17.1+).
1565
+ const onboarding = linux && head.some((a) => /^--(accept-terms|apps)(=|$)/.test(a));
1566
+ // The guide takes the global options only: anything else keeps its "unknown option" error.
1567
+ const globalsOnly = rest.every((a) => !a.startsWith('-') || a === '-h' || a === '-V' || (/^--(?:no-)?([a-z-]+)(?:=|$)/.exec(a)?.[1] ?? '') in GLOBAL_OPTIONS);
1568
+ if (head.includes('--json'))
1569
+ command = CENTER_COMMAND;
1570
+ else if (onboarding || (linux && options.tty))
1571
+ command = ONBOARDING_COMMAND;
1572
+ else if (options.tty)
1573
+ command = CENTER_COMMAND;
1574
+ else if (globalsOnly)
1575
+ command = GUIDE_COMMAND;
1576
+ }
1539
1577
  }
1540
1578
  if (!command) {
1541
1579
  if (words.length === 0) {
@@ -1681,7 +1719,7 @@ export const EXIT_CODE_HELP = [
1681
1719
  /** The top help's sections: every command's group (first word) is in exactly one (args.test.ts checks). */
1682
1720
  export const HELP_SECTIONS = [
1683
1721
  { title: 'Account (a user session: shard signup or shard auth login)', groups: ['signup', 'verify', 'auth', 'setup', 'env', 'orgs', 'projects', 'api-keys', 'members', 'invitations', 'billing', 'audit', 'account'] },
1684
- { title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'machine', 'template', 'agents', 'device', 'cli-proxy', 'ssh-proxy'] },
1722
+ { title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'machine', 'template', 'agents', 'accounts', 'device', 'cli-proxy', 'ssh-proxy'] },
1685
1723
  { title: 'Workspaces for your app (a project API key: SHARDFLUX_API_KEY, else the one shard setup saved)', groups: ['login', 'whoami', 'status', 'usage', 'workspaces', 'files', 'operations', 'executions', 'secrets', 'credentials', 'volumes', 'egress'] },
1686
1724
  { title: 'Templates for your app’s workspaces (not for cloud agents; publish and archive use the session, the rest the API key)', groups: ['templates'] },
1687
1725
  { title: 'Other', groups: ['menubar', 'update', 'feedback', 'version'] },
@@ -1696,12 +1734,19 @@ export function topHelp() {
1696
1734
  ' shard <path> [claude|codex] [--approvals]',
1697
1735
  'Terminal: detach with Ctrl-] then d; /exit ends the agent session.',
1698
1736
  '',
1737
+ 'Setting Shardflux up with a coding agent? Ask your user one question first: is it for a SaaS platform (their',
1738
+ 'product runs VMs for its own customers through the API, SDK or MCP) or for their personal setup (their own cloud',
1739
+ 'coding agents: each Claude or Codex conversation gets its own VM)? "shard setup --guide" prints the steps of both',
1740
+ '(0.17.1+; --json for a list); so does "shard" in a shell without a terminal.',
1741
+ '',
1699
1742
  'Feedback: shard feedback [options] <message...>',
1700
1743
  ...FEEDBACK_WHY.map((l) => ` ${l}`),
1701
1744
  ' shard feedback --category bug --request-id req-5c7744b2bb5c457b86b76101 --error-code template_not_found "ws open --template python-node failed; expected a suggestion of the closest slug"',
1702
1745
  ' shard feedback --category idea "user would like shard ws open to remember the last template they used"',
1703
1746
  '',
1704
1747
  'Start here (everything the web app does, from a terminal; a person only opens the emailed link and pays in Checkout):',
1748
+ ' shard signup --accept-terms # account, session and API key with this computer’s Codex login',
1749
+ ' shard auth login --browser # or approve this computer in the browser (--no-wait: print the link, return)',
1705
1750
  ` printf '%s\\n' "$PASSWORD" | shard auth register --email you@example.com`,
1706
1751
  ` shard auth verify-email '<link from the email>'`,
1707
1752
  ` printf '%s\\n' "$PASSWORD" | shard auth login --email you@example.com`,
@@ -10,6 +10,17 @@ export declare function claudeLoginUrls(output: string): string[];
10
10
  * already holds this account is left as it is.
11
11
  */
12
12
  export declare function mergeClaudeAccount(cell: Pick<CellClient, 'files'>, oauthAccount: unknown, signal?: AbortSignal): Promise<void>;
13
+ /**
14
+ * Runs a provider's own sign-in (`claude auth login`, `codex login --device-auth`) in a workspace PTY attached to this
15
+ * terminal, opening each sign-in link `urls` finds in its output in the browser. True when it exited 0.
16
+ */
17
+ export declare function runWorkspaceLogin(ctx: Ctx, ws: Workspace, cell: CellClient, login: {
18
+ session: string;
19
+ argv: string[];
20
+ env: Record<string, string>;
21
+ cwd: string;
22
+ urls: (output: string) => string[];
23
+ }): Promise<boolean>;
13
24
  /** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
14
25
  export declare function loginClaudeInWorkspace(ctx: Ctx, ws: Workspace): Promise<AgentCredentialMetadata>;
15
26
  export declare function ensureCloudClaude(ctx: Ctx, ws: Workspace): Promise<void>;
@@ -3,11 +3,14 @@ import { ShardfluxApiError } from '@shardflux/sdk';
3
3
  import { UsageError } from "./errors.js";
4
4
  import { openUrl } from "./browser.js";
5
5
  import { attachTerminal, cellConnector, PtyOutputCursor, workspaceAsleep } from "./agent-terminal.js";
6
- const NEED_TERMINAL = 'Claude sign-in needed. Run shard agents credentials login claude in a terminal.';
6
+ 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';
7
9
  const VERSION = '2.1.289';
8
10
  export function requireClaudeTerminal(ctx) {
9
- if (ctx.json || !ctx.io.stdin.isTTY || !ctx.io.stdin.setRawMode || !ctx.io.stdin.on)
10
- throw new UsageError(NEED_TERMINAL);
11
+ // 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))
13
+ throw new UsageError(NEED_TERMINAL, NEED_TERMINAL_HINT);
11
14
  }
12
15
  export async function cloudClaudeLogin(ctx) {
13
16
  return (await ctx.cloud().agentCredentials.list()).find(row => row.app === 'claude' && row.scope === 'account' && row.status === 'ready');
@@ -76,6 +79,48 @@ export async function mergeClaudeAccount(cell, oauthAccount, signal) {
76
79
  }
77
80
  throw new UsageError('Claude account configuration kept changing in the workspace. Run shard agents credentials login claude again.');
78
81
  }
82
+ /**
83
+ * Runs a provider's own sign-in (`claude auth login`, `codex login --device-auth`) in a workspace PTY attached to this
84
+ * terminal, opening each sign-in link `urls` finds in its output in the browser. True when it exited 0.
85
+ */
86
+ export async function runWorkspaceLogin(ctx, ws, cell, login) {
87
+ await cell.pty.open({ session_id: login.session, argv: login.argv, env: login.env, cwd: login.cwd, rows: 24, cols: 100 });
88
+ const connect = cellConnector(cell, login.session);
89
+ const opened = new Set();
90
+ const cursor = new PtyOutputCursor();
91
+ let output = '';
92
+ const code = await attachTerminal({ io: ctx.io, signal: ctx.signal, wake: signal => ws.wake({ signal }), asleep: () => workspaceAsleep(ws), invalidate: () => { },
93
+ sleep: (ms, signal) => new Promise(resolve => {
94
+ const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
95
+ const timer = setTimeout(done, ms);
96
+ signal.addEventListener('abort', done, { once: true });
97
+ if (signal.aborted)
98
+ done();
99
+ }),
100
+ connect: async (offset, signal) => {
101
+ const connection = await connect(offset, signal);
102
+ return {
103
+ input: bytes => connection.input(bytes), resize: (rows, cols) => connection.resize(rows, cols), close: () => connection.close(),
104
+ messages: async function* () {
105
+ for await (const message of connection.messages()) {
106
+ const bytes = cursor.consume(message);
107
+ if (bytes) {
108
+ output = (output + bytes.toString()).slice(-16384);
109
+ for (const url of login.urls(output))
110
+ if (!opened.has(url)) {
111
+ opened.add(url);
112
+ openUrl(url, ctx.io.env);
113
+ }
114
+ }
115
+ yield message;
116
+ }
117
+ },
118
+ };
119
+ },
120
+ });
121
+ const ended = await cell.pty.get(login.session);
122
+ return code === 0 && ended.state === 'exited' && ended.exit_code === 0;
123
+ }
79
124
  /** A transient Linux profile holds the initial pair; it is removed before an agent starts. */
80
125
  export async function loginClaudeInWorkspace(ctx, ws) {
81
126
  requireClaudeTerminal(ctx);
@@ -102,42 +147,7 @@ export async function loginClaudeInWorkspace(ctx, ws) {
102
147
  const version = await cell.exec.run(['claude', '--version'], { env, cwd: root, signal: ctx.signal, timeoutMs: 10_000 });
103
148
  if (version.exitCode !== 0 || version.stdout.trim() !== `${VERSION} (Claude Code)`)
104
149
  throw new UsageError(`Cloud Claude sign-in requires Claude Code ${VERSION} in the workspace image.`);
105
- await cell.pty.open({ session_id: session, argv: ['claude', 'auth', 'login', '--claudeai'], env, cwd: root, rows: 24, cols: 100 });
106
- const connect = cellConnector(cell, session);
107
- const opened = new Set();
108
- const cursor = new PtyOutputCursor();
109
- let output = '';
110
- const code = await attachTerminal({ io: ctx.io, signal: ctx.signal, wake: signal => ws.wake({ signal }), asleep: () => workspaceAsleep(ws), invalidate: () => { },
111
- sleep: (ms, signal) => new Promise(resolve => {
112
- const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
113
- const timer = setTimeout(done, ms);
114
- signal.addEventListener('abort', done, { once: true });
115
- if (signal.aborted)
116
- done();
117
- }),
118
- connect: async (offset, signal) => {
119
- const connection = await connect(offset, signal);
120
- return {
121
- input: bytes => connection.input(bytes), resize: (rows, cols) => connection.resize(rows, cols), close: () => connection.close(),
122
- messages: async function* () {
123
- for await (const message of connection.messages()) {
124
- const bytes = cursor.consume(message);
125
- if (bytes) {
126
- output = (output + bytes.toString()).slice(-16384);
127
- for (const url of claudeLoginUrls(output))
128
- if (!opened.has(url)) {
129
- opened.add(url);
130
- openUrl(url, ctx.io.env);
131
- }
132
- }
133
- yield message;
134
- }
135
- },
136
- };
137
- },
138
- });
139
- const ended = await cell.pty.get(session);
140
- if (code !== 0 || ended.state !== 'exited' || ended.exit_code !== 0)
150
+ if (!await runWorkspaceLogin(ctx, ws, cell, { session, argv: ['claude', 'auth', 'login', '--claudeai'], env, cwd: root, urls: claudeLoginUrls }))
141
151
  throw new UsageError('Claude cloud sign-in was interrupted. Run shard agents credentials login claude again.');
142
152
  const credential = JSON.parse(await cell.files.readText(`${root}/.claude/.credentials.json`));
143
153
  const config = JSON.parse(await cell.files.readText(`${root}/.claude/.claude.json`));
@@ -35,11 +35,21 @@ export declare const HUB_TEMPLATE = "codex-connector";
35
35
  * logins and settings, stay) before VMs get the hub's address.
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;
38
43
  export declare const HUB_CAPS: Caps;
39
44
  export declare const HUB_INFO_FILE = "cli-proxy.json";
40
45
  /** Paths in the hub workspace. */
41
46
  export declare const HUB_DIR = "/home/user/.cli-proxy-hub";
42
47
  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
53
  export interface HubInfo {
44
54
  version: 1;
45
55
  api_url: string;
@@ -122,7 +132,7 @@ export interface HubWorkspace {
122
132
  }
123
133
  export interface HubCloud {
124
134
  /** The hub workspace, running (resumed or woken as needed); with `create`, created on first use. Null when missing. */
125
- open(create: boolean): Promise<{
135
+ open(create: boolean, templateVersion?: number): Promise<{
126
136
  ws: HubWorkspace;
127
137
  created: boolean;
128
138
  template?: HubTemplate;
@@ -218,6 +228,15 @@ export interface EnsureOptions {
218
228
  progress?: (message: string) => void;
219
229
  /** Random management key (tests). */
220
230
  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
+ };
221
240
  }
222
241
  export interface EnsureResult {
223
242
  info: HubInfo;
@@ -238,6 +257,12 @@ export declare class HubError extends CodedError {
238
257
  * hub info locally (0600) and in the hub. Idempotent: a running, healthy hub with a valid callback URL is left as it is.
239
258
  */
240
259
  export declare function ensureCliProxyHub(cloud: Shardflux | HubCloud, opts: EnsureOptions): Promise<EnsureResult>;
260
+ /**
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.
264
+ */
265
+ export declare function poolHubConfig(text: string): string;
241
266
  /** The callback URL as the hub stores it (`callback.json`, 0600): its secret is shown only when it is created. */
242
267
  export interface StoredCallback {
243
268
  callback_url: string;
@@ -45,11 +45,18 @@ export const HUB_TEMPLATE = 'codex-connector';
45
45
  * logins and settings, stay) before VMs get the hub's address.
46
46
  */
47
47
  export const HUB_TEMPLATE_VERSION = 4;
48
+ /**
49
+ * The codex-connector version whose sf-agent runs the account pool router: a hub set up for pooled accounts is created
50
+ * on it, or upgraded to it. HUB_TEMPLATE_VERSION moves to it once it is registered in every environment.
51
+ */
52
+ export const POOL_HUB_TEMPLATE_VERSION = 5;
48
53
  export const HUB_CAPS = { cpu_millis: 1000, memory_mib: 256, disk_gib: 2, allocation_mode: 'fixed' };
49
54
  export const HUB_INFO_FILE = 'cli-proxy.json';
50
55
  /** Paths in the hub workspace. */
51
56
  export const HUB_DIR = '/home/user/.cli-proxy-hub';
52
57
  export const HUB_AUTH_DIR = '/home/user/.cli-proxy-api';
58
+ /** Pool mode: the router reads the API URL and the hub token (`sfh_…`) from these hub files (0600). */
59
+ export const HUB_POOL_FILES = { apiUrl: `${HUB_DIR}/api-url`, apiToken: `${HUB_DIR}/api-token` };
53
60
  const CALLBACK_PATH = /^\/__shardflux\/callback\/sfcb_[A-Za-z0-9_-]+\/$/;
54
61
  function callbackUrlOk(cb) {
55
62
  try {
@@ -258,7 +265,7 @@ export function hubPlacement(keyProjectId, home) {
258
265
  export function sdkHubCloud(cloud, opts = {}) {
259
266
  const signal = opts.signal;
260
267
  return {
261
- async open(create) {
268
+ async open(create, templateVersion = HUB_TEMPLATE_VERSION) {
262
269
  const existing = await cloud.workspaces.findByKey(HUB_KEY, { includeDeleted: false, ...(signal ? { signal } : {}) });
263
270
  if (!existing && !create)
264
271
  return null;
@@ -271,7 +278,7 @@ export function sdkHubCloud(cloud, opts = {}) {
271
278
  }
272
279
  const ws = await cloud.workspaces.open({
273
280
  key: HUB_KEY,
274
- ...(existing ? {} : { template: `${HUB_TEMPLATE}@${HUB_TEMPLATE_VERSION}`, caps: HUB_CAPS, lifetime: 'persistent' }),
281
+ ...(existing ? {} : { template: `${HUB_TEMPLATE}@${templateVersion}`, caps: HUB_CAPS, lifetime: 'persistent' }),
275
282
  labels: { ...existing?.labels, ...HUB_LABELS },
276
283
  wait: { ...(opts.wakeTimeoutMs ? { timeoutMs: Math.max(opts.wakeTimeoutMs, 180_000) } : {}), ...(signal ? { signal } : {}) },
277
284
  });
@@ -593,12 +600,14 @@ export async function ensureCliProxyHub(cloud, opts) {
593
600
  const progress = opts.progress ?? (() => { });
594
601
  const local = readHubInfo(opts.dir, opts.apiUrl);
595
602
  progress('Opening the CLIProxyAPI hub…');
596
- let opened = await hubCloud.open(!opts.repairOnly);
603
+ // Pooled accounts need the router's template; everything else keeps the general one.
604
+ const want = opts.pool ? POOL_HUB_TEMPLATE_VERSION : HUB_TEMPLATE_VERSION;
605
+ let opened = await hubCloud.open(!opts.repairOnly, want);
597
606
  if (!opened)
598
607
  throw new HubError('This account has no hosted CLIProxyAPI yet.', 'move yours: shard cli-proxy import');
599
608
  // A hub on an older template is upgraded before VMs get its address (a plain repair only starts it again).
600
609
  const template = opened.template;
601
- if (!opened.created && (!opts.repairOnly || opts.renewInvalid) && hubCloud.upgrade && template?.version != null && template.version < HUB_TEMPLATE_VERSION && (template.upgradeTo ?? 0) >= HUB_TEMPLATE_VERSION) {
610
+ if (!opened.created && (!opts.repairOnly || opts.renewInvalid) && hubCloud.upgrade && template?.version != null && template.version < want && (template.upgradeTo ?? 0) >= want) {
602
611
  progress('Updating the CLIProxyAPI hub…');
603
612
  await hubCloud.upgrade(opened.ws.id);
604
613
  opened = await hubCloud.open(false);
@@ -645,6 +654,14 @@ export async function ensureCliProxyHub(cloud, opts) {
645
654
  }
646
655
  if ((await ws.list(HUB_AUTH_DIR)) === null)
647
656
  await ws.sh(`mkdir -p -m 0700 ${HUB_AUTH_DIR}`);
657
+ if (opts.pool) {
658
+ // The router is in the hub's sf-agent: an older hub that could not be upgraded above cannot pool.
659
+ const running = opened.template?.version;
660
+ if (running != null && running < POOL_HUB_TEMPLATE_VERSION) {
661
+ throw new HubError(`Pooled accounts need the hub on ${HUB_TEMPLATE} ${POOL_HUB_TEMPLATE_VERSION}; it runs ${running} and no update is published yet.`);
662
+ }
663
+ await setUpPool(ws, opts.apiUrl, opts.pool);
664
+ }
648
665
  const started = await ws.sh(START_SCRIPT, [restart ? '1' : '0'], { timeoutMs: 180_000 });
649
666
  const startLines = started.stdout.trim().split('\n');
650
667
  const state = startLines.find((l) => ['running', 'started', 'restarted'].includes(l));
@@ -666,6 +683,45 @@ export async function ensureCliProxyHub(cloud, opts) {
666
683
  writeHubInfo(opts.dir, info);
667
684
  return { info, created, process: state, callbackReplaced, config };
668
685
  }
686
+ /**
687
+ * The hub config with the providers' rate-limit headers passed through (`requests.passthrough-headers`, or the legacy
688
+ * top-level key): CLIProxyAPI 8.0.23 records them per login only for non-streaming answers, so the pool router reads
689
+ * them on its own answers. Returns the text unchanged when it already passes them.
690
+ */
691
+ export function poolHubConfig(text) {
692
+ let doc = parseDocument(text || '{}');
693
+ if (doc.errors.length > 0 || !isMap(doc.contents))
694
+ doc = new Document({});
695
+ const v8 = doc.hasIn(['config-version']) || V8_SECTIONS.some((k) => isMap(doc.getIn([k])));
696
+ const path = v8 ? ['requests', 'passthrough-headers'] : ['passthrough-headers'];
697
+ if (doc.getIn(path) === true)
698
+ return text;
699
+ if (v8 && doc.hasIn(['passthrough-headers']))
700
+ doc.deleteIn(['passthrough-headers']);
701
+ doc.setIn(path, true);
702
+ return doc.toString();
703
+ }
704
+ const HUB_TOKEN_RE = /^sfh_[A-Za-z0-9_-]{43}$/;
705
+ /** Pool mode in the hub: the API URL, the hub token (minted once, or again with renew) and header passthrough. */
706
+ async function setUpPool(ws, apiUrl, pool) {
707
+ await ws.withLock('pool', async () => {
708
+ if ((await ws.read(HUB_POOL_FILES.apiUrl))?.trim() !== apiUrl)
709
+ await ws.write(HUB_POOL_FILES.apiUrl, `${apiUrl}\n`, '0600');
710
+ const have = (await ws.read(HUB_POOL_FILES.apiToken))?.trim() ?? '';
711
+ if (pool.renew || !HUB_TOKEN_RE.test(have)) {
712
+ const token = (await pool.hubToken(ws.id)).trim();
713
+ if (!HUB_TOKEN_RE.test(token))
714
+ throw new HubError('The Shardflux API did not return a hub token.');
715
+ await ws.write(HUB_POOL_FILES.apiToken, `${token}\n`, '0600');
716
+ }
717
+ const configPath = `${HUB_DIR}/config.yaml`;
718
+ const config = (await ws.read(configPath)) ?? '';
719
+ const next = poolHubConfig(config);
720
+ // A running CLIProxyAPI reloads its config by itself; the router starts within a minute of the token.
721
+ if (next !== config)
722
+ await ws.write(configPath, next, '0600');
723
+ });
724
+ }
669
725
  /**
670
726
  * The hub's one callback URL. Under the hub's callback lock: the stored one is kept while the port's callback still
671
727
  * has its creation time; otherwise one is created and stored before the lock is released. Every device and every
@@ -132,9 +132,11 @@ export declare function ttlSeconds(values: Values, b: {
132
132
  max: number;
133
133
  range: string;
134
134
  }): number | undefined;
135
+ /** A session with its conversation, and why that conversation needs you (0.18.0+, APIs that return `question`). */
135
136
  type ThreadRow = import('@shardflux/sdk').Agent & {
136
137
  conversation_id?: string | null;
137
138
  conversation_key?: string;
139
+ question?: string;
138
140
  };
139
141
  /**
140
142
  * Each session's own conversation: the one whose harness session it is, else the computer's only live conversation.
@@ -147,6 +149,7 @@ export declare function withConversations(rows: import('@shardflux/sdk').Agent[]
147
149
  harness_session_id: string | null;
148
150
  state: string;
149
151
  ended_at?: string | null;
152
+ question?: string | null;
150
153
  }[] | null): ThreadRow[];
151
154
  export declare const KEY_HANDLERS: Record<string, Handler>;
152
155
  export {};
package/dist/commands.js CHANGED
@@ -817,7 +817,7 @@ export function withConversations(rows, conversations) {
817
817
  const named = a.conversation_id;
818
818
  const c = (named ? conversations.find(c => c.id === named) : undefined)
819
819
  ?? conversations.find(c => c.workspace_id === a.workspace_id && c.harness_session_id === session) ?? (bound.length === 1 ? bound[0] : undefined);
820
- return c ? { ...a, conversation_id: c.id, conversation_key: c.key } : a;
820
+ return c ? { ...a, conversation_id: c.id, conversation_key: c.key, ...(c.state === 'needs_you' && c.question ? { question: c.question } : {}) } : a;
821
821
  });
822
822
  }
823
823
  /** The project-key commands; handlers.ts adds the account plane's. */
@@ -857,8 +857,12 @@ export const KEY_HANDLERS = {
857
857
  // A complete listing also shows sessions the projection has not picked up yet (agent-live.ts).
858
858
  if (!next && !str(values.cursor))
859
859
  rows.splice(0, rows.length, ...await withLiveAgents(ctx, rows, { ...(state ? { state } : {}), labels }));
860
- // LAST MESSAGE (0.13.4+): the session's latest message, from an API that reports it.
861
- const message = (a) => !a.last_message ? '-' : a.last_message.length > 60 ? `${a.last_message.slice(0, 59)}…` : a.last_message;
860
+ // LAST MESSAGE (0.13.4+): the session's latest message, from an API that reports it; for a conversation that
861
+ // needs you, why (0.18.0+): its question or the reason its turn stopped.
862
+ const message = (a) => {
863
+ const text = (a.question ?? a.last_message ?? '').replace(/\s+/g, ' ').trim();
864
+ return !text ? '-' : text.length > 60 ? `${text.slice(0, 59).trimEnd()}…` : text;
865
+ };
862
866
  const enriched = withConversations(rows, await threads);
863
867
  out(ctx, rows.length ? table(['CONVERSATION', 'COMPUTER', 'APP', 'STATE', 'TITLE', 'LAST MESSAGE'], enriched.map(a => [a.conversation_key?.split('/').at(-1) ?? '-', a.workspace_key, a.app, a.state, a.title, message(a)])) : 'No agent sessions.\n', { data: enriched, next_cursor: next });
864
868
  if (!ctx.json && next)
@@ -1,3 +1,8 @@
1
1
  import type { Ctx, Handler } from './commands.js';
2
2
  export declare function conversationClient(ctx: Ctx): import("@shardflux/sdk").Shardflux | import("@shardflux/sdk").ShardfluxAccount;
3
+ /** Why a conversation needs you, as one line of at most `max` characters; undefined in other states (0.18.0+). */
4
+ export declare function questionLine(c: {
5
+ state: string;
6
+ question?: string | null;
7
+ }, max?: number): string | undefined;
3
8
  export declare const CONVERSATION_HANDLERS: Record<string, Handler>;
@@ -6,16 +6,22 @@ export function conversationClient(ctx) {
6
6
  const session = Boolean(ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim()) || ctx.store.profile(ctx.apiUrl).session !== undefined;
7
7
  return key || !session ? ctx.cloud() : ctx.account();
8
8
  }
9
+ /** Why a conversation needs you, as one line of at most `max` characters; undefined in other states (0.18.0+). */
10
+ export function questionLine(c, max = 100) {
11
+ const text = c.state === 'needs_you' ? c.question?.replace(/\s+/g, ' ').trim() : undefined;
12
+ return !text ? undefined : text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
13
+ }
9
14
  export const CONVERSATION_HANDLERS = {
10
15
  async 'conversations resume'(ctx, _values, [id]) { const r = await conversationClient(ctx).conversations.resume(id, { signal: ctx.signal }); out(ctx, `${r.conversation.title}: resumed.\n`, r); return 0; },
11
16
  async 'conversations reader'(ctx, _values, [id]) { const r = await conversationClient(ctx).conversations.spaceReader(id, { signal: ctx.signal }); out(ctx, `${r.workspace.workspace_key}\n`, r); return 0; },
12
17
  async 'conversations list'(ctx, values) {
13
18
  const state = str(values.state);
14
19
  const page = await conversationClient(ctx).conversations.list({ projectId: str(values.project), state, parentId: str(values.parent), cursor: str(values.cursor), limit: values.limit ? Number(values.limit) : undefined });
15
- out(ctx, page.data.map(c => `${c.id}\t${c.title}\t${c.state}\t${c.branch}`).join('\n') + '\n', page);
20
+ // A conversation that needs you adds a fifth column: why (0.18.0+).
21
+ out(ctx, page.data.map(c => { const q = questionLine(c); return `${c.id}\t${c.title}\t${c.state}\t${c.branch}${q ? `\t${q}` : ''}`; }).join('\n') + '\n', page);
16
22
  return 0;
17
23
  },
18
- async 'conversations get'(ctx, _values, [id]) { const c = await conversationClient(ctx).conversations.get(id); out(ctx, `${c.title}\n${c.state}\n${c.branch}\n`, c); return 0; },
24
+ async 'conversations get'(ctx, _values, [id]) { const c = await conversationClient(ctx).conversations.get(id); const q = questionLine(c, 200); out(ctx, `${c.title}\n${c.state}\n${c.branch}\n${q ? `${q}\n` : ''}`, c); return 0; },
19
25
  async 'conversations send'(ctx, _values, [id, text]) { const r = await conversationClient(ctx).conversations.send(id, text, { signal: ctx.signal }); out(ctx, `Message sent (${r.seq}).\n`, r); return 0; },
20
26
  async 'conversations wait'(ctx, values, [id]) {
21
27
  const seconds = values.timeout === undefined ? 900 : Number(values.timeout);
@@ -40,6 +40,15 @@ export interface Conversation {
40
40
  /** The model and effort its harness starts with; null is the agent's own default (0.17.0+, APIs that keep them). */
41
41
  model?: string | null;
42
42
  effort?: string | null;
43
+ /** The account its thread runs on and the mode it started in (0.18.0+, APIs with the account pool). */
44
+ account?: {
45
+ id: string | null;
46
+ label: string | null;
47
+ provider: Harness;
48
+ mode: 'direct' | 'pooled';
49
+ } | null;
50
+ /** Why it needs you, while `state` is needs_you; null otherwise (0.18.0+, APIs that return it). */
51
+ question?: string | null;
43
52
  }
44
53
  export interface LocalConversation {
45
54
  api_url: string;
@@ -53,7 +53,7 @@ export class LinuxDesktopManager {
53
53
  return null;
54
54
  }
55
55
  }
56
- async status(dir) {
56
+ status(dir) {
57
57
  const version = this.#version(this.app), pids = [];
58
58
  try {
59
59
  const pid = Number(readFileSync(join(dir, 'desktop-linux.pid'), 'utf8'));
@@ -62,20 +62,21 @@ export class LinuxDesktopManager {
62
62
  pids.push(pid);
63
63
  }
64
64
  catch { /* no running app */ }
65
- return { installed: version !== null, version, running: pids.length > 0, pids, login: existsSync(join(this.home, '.config/autostart/shardflux.desktop')) ? 'on' : 'off' };
65
+ return Promise.resolve({ installed: version !== null, version, running: pids.length > 0, pids, login: existsSync(join(this.home, '.config/autostart/shardflux.desktop')) ? 'on' : 'off' });
66
66
  }
67
67
  packagedVersion() { const path = this.#source(); return path ? this.#version(path) : null; }
68
- async login(on) {
68
+ login(on) {
69
69
  const file = join(this.home, '.config/autostart/shardflux.desktop');
70
70
  if (!on) {
71
71
  rmSync(file, { force: true });
72
- return;
72
+ return Promise.resolve();
73
73
  }
74
74
  mkdirSync(dirname(file), { recursive: true });
75
75
  const entry = join(this.home, '.local/share/applications/dev.shardflux.Desktop.desktop');
76
76
  if (!existsSync(entry))
77
- throw new UsageError('Run shard menubar start first.');
77
+ return Promise.reject(new UsageError('Run shard menubar start first.'));
78
78
  cpSync(entry, file);
79
+ return Promise.resolve();
79
80
  }
80
81
  async stop(dir) {
81
82
  const status = await this.status(dir);
@@ -1,7 +1,17 @@
1
- import type { AgentFilesystem } from '@shardflux/sdk';
2
- import type { Handler } from './commands.js';
1
+ import type { AgentFilesystem, Workspace } from '@shardflux/sdk';
2
+ import type { Ctx, Handler } from './commands.js';
3
3
  /** One line per status: what is in use and what is saving or failed, its platform base and setup cache. */
4
4
  export declare function filesystemLine(state: AgentFilesystem & {
5
5
  cache_scope?: string;
6
6
  }): string;
7
+ /** The save's one line about software under the home that went into the image (0.18.0+), as the in-VM shard says it. */
8
+ export declare function movedLine(state: AgentFilesystem, level: 'machine' | 'template'): string;
9
+ /** Creates or resumes the machine VM of the saved machine version; the first one gets this computer's agent config. */
10
+ /** The account's machine VM, created or resumed; `sync: false` skips the first-open config sync (an account sign-in). */
11
+ export declare function openMachine(ctx: Ctx, state?: AgentFilesystem, opts?: {
12
+ sync?: boolean;
13
+ }): Promise<{
14
+ state: AgentFilesystem;
15
+ ws: Workspace;
16
+ }>;
7
17
  export declare const FILESYSTEM_HANDLERS: Record<string, Handler>;