@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.
- package/CHANGELOG.md +66 -0
- package/README.md +73 -11
- package/dist/account-args.js +28 -7
- package/dist/account-commands.js +140 -11
- package/dist/acp-catalog.js +2 -2
- package/dist/acp-login.d.ts +20 -0
- package/dist/acp-login.js +122 -0
- package/dist/acp.js +36 -13
- package/dist/agent-commands.js +58 -6
- package/dist/agent-git-commands.js +2 -1
- package/dist/agent-reachability.js +1 -1
- package/dist/agent-target-commands.js +26 -1
- package/dist/agent-targets.d.ts +7 -0
- package/dist/agent-targets.js +44 -32
- package/dist/agent-terminal.d.ts +24 -2
- package/dist/agent-terminal.js +82 -4
- package/dist/args.d.ts +3 -1
- package/dist/args.js +21 -9
- package/dist/browser-login.d.ts +35 -0
- package/dist/browser-login.js +145 -0
- package/dist/claude-cloud-login.js +2 -2
- package/dist/codex-cloud.d.ts +49 -0
- package/dist/codex-cloud.js +157 -0
- package/dist/command-center-actions.js +7 -0
- package/dist/command-center-renderer.d.ts +3 -0
- package/dist/command-center-renderer.js +42 -38
- package/dist/command-center.js +65 -11
- package/dist/conversation-terminal.js +19 -0
- package/dist/conversations.d.ts +1 -1
- package/dist/conversations.js +21 -8
- package/dist/credential-prompts.d.ts +4 -2
- package/dist/credential-prompts.js +38 -16
- package/dist/device-bridge.d.ts +13 -0
- package/dist/device-bridge.js +14 -4
- package/dist/device-helper.js +2 -1
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +15 -2
- package/dist/handlers.js +2 -1
- package/dist/initial-prompt.d.ts +26 -0
- package/dist/initial-prompt.js +196 -0
- package/dist/legal.d.ts +36 -0
- package/dist/legal.js +44 -0
- package/dist/main.js +6 -2
- package/dist/menubar.d.ts +8 -0
- package/dist/menubar.js +9 -1
- package/dist/onboarding-app/AGENTS.md +19 -0
- package/dist/onboarding-app/CLAUDE.md +19 -0
- package/dist/onboarding-app/README.md +12 -0
- package/dist/onboarding-app/dev.mjs +18 -0
- package/dist/onboarding-app/index.html +25 -0
- package/dist/onboarding-app/package.json +12 -0
- package/dist/onboarding-app/server.mjs +41 -0
- package/dist/onboarding-sample.d.ts +3 -0
- package/dist/onboarding-sample.js +29 -0
- package/dist/onboarding.d.ts +258 -0
- package/dist/onboarding.js +371 -0
- package/dist/terminal-brand.d.ts +5 -0
- package/dist/terminal-brand.js +18 -0
- package/package.json +3 -3
package/dist/agent-terminal.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
208
|
-
|
|
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.
|
|
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.
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
1204
|
-
{ path: ['agents', '
|
|
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
|
-
|
|
1578
|
-
|
|
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>;
|