@shardflux/cli 0.14.5 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +76 -12
- package/dist/account-args.js +31 -10
- package/dist/account-commands.d.ts +2 -0
- package/dist/account-commands.js +158 -14
- 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 +71 -6
- package/dist/agent-git-commands.js +2 -1
- package/dist/agent-reachability.js +1 -1
- package/dist/agent-target-commands.d.ts +14 -0
- package/dist/agent-target-commands.js +79 -13
- package/dist/agent-targets.d.ts +7 -0
- package/dist/agent-targets.js +44 -32
- 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/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/format.js +3 -1
- 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
|
@@ -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
|
+
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { CodedError, UsageError } from "./errors.js";
|
|
3
|
+
import { json } from "./format.js";
|
|
4
|
+
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
5
|
+
import { createConversation, resumeConversation } from "./conversations.js";
|
|
6
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
7
|
+
import { ensureProjectGit } from "./project-git.js";
|
|
8
|
+
import { gitRunner } from "./agent-git.js";
|
|
9
|
+
import { projectIdentity } from "./project-identity.js";
|
|
10
|
+
import { uploadInitialImages } from "./initial-prompt.js";
|
|
11
|
+
/** Only provider-returned task ids enter the registered Codex deep-link route. */
|
|
12
|
+
export async function openCodexThread(task, env) {
|
|
13
|
+
const url = threadLink(task);
|
|
14
|
+
const program = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
|
|
15
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
16
|
+
const child = spawn(program, args, { env, stdio: 'ignore' });
|
|
17
|
+
await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Codex app did not open in time')); }, 10000); child.once('error', error => { clearTimeout(timer); reject(error); }); child.once('exit', code => { clearTimeout(timer); if (code === 0)
|
|
18
|
+
resolve();
|
|
19
|
+
else
|
|
20
|
+
reject(new Error(`Open this Cloud thread in Codex: ${task.desktop_url}`)); }); });
|
|
21
|
+
}
|
|
22
|
+
function threadLink(task) {
|
|
23
|
+
if (!task.thread_id || !/^[a-zA-Z0-9_-]{1,128}$/.test(task.thread_id) || task.desktop_url !== `codex://threads/${encodeURIComponent(task.thread_id)}?hostId=durable`)
|
|
24
|
+
throw new Error('Cloud returned an invalid Codex thread link');
|
|
25
|
+
return task.desktop_url;
|
|
26
|
+
}
|
|
27
|
+
export async function submitCloudInitialPrompt(ctx, c, prompt, workspaceKey) {
|
|
28
|
+
try {
|
|
29
|
+
await ctx.cloud().conversations.send(c.id, prompt, { idempotencyKey: `codex-initial-${c.id}`, signal: ctx.signal });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new CodedError('initial_prompt_failed', `Created ${workspaceKey}, but the initial prompt could not be confirmed. Open that conversation before starting another.`, undefined, { workspace_key: workspaceKey, conversation_id: c.id, submitted: null });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function output(ctx, c, task, submitted = false) {
|
|
36
|
+
const url = threadLink(task);
|
|
37
|
+
if (ctx.json) {
|
|
38
|
+
ctx.io.stdout.write(json({ conversation: c, cloud: task, url, ...(submitted ? { initial_prompt: { submitted: true } } : {}) }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
ctx.io.stdout.write(`${c.key}\n${task.desktop_url}\n`);
|
|
42
|
+
await openCodexThread(task, ctx.io.env);
|
|
43
|
+
}
|
|
44
|
+
/** How long the account's Codex remote device may take to come up before it counts as stuck. */
|
|
45
|
+
export const CODEX_STARTING_LIMIT_MS = 5 * 60_000;
|
|
46
|
+
/** When a connector generation started: its UUIDv7 time. */
|
|
47
|
+
export function generationStarted(generation) {
|
|
48
|
+
const hex = generation?.replace(/-/g, '').slice(0, 12);
|
|
49
|
+
return hex && /^[0-9a-f]{12}$/i.test(hex) ? parseInt(hex, 16) : null;
|
|
50
|
+
}
|
|
51
|
+
export function codexHealth(c, workspaceState, now = Date.now()) {
|
|
52
|
+
if (!c)
|
|
53
|
+
return 'missing';
|
|
54
|
+
if (c.state === 'disabled' || c.state === 'disabling')
|
|
55
|
+
return 'disabled';
|
|
56
|
+
if (c.state === 'ready' && c.environment_id)
|
|
57
|
+
return 'ready';
|
|
58
|
+
const started = generationStarted(c.generation);
|
|
59
|
+
const late = started !== null && now - started > CODEX_STARTING_LIMIT_MS;
|
|
60
|
+
// A new device reports attention while its executor is still connecting, and the API tries again every 10 s on its
|
|
61
|
+
// own (staging: about 40 s after enable, ready 9 s later). Until the starting limit that is starting, not Retry.
|
|
62
|
+
if (c.state === 'attention')
|
|
63
|
+
return late || started === null ? 'attention' : 'starting';
|
|
64
|
+
if (workspaceState === 'failed' || workspaceState === 'deleted')
|
|
65
|
+
return 'failed';
|
|
66
|
+
if (late)
|
|
67
|
+
return 'stuck';
|
|
68
|
+
return 'starting';
|
|
69
|
+
}
|
|
70
|
+
/** The connector and the state of its workspace (failed, deleted when it is gone). Never wakes anything. */
|
|
71
|
+
export async function readCodexConnector(ctx) {
|
|
72
|
+
const connector = await ctx.cloud().codex.connector().catch((error) => { if (error.status === 404)
|
|
73
|
+
return null; throw error; });
|
|
74
|
+
if (!connector?.workspace_id || connector.state !== 'starting')
|
|
75
|
+
return { connector, workspaceState: null };
|
|
76
|
+
const workspace = await ctx.cloud().workspaces.get(connector.workspace_id).then(w => w.data.deleted_at ? 'deleted' : w.data.observed_state, (error) => { if (error.status === 404)
|
|
77
|
+
return 'deleted'; throw error; });
|
|
78
|
+
return { connector, workspaceState: workspace };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Starts the remote device again, once: disable, wait until it is disabled (bounded), enable. One executor per account
|
|
82
|
+
* throughout. Used by Retry in setup and Settings; never called in a loop.
|
|
83
|
+
*/
|
|
84
|
+
export async function restartCodexConnector(ctx, timeoutMs = 60_000) {
|
|
85
|
+
const codex = ctx.cloud().codex;
|
|
86
|
+
await codex.disable();
|
|
87
|
+
const end = Date.now() + timeoutMs;
|
|
88
|
+
for (;;) {
|
|
89
|
+
const current = await codex.connector().catch((error) => { if (error.status === 404)
|
|
90
|
+
return null; throw error; });
|
|
91
|
+
if (!current || current.state === 'disabled')
|
|
92
|
+
break;
|
|
93
|
+
if (Date.now() >= end)
|
|
94
|
+
throw new UsageError('Codex is still switching off; try again in a minute.');
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
96
|
+
}
|
|
97
|
+
return await codex.enable({ signal: ctx.signal });
|
|
98
|
+
}
|
|
99
|
+
export async function ensureCodexCloud(ctx) {
|
|
100
|
+
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME ?? ctx.io.env.USERPROFILE, redact: value => ctx.addSecret(value) });
|
|
101
|
+
// A remote device that failed or is stuck starting gets one new start; a starting or ready one is left alone.
|
|
102
|
+
const { connector, workspaceState } = await readCodexConnector(ctx);
|
|
103
|
+
const health = codexHealth(connector, workspaceState);
|
|
104
|
+
if (health === 'failed' || health === 'stuck' || health === 'attention')
|
|
105
|
+
await restartCodexConnector(ctx);
|
|
106
|
+
else
|
|
107
|
+
await ctx.cloud().codex.enable({ signal: ctx.signal });
|
|
108
|
+
return ctx.cloud().codex.waitConnector({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
109
|
+
}
|
|
110
|
+
export async function launchCodexCloud(ctx, values, path, images = []) {
|
|
111
|
+
if (values.in !== undefined && values.in !== 'codex-app')
|
|
112
|
+
throw new UsageError('New Codex conversations open in Codex Cloud. Omit --in or use --in codex-app.');
|
|
113
|
+
if (values.like !== undefined)
|
|
114
|
+
throw new UsageError('Choose the project directory with --path; new Codex conversations use its latest machine template.');
|
|
115
|
+
const project = projectIdentity(path);
|
|
116
|
+
await ensureProjectGit(ctx, project.folder);
|
|
117
|
+
await gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal)(['rev-parse', 'HEAD']);
|
|
118
|
+
await ensureCodexCloud(ctx);
|
|
119
|
+
const created = await createConversation(ctx, path, 'codex', 'codex-cloud', typeof values.name === 'string' ? values.name : undefined);
|
|
120
|
+
const c = created.conversation;
|
|
121
|
+
// --computer: the conversation VM's desktop is on before Codex starts, so the thread gets the computer tools.
|
|
122
|
+
if (values.computer === true)
|
|
123
|
+
await created.workspace.setComputerUse(true);
|
|
124
|
+
// The thread's tools run in this VM, so its first message can name the images by their VM paths.
|
|
125
|
+
await uploadInitialImages(created.workspace.cell(), images, ctx.signal);
|
|
126
|
+
// Idempotency follows the durable conversation, including a lost response.
|
|
127
|
+
await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-${c.id}`, signal: ctx.signal });
|
|
128
|
+
const task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
129
|
+
new AgentStateStore(ctx.store.dir).save(project.remote ?? project.folder, 'codex');
|
|
130
|
+
const submitted = typeof values.initialPrompt === 'string';
|
|
131
|
+
if (submitted)
|
|
132
|
+
await submitCloudInitialPrompt(ctx, c, values.initialPrompt, created.workspace.key);
|
|
133
|
+
await output(ctx, c, task, submitted);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
export async function openCodexConversation(ctx, c) {
|
|
137
|
+
if (c.state === 'done' || c.state === 'archived' || c.ended_at) {
|
|
138
|
+
c = await resumeConversation(ctx, c);
|
|
139
|
+
await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-resume-${c.id}-${c.workspace_id}`, signal: ctx.signal });
|
|
140
|
+
const task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
141
|
+
await output(ctx, c, task);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
const task = await ctx.cloud().codex.task(c.id);
|
|
145
|
+
if (task.state === 'canceled')
|
|
146
|
+
throw new UsageError('This conversation’s Codex executor was removed. Start a new Cloud conversation; its saved branch and history remain available.');
|
|
147
|
+
if (task.state !== 'active' || !task.thread_id)
|
|
148
|
+
throw new UsageError('This Cloud conversation is not ready; inspect it with shard agents codex status.');
|
|
149
|
+
// Opening provider history does not wake the conversation VM.
|
|
150
|
+
await output(ctx, c, task);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
export const CODEX_CLOUD_HANDLERS = {
|
|
154
|
+
'agents codex status': async (ctx) => { ctx.io.stdout.write(json(await ctx.cloud().codex.connector())); return 0; },
|
|
155
|
+
'agents codex enable': async (ctx) => { ctx.io.stdout.write(json(await ensureCodexCloud(ctx))); return 0; },
|
|
156
|
+
'agents codex disable': async (ctx) => { ctx.io.stdout.write(json(await ctx.cloud().codex.disable())); return 0; },
|
|
157
|
+
};
|
|
@@ -11,6 +11,7 @@ import { agentName as gitAgentName } from "./agent-git.js";
|
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
12
|
import { prepareAgentMachine } from "./agent-machine.js";
|
|
13
13
|
import { OperationFailedError } from '@shardflux/sdk';
|
|
14
|
+
import { launchConversationProject } from "./conversation-terminal.js";
|
|
14
15
|
/** L9 can register target-specific handlers after detecting the installed applications. */
|
|
15
16
|
export async function availableTargets(ctx) {
|
|
16
17
|
const { HANDLERS } = await import("./handlers.js");
|
|
@@ -33,6 +34,12 @@ export async function agentAction(ctx, action, values, args) {
|
|
|
33
34
|
}
|
|
34
35
|
export const CENTER_HANDLERS = {
|
|
35
36
|
async 'agents new'(ctx, values, [ref]) {
|
|
37
|
+
if ((values.prompt !== undefined || values['prompt-file'] !== undefined || values.image !== undefined) && (ref || values.name !== undefined || values.like !== undefined || values.in !== undefined)) {
|
|
38
|
+
throw new UsageError('An initial prompt needs a new conversation: use agents new --path <folder> --app claude|codex.');
|
|
39
|
+
}
|
|
40
|
+
if (!ref && values.name === undefined && values.like === undefined && values.in === undefined) {
|
|
41
|
+
return launchConversationProject(ctx, values, [str(values.path) ?? './', ...(str(values.app) ? [str(values.app)] : [])]);
|
|
42
|
+
}
|
|
36
43
|
if (values.name !== undefined || !ref || values.path !== undefined) {
|
|
37
44
|
return launchProject(ctx, { ...values, ...(ref && values.like === undefined ? { like: ref } : {}) }, [str(values.path) ?? './', ...(str(values.app) ? [str(values.app)] : [])]);
|
|
38
45
|
}
|
|
@@ -15,6 +15,9 @@ export interface CenterView {
|
|
|
15
15
|
};
|
|
16
16
|
extraKeys: string[];
|
|
17
17
|
menubarHint?: string;
|
|
18
|
+
doneCount?: number;
|
|
19
|
+
showDone?: boolean;
|
|
20
|
+
now?: number;
|
|
18
21
|
}
|
|
19
22
|
export declare function renderCenter(view: CenterView, columns: number, height: number, color?: boolean): string[];
|
|
20
23
|
/** Cursor-address changed lines only; polling an unchanged view writes nothing. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { browserSessionNote, workspaceBrowserSessionNote } from "./browser-profile-copy.js";
|
|
2
1
|
import { agentName } from "./command-center-model.js";
|
|
3
2
|
import { setupLine } from "./assisted-setup.js";
|
|
3
|
+
import { terminalBrand } from "./terminal-brand.js";
|
|
4
4
|
/** API titles and local input never get to send terminal controls. */
|
|
5
5
|
export function plain(value) { return [...value].map(ch => { const code = ch.codePointAt(0); return code < 32 || code >= 127 && code <= 159 ? ' ' : ch; }).join(''); }
|
|
6
6
|
function width(ch) {
|
|
@@ -23,63 +23,67 @@ export function clip(value, columns) {
|
|
|
23
23
|
export function renderCenter(view, columns, height, color = true) {
|
|
24
24
|
columns = Math.max(1, Math.min(1000, columns));
|
|
25
25
|
height = Math.max(1, Math.min(1000, height));
|
|
26
|
-
const
|
|
26
|
+
const prompt = view.prompt?.replace(/ {2}\(Enter accepts, Esc cancels\)$/, '');
|
|
27
|
+
const lines = [prompt ? ` ${prompt}` : ' + New conversation n', view.filter && !prompt ? ` Search: ${view.filter}` : ''];
|
|
28
|
+
const selectedRow = view.rows.find(row => row.id === view.selected);
|
|
27
29
|
const body = [];
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const age = (since) => {
|
|
31
|
+
if (!since || !Number.isFinite(Date.parse(since)))
|
|
32
|
+
return '';
|
|
33
|
+
const minutes = Math.max(0, Math.floor(((view.now ?? Date.now()) - Date.parse(since)) / 60000));
|
|
34
|
+
return minutes < 1 ? 'now' : minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`;
|
|
35
|
+
};
|
|
34
36
|
for (const row of view.rows) {
|
|
35
|
-
if (row.project !== project) {
|
|
36
|
-
project = row.project;
|
|
37
|
-
key = '';
|
|
38
|
-
body.push({ text: project });
|
|
39
|
-
const setup = view.rows.find(candidate => candidate.project === project && candidate.setup)?.setup;
|
|
40
|
-
const progress = setup && setupLine(project, setup);
|
|
41
|
-
if (progress)
|
|
42
|
-
body.push({ text: ` ${progress}` });
|
|
43
|
-
}
|
|
44
37
|
if (row.start) {
|
|
45
|
-
body.push({ text:
|
|
38
|
+
body.push({ text: ` Start a conversation in ${row.project}`, id: row.id });
|
|
46
39
|
continue;
|
|
47
40
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
const session = row.agent?.id.slice(row.agent.workspace_id.length + 1);
|
|
56
|
-
body.push({ text: ` [${row.badge}] ${row.agent ? `${row.agent.app} ${clip(session, 12)} ${row.agent.title || 'Untitled session'}` : 'Open a session'}`, id: row.id, badge: row.badge });
|
|
41
|
+
const title = row.agent?.title || (row.agent ? 'Untitled conversation' : 'Open a conversation');
|
|
42
|
+
const project = `${row.project}/${agentName(row.key, row.agent?.labels ?? row.workspace?.labels ?? {})}`;
|
|
43
|
+
const suffix = ` ${clip(project, Math.max(8, Math.floor(columns * 0.29)))} ${age(row.agent?.since).padStart(3)}`;
|
|
44
|
+
const titleWidth = Math.max(1, columns - suffix.length - 7);
|
|
45
|
+
const name = clip(title, titleWidth);
|
|
46
|
+
body.push({ text: ` ${row.badge === 'done' ? '○' : '●'} ${name}${' '.repeat(Math.max(0, titleWidth - [...name].reduce((n, c) => n + width(c), 0)))}${suffix}`, id: row.id, badge: row.badge });
|
|
57
47
|
}
|
|
58
48
|
if (!body.length)
|
|
59
|
-
body.push({ text: view.
|
|
49
|
+
body.push({ text: view.message ? '' : view.filter ? ' No conversations match this search.' : ' Your conversations will appear here.' });
|
|
60
50
|
const available = Math.max(0, height - 7);
|
|
61
51
|
const selected = body.findIndex(r => r.id === view.selected);
|
|
62
52
|
const start = Math.max(0, selected - available + 1);
|
|
63
53
|
for (const row of body.slice(start, start + available)) {
|
|
64
|
-
let line = clip(
|
|
54
|
+
let line = clip(row.text, columns);
|
|
55
|
+
if (row.id && row.id === view.selected)
|
|
56
|
+
line = '›' + line.slice(1);
|
|
65
57
|
if (color) {
|
|
66
58
|
if (row.badge === 'working')
|
|
67
|
-
line = `\x1b[38;
|
|
59
|
+
line = line.replace('●', `\x1b[38;2;${terminalBrand['sf-molten'].join(';')}m●\x1b[39m`);
|
|
68
60
|
if (row.badge === 'done')
|
|
69
|
-
line = `\x1b[2m${line}\x1b[
|
|
70
|
-
if (row.id === view.selected)
|
|
71
|
-
line = `\x1b[
|
|
61
|
+
line = `\x1b[2m${line}\x1b[22m`;
|
|
62
|
+
if (row.id && row.id === view.selected)
|
|
63
|
+
line = `\x1b[48;2;${terminalBrand['sf-overlay'].join(';')}m\x1b[38;2;${terminalBrand['sf-ink'].join(';')}m${line}\x1b[0m`;
|
|
72
64
|
}
|
|
73
65
|
lines.push(line);
|
|
74
66
|
}
|
|
75
|
-
while (lines.length < height -
|
|
67
|
+
while (lines.length < height - 5)
|
|
76
68
|
lines.push('');
|
|
77
|
-
|
|
69
|
+
const profiles = selectedRow?.browserProfiles ?? [];
|
|
70
|
+
const sites = (rows) => [...new Set(rows.flatMap(p => p.sites.map(s => s.site)))].join(', ');
|
|
71
|
+
const sharedSites = sites(profiles.filter(p => p.scope === 'project'));
|
|
72
|
+
const ownSites = sites(profiles.filter(p => p.scope !== 'project'));
|
|
73
|
+
const browserContext = [sharedSites && `project · ${sharedSites}`, ownSites && `conversation · ${ownSites}`].filter(Boolean).join(' | ');
|
|
74
|
+
const detail = selectedRow && !selectedRow.start ? `${selectedRow.agent?.app === 'codex' ? 'Codex' : 'Claude'} · ${selectedRow.badge}${selectedRow.agent?.last_message ? ` · ${selectedRow.agent.last_message}` : ''}` : '';
|
|
75
|
+
lines.push(view.message || (selectedRow?.setup && setupLine(selectedRow.project, selectedRow.setup)) || detail);
|
|
76
|
+
lines.push(browserContext ? ` Browser sign-ins: ${browserContext}` : '');
|
|
77
|
+
lines.push(view.doneCount ? ` ${view.doneCount} done · D ${view.showDone ? 'hide' : 'show'}` : '');
|
|
78
|
+
lines.push(view.prompt ? ' Enter continue Esc cancel' : ' Enter open n new / search ? actions q quit');
|
|
79
|
+
lines.push(view.menubarHint ?? '');
|
|
78
80
|
if (view.menu) {
|
|
79
|
-
const menu = [view.menu.title, ...view.menu.items.map((item, i) => `${i === view.menu.selected ? '
|
|
80
|
-
const offset = Math.min(
|
|
81
|
+
const menu = [` ${view.menu.title}`, '', ...view.menu.items.map((item, i) => `${i === view.menu.selected ? '›' : ' '} ${item}`), '', ' Enter select Esc back'];
|
|
82
|
+
const offset = Math.min(2, Math.max(0, height - menu.length));
|
|
81
83
|
for (let i = 0; i < menu.length && offset + i < height; i++)
|
|
82
|
-
lines[offset + i] = menu[i];
|
|
84
|
+
lines[offset + i] = clip(menu[i], columns);
|
|
85
|
+
for (let i = offset + menu.length; i < height - 1; i++)
|
|
86
|
+
lines[i] = '';
|
|
83
87
|
}
|
|
84
88
|
return lines.slice(0, height).map(line => line.includes('\x1b') ? line : clip(line, columns));
|
|
85
89
|
}
|