@shardflux/cli 0.14.4 → 0.15.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 (59) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +73 -11
  3. package/dist/account-args.js +28 -7
  4. package/dist/account-commands.js +140 -11
  5. package/dist/acp-catalog.js +2 -2
  6. package/dist/acp-login.d.ts +20 -0
  7. package/dist/acp-login.js +122 -0
  8. package/dist/acp.js +36 -13
  9. package/dist/agent-commands.js +58 -6
  10. package/dist/agent-git-commands.js +2 -1
  11. package/dist/agent-reachability.js +1 -1
  12. package/dist/agent-target-commands.js +26 -1
  13. package/dist/agent-targets.d.ts +7 -0
  14. package/dist/agent-targets.js +44 -32
  15. package/dist/agent-terminal.d.ts +24 -2
  16. package/dist/agent-terminal.js +82 -4
  17. package/dist/args.d.ts +3 -1
  18. package/dist/args.js +21 -9
  19. package/dist/browser-login.d.ts +35 -0
  20. package/dist/browser-login.js +145 -0
  21. package/dist/claude-cloud-login.js +2 -2
  22. package/dist/codex-cloud.d.ts +49 -0
  23. package/dist/codex-cloud.js +157 -0
  24. package/dist/command-center-actions.js +7 -0
  25. package/dist/command-center-renderer.d.ts +3 -0
  26. package/dist/command-center-renderer.js +42 -38
  27. package/dist/command-center.js +65 -11
  28. package/dist/conversation-terminal.js +19 -0
  29. package/dist/conversations.d.ts +1 -1
  30. package/dist/conversations.js +21 -8
  31. package/dist/credential-prompts.d.ts +4 -2
  32. package/dist/credential-prompts.js +38 -16
  33. package/dist/device-bridge.d.ts +13 -0
  34. package/dist/device-bridge.js +14 -4
  35. package/dist/device-helper.js +2 -1
  36. package/dist/errors.d.ts +9 -0
  37. package/dist/errors.js +15 -2
  38. package/dist/handlers.js +2 -1
  39. package/dist/initial-prompt.d.ts +26 -0
  40. package/dist/initial-prompt.js +196 -0
  41. package/dist/legal.d.ts +36 -0
  42. package/dist/legal.js +44 -0
  43. package/dist/main.js +6 -2
  44. package/dist/menubar.d.ts +8 -0
  45. package/dist/menubar.js +9 -1
  46. package/dist/onboarding-app/AGENTS.md +19 -0
  47. package/dist/onboarding-app/CLAUDE.md +19 -0
  48. package/dist/onboarding-app/README.md +12 -0
  49. package/dist/onboarding-app/dev.mjs +18 -0
  50. package/dist/onboarding-app/index.html +25 -0
  51. package/dist/onboarding-app/package.json +12 -0
  52. package/dist/onboarding-app/server.mjs +41 -0
  53. package/dist/onboarding-sample.d.ts +3 -0
  54. package/dist/onboarding-sample.js +29 -0
  55. package/dist/onboarding.d.ts +258 -0
  56. package/dist/onboarding.js +371 -0
  57. package/dist/terminal-brand.d.ts +5 -0
  58. package/dist/terminal-brand.js +18 -0
  59. package/package.json +3 -3
@@ -1,6 +1,11 @@
1
1
  import { StringDecoder } from 'node:string_decoder';
2
2
  import { PtyConnection, ShardfluxApiError, ShardfluxProtocolError, exitCodePosix } from '@shardflux/sdk';
3
3
  import { UsageError } from "./errors.js";
