@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
|
@@ -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
|
}
|
package/dist/command-center.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ensureMenubar } from "./menubar.js";
|
|
|
12
12
|
import { MenubarManager } from "./menubar.js";
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { retrySetupStep } from "./assisted-setup.js";
|
|
15
|
+
import { localConversations } from "./conversations.js";
|
|
15
16
|
export async function showCenter(ctx, scope, options = {}) {
|
|
16
17
|
const input = ctx.io.stdin;
|
|
17
18
|
if (!input.isTTY || !input.setRawMode || !input.on || !input.off)
|
|
@@ -30,6 +31,7 @@ export async function showCenter(ctx, scope, options = {}) {
|
|
|
30
31
|
let timer, escapeTimer;
|
|
31
32
|
let refreshing;
|
|
32
33
|
let edit;
|
|
34
|
+
let showDone = false, draftPrompt = '';
|
|
33
35
|
let menuActions = [];
|
|
34
36
|
let finish;
|
|
35
37
|
const done = new Promise(resolve => { finish = resolve; });
|
|
@@ -41,7 +43,10 @@ export async function showCenter(ctx, scope, options = {}) {
|
|
|
41
43
|
ctx.io.stdout.write(renderer.draw(renderCenter(view, size.columns, size.rows, ctx.io.env.NO_COLOR === undefined && ctx.io.env.TERM !== 'dumb')));
|
|
42
44
|
};
|
|
43
45
|
const updateRows = () => {
|
|
44
|
-
|
|
46
|
+
const rows = centerRows(data, scope, view.filter);
|
|
47
|
+
view.doneCount = rows.filter(row => row.badge === 'done').length;
|
|
48
|
+
view.showDone = showDone;
|
|
49
|
+
view.rows = showDone || view.filter ? rows : rows.filter(row => row.badge !== 'done');
|
|
45
50
|
if (!view.rows.some(r => r.id === view.selected))
|
|
46
51
|
view.selected = view.rows[0]?.id;
|
|
47
52
|
};
|
|
@@ -157,6 +162,43 @@ export async function showCenter(ctx, scope, options = {}) {
|
|
|
157
162
|
return;
|
|
158
163
|
}
|
|
159
164
|
if (edit) {
|
|
165
|
+
if ((edit.kind === 'task' || edit.kind === 'project') && key === 'enter') {
|
|
166
|
+
if (!edit.text.trim()) {
|
|
167
|
+
view.message = edit.kind === 'task' ? 'Describe what the agent should do.' : 'Choose a project folder.';
|
|
168
|
+
draw();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (edit.kind === 'task') {
|
|
172
|
+
draftPrompt = edit.text;
|
|
173
|
+
const row = view.rows.find(r => r.id === view.selected);
|
|
174
|
+
const records = Object.values(localConversations(ctx));
|
|
175
|
+
const folder = scope?.folder ?? records.find(r => r.conversation.workspace_id === row?.workspace?.id)?.folder ?? records[0]?.folder ?? process.cwd();
|
|
176
|
+
edit = { kind: 'project', text: folder };
|
|
177
|
+
view.message = '';
|
|
178
|
+
view.prompt = `Project folder: ${folder} (Enter accepts, Esc cancels)`;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
let project;
|
|
182
|
+
try {
|
|
183
|
+
project = projectIdentity(edit.text);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
view.message = 'Choose an accessible project folder.';
|
|
187
|
+
draw();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
edit = undefined;
|
|
191
|
+
view.prompt = undefined;
|
|
192
|
+
view.message = '';
|
|
193
|
+
const preferred = options.app ?? new AgentStateStore(ctx.store.dir).app(project.remote ?? project.folder);
|
|
194
|
+
menu('Start conversation', [preferred, preferred === 'claude' ? 'codex' : 'claude'].map(app => ({
|
|
195
|
+
label: `${app === 'claude' ? 'Claude' : 'Codex'} · ${project.slug}`,
|
|
196
|
+
run: () => dispatch('new', { path: project.folder, app, prompt: draftPrompt }, [], true),
|
|
197
|
+
})));
|
|
198
|
+
}
|
|
199
|
+
draw();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
160
202
|
if (key === 'escape') {
|
|
161
203
|
if (edit.kind === 'filter') {
|
|
162
204
|
view.filter = edit.previous ?? '';
|
|
@@ -164,6 +206,7 @@ export async function showCenter(ctx, scope, options = {}) {
|
|
|
164
206
|
}
|
|
165
207
|
edit = undefined;
|
|
166
208
|
view.prompt = undefined;
|
|
209
|
+
view.message = '';
|
|
167
210
|
}
|
|
168
211
|
else if (key === 'enter') {
|
|
169
212
|
const current = edit;
|
|
@@ -193,14 +236,14 @@ export async function showCenter(ctx, scope, options = {}) {
|
|
|
193
236
|
}
|
|
194
237
|
else if (key === 'backspace')
|
|
195
238
|
edit.text = [...edit.text].slice(0, -1).join('');
|
|
196
|
-
else if ([...key].length === 1 && key >= ' ' && edit.text.length < (edit.kind === 'filter' ? 256 : 64))
|
|
239
|
+
else if ([...key].length === 1 && key >= ' ' && edit.text.length < (edit.kind === 'task' ? 4096 : edit.kind === 'project' ? 2048 : edit.kind === 'filter' ? 256 : 64))
|
|
197
240
|
edit.text += key;
|
|
198
241
|
if (edit) {
|
|
199
242
|
if (edit.kind === 'filter') {
|
|
200
243
|
view.filter = edit.text;
|
|
201
244
|
updateRows();
|
|
202
245
|
}
|
|
203
|
-
view.prompt = `${edit.kind === 'filter' ? '
|
|
246
|
+
view.prompt = `${edit.kind === 'filter' ? 'Search' : edit.kind === 'task' ? 'What should the agent do?' : edit.kind === 'project' ? 'Project folder' : 'New agent name'}: ${edit.text} (Enter accepts, Esc cancels)`;
|
|
204
247
|
}
|
|
205
248
|
draw();
|
|
206
249
|
return;
|
|
@@ -230,10 +273,28 @@ export async function showCenter(ctx, scope, options = {}) {
|
|
|
230
273
|
}
|
|
231
274
|
if (key === '/') {
|
|
232
275
|
edit = { kind: 'filter', text: view.filter, previous: view.filter };
|
|
233
|
-
view.prompt = `
|
|
276
|
+
view.prompt = `Search: ${view.filter}`;
|
|
234
277
|
draw();
|
|
235
278
|
return;
|
|
236
279
|
}
|
|
280
|
+
if (key === 'D') {
|
|
281
|
+
showDone = !showDone;
|
|
282
|
+
updateRows();
|
|
283
|
+
draw();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (key === 'n' || key === 'enter' && view.rows.find(r => r.id === view.selected)?.start) {
|
|
287
|
+
edit = { kind: 'task', text: '' };
|
|
288
|
+
view.message = '';
|
|
289
|
+
view.prompt = 'What should the agent do? ';
|
|
290
|
+
draw();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (key === '?') {
|
|
294
|
+
const choices = [['n', 'New conversation'], ['o', 'Open in…'], ['N', 'New agent workspace'], ['f', 'Fork workspace'], ['p', 'Park'], ['l', 'View logs'], ...(view.rows.find(row => row.id === view.selected)?.setup ? [['r', 'Retry setup']] : []), ...extra, ['D', showDone ? 'Hide done conversations' : 'Show done conversations']];
|
|
295
|
+
menu('Conversation actions', choices.map(([key, label]) => ({ label: `${key} ${label}`, run: () => handle(key) })));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
237
298
|
const row = view.rows.find(r => r.id === view.selected);
|
|
238
299
|
if (!row)
|
|
239
300
|
return;
|
|
@@ -257,10 +318,6 @@ export async function showCenter(ctx, scope, options = {}) {
|
|
|
257
318
|
}
|
|
258
319
|
const project = scope ? scope.remote ?? scope.folder : row.workspace?.labels.repo_remote || row.project;
|
|
259
320
|
const app = row.agent?.app || options.app || new AgentStateStore(ctx.store.dir).app(project, row.workspace?.labels.app === 'codex' ? 'codex' : 'claude');
|
|
260
|
-
if (row.start && ['enter', 'n', 'N'].includes(key)) {
|
|
261
|
-
menu('Start here', [app, app === 'claude' ? 'codex' : 'claude'].map(app => ({ label: `Start ${app === 'claude' ? 'Claude' : 'Codex'} here`, run: () => dispatch('new', { path: scope.folder, app }, [], true) })));
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
321
|
if (key === 'enter') {
|
|
265
322
|
const target = new AgentStateStore(ctx.store.dir).target(project, ['terminal', ...installedTargets(ctx.io.env).targets]);
|
|
266
323
|
await dispatch('open', { app, project, in: target }, [row.key], true);
|
|
@@ -272,9 +329,6 @@ export async function showCenter(ctx, scope, options = {}) {
|
|
|
272
329
|
await dispatch('open', { app, project, in: target }, [row.key], true);
|
|
273
330
|
} })));
|
|
274
331
|
}
|
|
275
|
-
else if (key === 'n') {
|
|
276
|
-
menu('New session', ['claude', 'codex'].map(app => ({ label: `${app === 'claude' ? 'Claude' : 'Codex'} (reattach if running)`, run: () => dispatch('new', { app, project }, [row.key], true) })));
|
|
277
|
-
}
|
|
278
332
|
else if (key === 'N' || key === 'f') {
|
|
279
333
|
edit = { kind: key === 'N' ? 'new' : 'fork', text: '' };
|
|
280
334
|
view.prompt = 'New agent name: ';
|
|
@@ -13,13 +13,22 @@ import { ensureMenubar, MenubarManager } from "./menubar.js";
|
|
|
13
13
|
import { UsageError } from "./errors.js";
|
|
14
14
|
import { localResumeTerminal } from "./conversation-local-resume.js";
|
|
15
15
|
import { agentReachability } from "./agent-reachability.js";
|
|
16
|
+
import { promptWithImages, readInitialImages, readInitialPrompt, uploadInitialImages } from "./initial-prompt.js";
|
|
17
|
+
import { launchCodexCloud, openCodexConversation } from "./codex-cloud.js";
|
|
16
18
|
/** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
|
|
17
19
|
export async function conversationTerminal(ctx, ws, c, project, values) {
|
|
20
|
+
if (c.route === 'codex-cloud')
|
|
21
|
+
return openCodexConversation(ctx, c);
|
|
18
22
|
await restoreConversationBinding(ctx, c, ws);
|
|
19
23
|
return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id });
|
|
20
24
|
}
|
|
21
25
|
/** Select a durable conversation, then use the harness's native terminal for all interaction. */
|
|
22
26
|
export const launchConversationProject = async (ctx, values, [path = './', explicit]) => {
|
|
27
|
+
const text = await readInitialPrompt(ctx, values);
|
|
28
|
+
// Images are read and validated before allocation, held in memory, and written only into the new VM.
|
|
29
|
+
const images = await readInitialImages(ctx, values, text);
|
|
30
|
+
const initialPrompt = text === undefined ? undefined : promptWithImages(text, images);
|
|
31
|
+
values = { ...values, ...(initialPrompt === undefined ? {} : { initialPrompt }) };
|
|
23
32
|
const project = projectIdentity(path), store = new AgentStateStore(ctx.store.dir);
|
|
24
33
|
const app = explicit === 'claude' || explicit === 'codex' ? explicit : store.app(project.remote ?? project.folder);
|
|
25
34
|
if (explicit && explicit !== app)
|
|
@@ -39,9 +48,13 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
|
|
|
39
48
|
return localResumeTerminal(ctx, c, project.folder);
|
|
40
49
|
const next = await resumeConversation(ctx, c);
|
|
41
50
|
rememberConversation(ctx, next, project.folder);
|
|
51
|
+
if (next.route === 'codex-cloud')
|
|
52
|
+
return openCodexConversation(ctx, next);
|
|
42
53
|
const ws = await conversationWorkspace(ctx, next);
|
|
43
54
|
return conversationTerminal(ctx, ws, next, project.remote ?? project.folder, values);
|
|
44
55
|
}
|
|
56
|
+
if (app === 'codex')
|
|
57
|
+
return launchCodexCloud(ctx, values, path, images);
|
|
45
58
|
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
46
59
|
const signedIn = app === 'claude' ? await cloudClaudeLogin(ctx) : true;
|
|
47
60
|
if (!signedIn)
|
|
@@ -59,7 +72,10 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
|
|
|
59
72
|
const ws = await ctx.cloud().workspaces.open({ ...agentReachability(values), key: `${project.slug}/${name}`, template: prepared.template,
|
|
60
73
|
labels: { ...projectLabels(project, name, app), ...prepared.labels, repo_path: projectWorkspacePath(project) },
|
|
61
74
|
wait: { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal } });
|
|
75
|
+
if (values.computer === true)
|
|
76
|
+
await ws.setComputerUse(true);
|
|
62
77
|
await seedAgentRepository(ctx, ws, project.folder, { populate: true });
|
|
78
|
+
await uploadInitialImages(ws.cell(), images, ctx.signal);
|
|
63
79
|
if (!signedIn)
|
|
64
80
|
await loginClaudeInWorkspace(ctx, ws);
|
|
65
81
|
if (!(await savedMachine(ctx))?.template)
|
|
@@ -67,6 +83,9 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
|
|
|
67
83
|
return terminal(ctx, ws, app, name, project.remote ?? project.folder, values, prepared.setupHash);
|
|
68
84
|
}
|
|
69
85
|
const { conversation: c, workspace: ws } = created;
|
|
86
|
+
if (values.computer === true)
|
|
87
|
+
await ws.setComputerUse(true);
|
|
88
|
+
await uploadInitialImages(ws.cell(), images, ctx.signal);
|
|
70
89
|
if (values.reachable !== undefined)
|
|
71
90
|
await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
|
|
72
91
|
if (!signedIn)
|
package/dist/conversations.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface Conversation {
|
|
|
9
9
|
summary: string;
|
|
10
10
|
harness: Harness;
|
|
11
11
|
harness_session_id: string | null;
|
|
12
|
-
route: 'terminal' | 'acp' | 'codex-app' | 'claude-desktop' | 't3-ssh';
|
|
12
|
+
route: 'terminal' | 'acp' | 'codex-app' | 'codex-cloud' | 'claude-desktop' | 't3-ssh';
|
|
13
13
|
workspace_id: string;
|
|
14
14
|
worktree_path: string;
|
|
15
15
|
branch: string;
|
package/dist/conversations.js
CHANGED
|
@@ -104,14 +104,27 @@ export async function createConversationRecord(ctx, folder, harness, route, name
|
|
|
104
104
|
export async function createConversation(ctx, folder, harness, route, name) {
|
|
105
105
|
const project = projectIdentity(folder);
|
|
106
106
|
const c = await createConversationRecord(ctx, project.folder, harness, route, name);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
try {
|
|
108
|
+
const ws = await conversationWorkspace(ctx, c);
|
|
109
|
+
// This is a one-time seed. A conversation never watches or writes the owner's checkout.
|
|
110
|
+
await seedAgentRepository(ctx, ws, project.folder, { root: c.worktree_path, populate: true });
|
|
111
|
+
const cell = ws.cell();
|
|
112
|
+
const result = await cell.exec.run(['git', '-C', c.worktree_path, 'checkout', '-B', c.branch, c.base_commit], { timeoutMs: 30_000, signal: ctx.signal });
|
|
113
|
+
if (result.exitCode !== 0)
|
|
114
|
+
throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
|
|
115
|
+
return { conversation: c, workspace: ws };
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (route === 'codex-cloud') {
|
|
119
|
+
try {
|
|
120
|
+
await ctx.cloud().workspaces.delete(c.workspace_id);
|
|
121
|
+
}
|
|
122
|
+
catch (cleanup) {
|
|
123
|
+
throw new AggregateError([error, cleanup], `Cloud setup failed; remove owned workspace ${c.workspace_id} before retrying.`, { cause: cleanup });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
115
128
|
}
|
|
116
129
|
export async function resumeConversation(ctx, c) {
|
|
117
130
|
if (c.state !== 'done' && c.state !== 'archived')
|
|
@@ -5,6 +5,8 @@ type Selection = {
|
|
|
5
5
|
vault: Vault;
|
|
6
6
|
item: string;
|
|
7
7
|
always: boolean;
|
|
8
|
+
scope?: 'ask' | 'project' | 'machine';
|
|
9
|
+
remember?: boolean;
|
|
8
10
|
fields: CredentialRequest['fields'];
|
|
9
11
|
};
|
|
10
12
|
export declare class SiteVaultStore {
|
|
@@ -12,8 +14,8 @@ export declare class SiteVaultStore {
|
|
|
12
14
|
constructor(directory: string);
|
|
13
15
|
key(api: string, project: string, site: string): string;
|
|
14
16
|
read(): Record<string, Selection>;
|
|
15
|
-
get(api: string, project: string, site: string): Selection | undefined;
|
|
16
|
-
save(api: string, project: string, site: string, selection: Selection): void;
|
|
17
|
+
get(api: string, project: string, site: string, profile?: string): Selection | undefined;
|
|
18
|
+
save(api: string, project: string, site: string, selection: Selection, profile?: string): void;
|
|
17
19
|
}
|
|
18
20
|
export declare function approveCredential(ctx: Ctx, request: CredentialRequest, selection: Selection, remember?: boolean): Promise<void>;
|
|
19
21
|
export declare function configureCredentialFill(ctx: Ctx, ws: Workspace): Promise<boolean>;
|