4
+ /** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
5
+ export function sleepingAttach(error) {
6
+ return error instanceof ShardfluxApiError && error.code === 'workspace_not_running';
7
+ }
8
+ const ASLEEP_NOTICE = '\r\n\x1b[2m· asleep. Type to wake it.\x1b[0m\r\n';
4
9
  export function retryableAttach(error) {
5
10
  if (error instanceof ShardfluxProtocolError && /invalid|byte gap|omitted/.test(error.message))
6
11
  return false;
@@ -50,6 +55,7 @@ export async function attachTerminal(options) {
50
55
  const size = dimensions();
51
56
  connection?.resize(Math.min(1000, Math.max(1, size.rows)), Math.min(1000, Math.max(1, size.cols)));
52
57
  };
58
+ let typed;
53
59
  const send = (bytes) => {
54
60
  if (!bytes.length)
55
61
  return;
@@ -75,10 +81,21 @@ export async function attachTerminal(options) {
75
81
  inputFailure = new UsageError('terminal input exceeded the reconnect buffer');
76
82
  controller.abort(inputFailure);
77
83
  }
78
- else
84
+ else {
79
85
  pending.push(bytes);
86
+ typed?.();
87
+ }
80
88
  }
81
89
  };
90
+ const untilTyped = () => new Promise((resolve) => {
91
+ if (pending.length || signal.aborted) {
92
+ resolve();
93
+ return;
94
+ }
95
+ const done = () => { typed = undefined; signal.removeEventListener('abort', done); resolve(); };
96
+ typed = done;
97
+ signal.addEventListener('abort', done, { once: true });
98
+ });
82
99
  const data = (value) => {
83
100
  const bytes = typeof value === 'string' ? Buffer.from(value) : value;
84
101
  if (promptAnswer) {
@@ -147,13 +164,25 @@ export async function attachTerminal(options) {
147
164
  controller.abort();
148
165
  } });
149
166
  let reconnects = 0;
167
+ let asleep = false;
150
168
  try {
151
169
  while (!signal.aborted) {
152
170
  try {
153
- if (reconnects) {
171
+ if (asleep) {
172
+ // Asleep: an open terminal is not a reason to wake the workspace. The first keystroke is.
173
+ asleep = false;
174
+ io.stderr.write(ASLEEP_NOTICE);
175
+ await untilTyped();
176
+ if (signal.aborted)
177
+ break;
154
178
  options.invalidate();
155
179
  await options.wake(signal);
156
180
  }
181
+ else if (reconnects) {
182
+ options.invalidate();
183
+ if (!options.asleep)
184
+ await options.wake(signal);
185
+ }
157
186
  connection = await options.connect(cursor.offset, signal);
158
187
  acceptingInput = true;
159
188
  resize();
@@ -179,6 +208,10 @@ export async function attachTerminal(options) {
179
208
  break;
180
209
  if (!retryableAttach(error))
181
210
  throw error;
211
+ if (options.asleep && (sleepingAttach(error) || await options.asleep(signal).catch(() => false))) {
212
+ asleep = true;
213
+ continue;
214
+ }
182
215
  }
183
216
  finally {
184
217
  const previous = connection;
@@ -204,6 +237,51 @@ export async function attachTerminal(options) {
204
237
  stdin.pause?.();
205
238
  }
206
239
  }
207
- export function cellConnector(cell, sessionId) {
208
- return (offset, signal) => PtyConnection.attach(cell, sessionId, offset, signal);
240
+ /** Reads the workspace without waking it: suspended, or on its way there. */
241
+ export async function workspaceAsleep(ws) {
242
+ await ws.refresh();
243
+ return ws.desiredState === 'suspended' || ws.state === 'suspending' || ws.state === 'suspended';
244
+ }
245
+ /** A host without passive attach refuses it before any output; the terminal then attaches as an ordinary one. */
246
+ export function passiveUnavailable(error) {
247
+ return error instanceof ShardfluxApiError && error.reason === 'host_feature_unavailable' && error.details?.feature === 'passive_pty';
248
+ }
249
+ /**
250
+ * Attaches a PTY. `passive` (the agent terminal, 0.14.4+) holds the workspace only while bytes move through it, so a
251
+ * waiting agent's workspace parks and suspends under an open terminal, and input still wakes it; on a host without
252
+ * passive attach the next attach is an ordinary one. An ordinary attach wakes a parked workspace and holds it.
253
+ */
254
+ export function cellConnector(cell, sessionId, opts = {}) {
255
+ let passive = opts.passive === true;
256
+ const fallback = (error) => {
257
+ if (!passive || !passiveUnavailable(error))
258
+ return error;
259
+ passive = false;
260
+ return new ShardfluxProtocolError('agent terminal host has no passive attach', 0, 'cell');
261
+ };
262
+ return async (offset, signal) => {
263
+ const { url, token } = await cell.pty.attachUrl(sessionId, offset);
264
+ const target = new URL(url);
265
+ if (passive)
266
+ target.searchParams.set('passive', 'true');
267
+ const connection = new PtyConnection(target.toString(), token, signal);
268
+ try {
269
+ await connection.ready;
270
+ }
271
+ catch (error) {
272
+ await connection.close();
273
+ throw fallback(error);
274
+ }
275
+ return {
276
+ input: (bytes) => connection.input(bytes), resize: (rows, cols) => connection.resize(rows, cols), close: () => connection.close(),
277
+ async *messages() {
278
+ try {
279
+ yield* connection.messages();
280
+ }
281
+ catch (error) {
282
+ throw fallback(error);
283
+ }
284
+ },
285
+ };
286
+ };
209
287
  }
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.14.4";
1
+ export declare const CLI_VERSION = "0.15.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
@@ -6,6 +6,8 @@ export interface OptionSpec {
6
6
  /** Placeholder shown in help, e.g. `<slug>`. */
7
7
  value?: string;
8
8
  description: string;
9
+ /** Accept the option for compatibility but omit it from help. */
10
+ hidden?: boolean;
9
11
  }
10
12
  export interface PositionalSpec {
11
13
  name: string;
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.14.4';
15
+ export const CLI_VERSION = '0.15.0';
16
16
  export const GLOBAL_OPTIONS = {
17
17
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
18
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -268,7 +268,7 @@ const KEY_COMMANDS = [
268
268
  'delete-after-idle': { type: 'string', value: '<days>d', description: 'Delete this persistent workspace after 1..3650 idle days (e.g. 14d).' },
269
269
  like: { type: 'string', value: '<id|key>', description: 'Open a fresh workspace with the source’s template and configuration.' },
270
270
  volume: { type: 'string', multiple: true, value: '<name:/path[:ro]>', description: 'Replace the named volume mounts; repeat for each mount.' },
271
- reachable: { type: 'string', value: '<duration>', description: 'Keep waiting agents reachable (default 30m; 0 off; maximum 24h).' },
271
+ reachable: { type: 'string', value: '<duration>', hidden: true, description: 'Deprecated; ignored. Claude conversations stay reachable without a time limit.' },
272
272
  'agent-idle-grace-seconds': { type: 'string', value: '<0-3600>', description: 'Grace after a coding agent waits or exits (default 300 seconds).' },
273
273
  'idle-policy': { type: 'string', value: '<policy>', description: 'adaptive, never or fixed:<seconds>.' },
274
274
  secret: {
@@ -876,9 +876,10 @@ const KEY_COMMANDS = [
876
876
  { path: ['credentials', 'list'], summary: 'Show pending login requests', positionals: [], options: {} },
877
877
  { path: ['credentials', 'enable'], summary: 'Enable credential fill in a workspace', positionals: [REF], options: {} },
878
878
  { path: ['credentials', 'deny'], summary: 'Deny a login request', positionals: [{ name: 'request-id', required: true }], options: {} },
879
- { path: ['credentials', 'forget'], summary: 'Remove a remembered site credential', positionals: [REF, { name: 'site', required: true }], options: {} },
879
+ { path: ['credentials', 'forget'], summary: 'Remove a remembered site credential', positionals: [REF, { name: 'site', required: true }], options: { scope: { type: 'string', value: '<conversation|project|machine>', description: 'Remembered sign-in scope (default project).' } } },
880
880
  { path: ['credentials', 'approve'], summary: 'Approve a login directly from your vault', positionals: [{ name: 'request-id', required: true }], options: {
881
881
  from: { type: 'string', description: 'hsec, 1password, bitwarden or keychain.' }, item: { type: 'string', description: 'Vault login item name.' },
882
+ scope: { type: 'string', value: '<ask|project|machine>', description: 'Future-request scope; unattended sign-in uses the same scope.' },
882
883
  always: { type: 'boolean', description: 'Always allow these fields for this project on this laptop.' }, remember: { type: 'boolean', description: 'Remember for unattended use, encrypted with AWS KMS.' },
883
884
  } },
884
885
  {
@@ -1166,8 +1167,12 @@ const AGENT_CREDENTIAL_COMMANDS = [
1166
1167
  ...['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: {} })),
1167
1168
  { 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.' } } },
1168
1169
  ];
1170
+ const COMPUTER_OPTION = { type: 'boolean', description: 'Give the new conversation a desktop with a browser its agent drives (computer use).' };
1169
1171
  const AGENT_OPTIONS = {
1170
- reachable: { type: 'string', value: '<duration>', description: 'Keep waiting agents reachable (default 30m; 0 off; maximum 24h).' },
1172
+ prompt: { type: 'string', value: '<text>', description: 'First user message for a new conversation.' },
1173
+ 'prompt-file': { type: 'string', value: '<file|->', description: 'Read the first user message from a UTF-8 file or stdin (-), up to 256 KiB.' },
1174
+ image: { type: 'string', multiple: true, value: '<file|->', description: 'Attach a PNG, JPEG, GIF or WebP image to the first message (repeatable, up to 5 of 10 MiB each; - reads PNG images back to back from stdin) (0.15.0+).' },
1175
+ reachable: { type: 'string', value: '<duration>', hidden: true, description: 'Deprecated; ignored. Claude conversations stay reachable without a time limit.' },
1171
1176
  approvals: { type: 'boolean', description: 'Keep the agent’s permission prompts.' },
1172
1177
  };
1173
1178
  export const PROJECT_COMMAND = {
@@ -1175,6 +1180,7 @@ export const PROJECT_COMMAND = {
1175
1180
  description: 'Opens Claude Code or Codex directly. --resume continues a project conversation. With --json it starts detached and prints the session. Detach with Ctrl-] then d.',
1176
1181
  positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: {
1177
1182
  ...AGENT_OPTIONS,
1183
+ computer: COMPUTER_OPTION,
1178
1184
  new: { type: 'boolean', description: 'Start a new conversation.' },
1179
1185
  resume: { type: 'boolean', description: 'Continue a conversation from this project.' },
1180
1186
  claude: { type: 'boolean', description: 'Same as the claude argument (0.14.4+).' },
@@ -1200,9 +1206,11 @@ const AGENT_COMMANDS = [
1200
1206
  ...(action === 'keep' ? { push: { type: 'boolean', description: 'Push the kept branch to origin and open a PR when gh is installed.' } } : {}),
1201
1207
  },
1202
1208
  })),
1203
- { path: ['agents', 'open'], summary: 'Attach an agent in the terminal', description: 'Detach with Ctrl-] then d; /exit ends the agent session.', positionals: [{ name: 'agent', required: true }], options: { ...AGENT_OPTIONS, app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' }, in: { type: 'string', value: '<terminal|claude-desktop|codex-app|web|t3>', description: 'Where to open the session (default terminal).' } } },
1204
- { path: ['agents', 'new'], summary: 'Start a session or a named agent', positionals: [{ name: 'agent' }], options: {
1209
+ ...['status', 'enable', 'disable'].map(action => ({ path: ['agents', 'codex', action], summary: `${action} the account's billed Codex Cloud connector`, positionals: [], options: {} })),
1210
+ { path: ['agents', 'open'], summary: 'Open a conversation (Codex Cloud or the native Claude session)', description: 'Codex opens its existing Cloud thread without waking its VM. Claude attaches its native session.', positionals: [{ name: 'agent', required: true }], options: { ...AGENT_OPTIONS, app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' }, in: { type: 'string', value: '<terminal|auto|claude-desktop|codex-app|web|t3>', description: 'Where to open a legacy session.' } } },
1211
+ { path: ['agents', 'new'], summary: 'Start a conversation in a fresh VM; Codex opens a genuine Cloud thread', positionals: [{ name: 'agent' }], options: {
1205
1212
  ...AGENT_OPTIONS,
1213
+ computer: COMPUTER_OPTION,
1206
1214
  app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' },
1207
1215
  path: { type: 'string', value: '<path>', description: 'Local project path for first launch.' },
1208
1216
  name: { type: 'string', value: '<name>', description: 'New agent name; copies the project main workspace with --like.' },
@@ -1396,6 +1404,9 @@ function toParseArgsOptions(opts) {
1396
1404
  * returns help text for --help, bare groups and non-TTY bare `shard`.
1397
1405
  */
1398
1406
  export function parseCommandLine(argv, options = {}) {
1407
+ // `acp --login` is the ACP terminal sign-in an editor runs (ACP appends the method's args to `shard acp`).
1408
+ if (argv.length === 2 && argv[0] === 'acp' && argv[1] === '--login')
1409
+ return { kind: 'run', command: { path: ['acp'], summary: '', positionals: [], options: {} }, values: { login: true }, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1399
1410
  if (argv.length === 1 && (argv[0] === 'acp' || argv[0] === 'device-helper'))
1400
1411
  return { kind: 'run', command: { path: [argv[0]], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1401
1412
  const terminator = argv.indexOf('--');
@@ -1559,7 +1570,7 @@ function emailedTokensAsPositionals(rest, command) {
1559
1570
  }
1560
1571
  // ---- help ----------------------------------------------------------------------------------
1561
1572
  function optionLines(opts) {
1562
- const rows = Object.entries(opts).map(([name, o]) => [`${o.short ? `-${o.short}, ` : ' '}--${name}${o.value ? ` ${o.value}` : ''}`, o.description]);
1573
+ const rows = Object.entries(opts).filter(([, o]) => !o.hidden).map(([name, o]) => [`${o.short ? `-${o.short}, ` : ' '}--${name}${o.value ? ` ${o.value}` : ''}`, o.description]);
1563
1574
  const w = Math.max(0, ...rows.map((r) => r[0].length));
1564
1575
  return rows.map(([l, d]) => ` ${l.padEnd(w)} ${d}`);
1565
1576
  }
@@ -1574,8 +1585,9 @@ export function commandHelp(c) {
1574
1585
  const lines = [`Usage: ${usageLine(c)}`, '', c.summary + '.'];
1575
1586
  if (c.description)
1576
1587
  lines.push('', c.description);
1577
- if (Object.keys(c.options).length > 0)
1578
- lines.push('', 'Options:', ...optionLines(c.options));
1588
+ const options = optionLines(c.options);
1589
+ if (options.length > 0)
1590
+ lines.push('', 'Options:', ...options);
1579
1591
  lines.push('', 'Global options:', ...optionLines(GLOBAL_OPTIONS));
1580
1592
  if (c.examples?.length)
1581
1593
  lines.push('', 'Examples:', ...c.examples.map((e) => ` ${e}`));
@@ -0,0 +1,35 @@
1
+ import type { AccountClientOptions, AuthUser } from '@shardflux/sdk';
2
+ export interface DeviceAuthorization {
3
+ device_code: string;
4
+ user_code: string;
5
+ verification_uri: string;
6
+ verification_uri_complete: string;
7
+ expires_in: number;
8
+ interval: number;
9
+ }
10
+ export interface DeviceSession {
11
+ status: 'authenticated';
12
+ user: AuthUser;
13
+ session_token: string;
14
+ session_expires_at: string;
15
+ }
16
+ export interface DevicePollOptions {
17
+ signal: AbortSignal;
18
+ /** Waits `ms` (ends early when the signal aborts). */
19
+ sleep(ms: number): Promise<void>;
20
+ now?: () => number;
21
+ /** A token poll that has not answered after this long is a connection timeout (default 15 s). */
22
+ requestTimeoutMs?: number;
23
+ }
24
+ /** The default name on the approval page: "shard CLI on <hostname>". */
25
+ export declare function defaultClientName(hostname: string): string;
26
+ /** POST /v1/auth/device. */
27
+ export declare function startDeviceAuthorization(opts: AccountClientOptions, clientName: string, signal: AbortSignal): Promise<DeviceAuthorization>;
28
+ /**
29
+ * Polls POST /v1/auth/device/token until the session is issued (RFC 8628 §3.4, §3.5): waits `interval` seconds before
30
+ * each poll; a slow_down adds 5 s (or takes the server's longer interval); a connection failure or timeout doubles the
31
+ * interval (exponential backoff); 429 waits its Retry-After and 5xx the interval, while the request can still be
32
+ * approved. Stops on access_denied, expired_token and every other refusal (thrown as the API's error), and when the
33
+ * request's lifetime has passed.
34
+ */
35
+ export declare function waitForDeviceSession(opts: AccountClientOptions, d: DeviceAuthorization, poll: DevicePollOptions): Promise<DeviceSession>;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `shard auth login --browser` (0.15.0+): sign in by approving this computer in the browser (API contracts §30.5, the
3
+ * OAuth 2.0 Device Authorization Grant of RFC 8628). POST /v1/auth/device returns a device code (kept in memory only,
4
+ * never printed) and a user code; the person opens <app>/cli?code=XXXX-XXXX, signs in or signs up there, checks that the
5
+ * page shows the same code and approves; meanwhile this polls POST /v1/auth/device/token every `interval` seconds
6
+ * (5 s more after each slow_down) until the API hands over the CLI session, the same kind `shard auth login` saves.
7
+ *
8
+ * The Mac app runs it with --json and reads NDJSON on stdout (one object per line):
9
+ * {"event":"open","verification_uri_complete":"…","user_code":"ABCD-EFGH","expires_in":600,
10
+ * "terms_url":"https://shardflux.dev/terms","privacy_url":"https://shardflux.dev/privacy"}
11
+ * {"event":"signed_in","email":"…"} (exit 0), or
12
+ * {"event":"error","code":"access_denied|expired_token|…","message":"…"} (exit non-zero)
13
+ */
14
+ import { ShardfluxApiError, ShardfluxProtocolError } from '@shardflux/sdk';
15
+ const DEVICE_CODE = /^sfd_[A-Za-z0-9_-]{43}$/;
16
+ const USER_CODE = /^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/;
17
+ const SESSION_TOKEN = /^sfu_[A-Za-z0-9_-]{43}$/;
18
+ /** RFC 8628 §3.5: every slow_down adds at least 5 seconds to the interval (more when the server names a longer one). */
19
+ const SLOW_DOWN_STEP_SECONDS = 5;
20
+ const REQUEST_TIMEOUT_MS = 15_000;
21
+ /** Polls keep going this long past expires_in while the API cannot be reached (it answers expired_token itself). */
22
+ const EXPIRY_GRACE_MS = 30_000;
23
+ /** The default name on the approval page: "shard CLI on <hostname>". */
24
+ export function defaultClientName(hostname) {
25
+ const host = hostname.trim().replace(/\.local$/i, '');
26
+ return host ? `shard CLI on ${host}`.slice(0, 100) : 'shard CLI';
27
+ }
28
+ async function call(opts, path, body, signal) {
29
+ const doFetch = opts.fetch ?? fetch;
30
+ const headers = { 'content-type': 'application/json', accept: 'application/json' };
31
+ if (opts.userAgent)
32
+ headers['user-agent'] = opts.userAgent;
33
+ const base = (opts.baseUrl ?? 'https://api.shardflux.dev').replace(/\/+$/, '');
34
+ const res = await doFetch(`${base}${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal });
35
+ const text = await res.text();
36
+ let parsed = null;
37
+ if (text.length > 0) {
38
+ try {
39
+ parsed = JSON.parse(text);
40
+ }
41
+ catch {
42
+ throw new ShardfluxProtocolError(`POST ${path} answered ${res.status} without JSON`, res.status, 'api');
43
+ }
44
+ }
45
+ const ra = Number(res.headers.get('retry-after'));
46
+ return { status: res.status, body: parsed, retryAfter: Number.isFinite(ra) && ra > 0 ? ra : undefined };
47
+ }
48
+ function apiError(status, body, retryAfter, path) {
49
+ const e = body?.error;
50
+ if (!e || typeof e.code !== 'string' || typeof e.message !== 'string')
51
+ return new ShardfluxProtocolError(`POST ${path} answered ${status} without an error body`, status, 'api');
52
+ return new ShardfluxApiError(status, body, 'api', retryAfter);
53
+ }
54
+ /** An error the CLI raises itself in the API's shape (the request expired while the API could not be reached). */
55
+ function localError(code, message) {
56
+ return new ShardfluxApiError(400, { error: { code, message, request_id: '', retryable: false } }, 'api');
57
+ }
58
+ /** POST /v1/auth/device. */
59
+ export async function startDeviceAuthorization(opts, clientName, signal) {
60
+ const path = '/v1/auth/device';
61
+ const r = await call(opts, path, { client_name: clientName }, signal);
62
+ if (r.status !== 200)
63
+ throw apiError(r.status, r.body, r.retryAfter, path);
64
+ const d = r.body;
65
+ if (!d ||
66
+ typeof d.device_code !== 'string' ||
67
+ !DEVICE_CODE.test(d.device_code) ||
68
+ typeof d.user_code !== 'string' ||
69
+ !USER_CODE.test(d.user_code) ||
70
+ typeof d.verification_uri_complete !== 'string' ||
71
+ !/^https?:\/\//.test(d.verification_uri_complete) ||
72
+ typeof d.expires_in !== 'number' ||
73
+ typeof d.interval !== 'number') {
74
+ throw new ShardfluxProtocolError(`POST ${path} answered without a device authorization`, r.status, 'api');
75
+ }
76
+ return d;
77
+ }
78
+ /** No answer: the connection failed or timed out, or something other than the API answered (a proxy's HTML page). */
79
+ function transient(err) {
80
+ if (err instanceof TypeError && err.message === 'fetch failed')
81
+ return true;
82
+ return err instanceof Error && (err.name === 'TimeoutError' || err.name === 'ShardfluxProtocolError');
83
+ }
84
+ /**
85
+ * Polls POST /v1/auth/device/token until the session is issued (RFC 8628 §3.4, §3.5): waits `interval` seconds before
86
+ * each poll; a slow_down adds 5 s (or takes the server's longer interval); a connection failure or timeout doubles the
87
+ * interval (exponential backoff); 429 waits its Retry-After and 5xx the interval, while the request can still be
88
+ * approved. Stops on access_denied, expired_token and every other refusal (thrown as the API's error), and when the
89
+ * request's lifetime has passed.
90
+ */
91
+ export async function waitForDeviceSession(opts, d, poll) {
92
+ const path = '/v1/auth/device/token';
93
+ const now = poll.now ?? Date.now;
94
+ const deadline = now() + d.expires_in * 1000 + EXPIRY_GRACE_MS;
95
+ const timeoutMs = poll.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
96
+ const unreachable = () => localError('expired_token', 'The sign-in request expired before Shardflux could be reached. Start again: shard auth login --browser');
97
+ let interval = Math.max(1, d.interval);
98
+ let wait = interval;
99
+ for (;;) {
100
+ await poll.sleep(wait * 1000);
101
+ if (poll.signal.aborted)
102
+ throw poll.signal.reason instanceof Error ? poll.signal.reason : new DOMException('Interrupted.', 'AbortError');
103
+ wait = interval;
104
+ let r;
105
+ try {
106
+ r = await call(opts, path, { device_code: d.device_code }, AbortSignal.any([poll.signal, AbortSignal.timeout(timeoutMs)]));
107
+ }
108
+ catch (err) {
109
+ if (poll.signal.aborted || !transient(err))
110
+ throw err;
111
+ // RFC 8628 §3.5: back off exponentially on connection timeouts.
112
+ interval *= 2;
113
+ wait = interval;
114
+ if (now() + wait * 1000 > deadline)
115
+ throw unreachable();
116
+ continue;
117
+ }
118
+ if (r.status === 200) {
119
+ const s = r.body;
120
+ if (!s || typeof s.session_token !== 'string' || !SESSION_TOKEN.test(s.session_token) || !s.user || typeof s.user.email !== 'string') {
121
+ throw new ShardfluxProtocolError(`POST ${path} answered 200 without a session`, 200, 'api');
122
+ }
123
+ return s;
124
+ }
125
+ const err = apiError(r.status, r.body, r.retryAfter, path);
126
+ const code = err instanceof ShardfluxApiError ? err.code : null;
127
+ if (code === 'authorization_pending')
128
+ continue;
129
+ if (code === 'slow_down') {
130
+ const given = err.details?.interval;
131
+ interval = Math.max(interval + SLOW_DOWN_STEP_SECONDS, typeof given === 'number' && Number.isFinite(given) ? given : 0);
132
+ wait = interval;
133
+ continue;
134
+ }
135
+ if (code === 'rate_limited' || r.status >= 500) {
136
+ const after = err instanceof ShardfluxApiError ? err.retryAfterSeconds : undefined;
137
+ wait = Math.max(interval, after ?? interval);
138
+ // Waiting past the request's lifetime cannot succeed: report the refusal now.
139
+ if (now() + wait * 1000 > deadline)
140
+ throw err;
141
+ continue;
142
+ }
143
+ throw err;
144
+ }
145
+ }
@@ -2,7 +2,7 @@ import { isDeepStrictEqual } from 'node:util';
2
2
  import { ShardfluxApiError } from '@shardflux/sdk';
3
3
  import { UsageError } from "./errors.js";
4
4
  import { openUrl } from "./browser.js";
5
- import { attachTerminal, cellConnector, PtyOutputCursor } from "./agent-terminal.js";
5
+ import { attachTerminal, cellConnector, PtyOutputCursor, workspaceAsleep } from "./agent-terminal.js";
6
6
  const NEED_TERMINAL = 'Claude sign-in needed. Run shard agents credentials login claude in a terminal.';
7
7
  const VERSION = '2.1.289';
8
8
  export function requireClaudeTerminal(ctx) {
@@ -107,7 +107,7 @@ export async function loginClaudeInWorkspace(ctx, ws) {
107
107
  const opened = new Set();
108
108
  const cursor = new PtyOutputCursor();
109
109
  let output = '';
110
- const code = await attachTerminal({ io: ctx.io, signal: ctx.signal, wake: signal => ws.wake({ signal }), invalidate: () => { },
110
+ const code = await attachTerminal({ io: ctx.io, signal: ctx.signal, wake: signal => ws.wake({ signal }), asleep: () => workspaceAsleep(ws), invalidate: () => { },
111
111
  sleep: (ms, signal) => new Promise(resolve => {
112
112
  const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
113
113
  const timer = setTimeout(done, ms);
@@ -0,0 +1,49 @@
1
+ import type { CodexCloudTask } from '@shardflux/sdk';
2
+ import type { Ctx, Handler } from './commands.js';
3
+ import type { Values } from './args.js';
4
+ import type { Conversation } from './conversations.js';
5
+ import type { InitialImage } from './initial-prompt.js';
6
+ /** Only provider-returned task ids enter the registered Codex deep-link route. */
7
+ export declare function openCodexThread(task: CodexCloudTask, env: Ctx['io']['env']): Promise<void>;
8
+ export declare function submitCloudInitialPrompt(ctx: Ctx, c: Conversation, prompt: string, workspaceKey: string): Promise<void>;
9
+ /** How long the account's Codex remote device may take to come up before it counts as stuck. */
10
+ export declare const CODEX_STARTING_LIMIT_MS: number;
11
+ export type CodexConnectorView = {
12
+ state: string;
13
+ environment_id: string | null;
14
+ last_error: string | null;
15
+ workspace_id?: string;
16
+ generation?: string;
17
+ };
18
+ /**
19
+ * The account's Codex remote device, as a person needs to know it: missing or disabled (enable it), starting (wait),
20
+ * ready, or failed, stuck (starting for longer than CODEX_STARTING_LIMIT_MS) or attention (retry once).
21
+ */
22
+ export type CodexHealth = 'missing' | 'disabled' | 'starting' | 'ready' | 'failed' | 'stuck' | 'attention';
23
+ /** When a connector generation started: its UUIDv7 time. */
24
+ export declare function generationStarted(generation: string | undefined): number | null;
25
+ export declare function codexHealth(c: CodexConnectorView | null, workspaceState: string | null, now?: number): CodexHealth;
26
+ /** The connector and the state of its workspace (failed, deleted when it is gone). Never wakes anything. */
27
+ export declare function readCodexConnector(ctx: Ctx): Promise<{
28
+ connector: CodexConnectorView | null;
29
+ workspaceState: string | null;
30
+ }>;
31
+ /**
32
+ * Starts the remote device again, once: disable, wait until it is disabled (bounded), enable. One executor per account
33
+ * throughout. Used by Retry in setup and Settings; never called in a loop.
34
+ */
35
+ export declare function restartCodexConnector(ctx: Ctx, timeoutMs?: number): Promise<CodexConnectorView>;
36
+ export declare function ensureCodexCloud(ctx: Ctx): Promise<{
37
+ organization_id: string;
38
+ project_id: string;
39
+ workspace_id: string;
40
+ generation: string;
41
+ enabled: boolean;
42
+ state: "starting" | "ready" | "attention" | "disabling" | "disabled";
43
+ environment_id: string | null;
44
+ last_error: string | null;
45
+ last_seen_at: string | null;
46
+ }>;
47
+ export declare function launchCodexCloud(ctx: Ctx, values: Values, path: string, images?: readonly InitialImage[]): Promise<number>;
48
+ export declare function openCodexConversation(ctx: Ctx, c: Conversation): Promise<number>;
49
+ export declare const CODEX_CLOUD_HANDLERS: Record<string, Handler>;