@shardflux/cli 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +73 -11
  3. package/dist/account-args.js +28 -7
  4. package/dist/account-commands.js +140 -11
  5. package/dist/acp-catalog.js +2 -2
  6. package/dist/acp-login.d.ts +20 -0
  7. package/dist/acp-login.js +122 -0
  8. package/dist/acp.js +36 -13
  9. package/dist/agent-commands.js +58 -6
  10. package/dist/agent-git-commands.js +2 -1
  11. package/dist/agent-reachability.js +1 -1
  12. package/dist/agent-target-commands.js +26 -1
  13. package/dist/agent-targets.d.ts +7 -0
  14. package/dist/agent-targets.js +44 -32
  15. package/dist/agent-terminal.d.ts +24 -2
  16. package/dist/agent-terminal.js +82 -4
  17. package/dist/args.d.ts +3 -1
  18. package/dist/args.js +21 -9
  19. package/dist/browser-login.d.ts +35 -0
  20. package/dist/browser-login.js +145 -0
  21. package/dist/claude-cloud-login.js +2 -2
  22. package/dist/codex-cloud.d.ts +49 -0
  23. package/dist/codex-cloud.js +157 -0
  24. package/dist/command-center-actions.js +7 -0
  25. package/dist/command-center-renderer.d.ts +3 -0
  26. package/dist/command-center-renderer.js +42 -38
  27. package/dist/command-center.js +65 -11
  28. package/dist/conversation-terminal.js +19 -0
  29. package/dist/conversations.d.ts +1 -1
  30. package/dist/conversations.js +21 -8
  31. package/dist/credential-prompts.d.ts +4 -2
  32. package/dist/credential-prompts.js +38 -16
  33. package/dist/device-bridge.d.ts +13 -0
  34. package/dist/device-bridge.js +14 -4
  35. package/dist/device-helper.js +2 -1
  36. package/dist/errors.d.ts +9 -0
  37. package/dist/errors.js +15 -2
  38. package/dist/handlers.js +2 -1
  39. package/dist/initial-prompt.d.ts +26 -0
  40. package/dist/initial-prompt.js +196 -0
  41. package/dist/legal.d.ts +36 -0
  42. package/dist/legal.js +44 -0
  43. package/dist/main.js +6 -2
  44. package/dist/menubar.d.ts +8 -0
  45. package/dist/menubar.js +9 -1
  46. package/dist/onboarding-app/AGENTS.md +19 -0
  47. package/dist/onboarding-app/CLAUDE.md +19 -0
  48. package/dist/onboarding-app/README.md +12 -0
  49. package/dist/onboarding-app/dev.mjs +18 -0
  50. package/dist/onboarding-app/index.html +25 -0
  51. package/dist/onboarding-app/package.json +12 -0
  52. package/dist/onboarding-app/server.mjs +41 -0
  53. package/dist/onboarding-sample.d.ts +3 -0
  54. package/dist/onboarding-sample.js +29 -0
  55. package/dist/onboarding.d.ts +258 -0
  56. package/dist/onboarding.js +371 -0
  57. package/dist/terminal-brand.d.ts +5 -0
  58. package/dist/terminal-brand.js +18 -0
  59. package/package.json +3 -3
@@ -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 lines = ['shardflux / Cloud agents', view.filter ? `Filter: ${view.filter}` : 'Projects / agents / sessions · attention first', ''];
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 sites = (profiles) => [...new Set(profiles.flatMap(p => p.sites.map(s => s.site)))].join(', ');
29
- // Project-scoped sessions reach every row, so they are named once; workspace-scoped ones sit under their agent.
30
- const shared = [...new Map(view.rows.flatMap(r => r.browserProfiles ?? []).filter(p => p.scope === 'project').map(p => [p.name, p])).values()];
31
- if (shared.length)
32
- body.push({ text: `Browser sessions: ${sites(shared)}` }, { text: browserSessionNote }, { text: '' });
33
- let project = '', key = '';
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: ' Start Claude here (or Codex)', id: row.id });
38
+ body.push({ text: ` Start a conversation in ${row.project}`, id: row.id });
46
39
  continue;
47
40
  }
48
- if (row.key !== key) {
49
- key = row.key;
50
- body.push({ text: ` ${agentName(key, row.agent?.labels ?? row.workspace?.labels ?? {})}` });
51
- const own = row.browserProfiles?.filter(p => p.scope !== 'project') ?? [];
52
- if (own.length)
53
- body.push({ text: ` Browser sessions: ${sites(own)}` }, { text: ` ${workspaceBrowserSessionNote}` });
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.filter ? 'No matching sessions. Press / to change the filter.' : 'No cloud agents. Run shard <path> to start a project.' });
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(`${row.id === view.selected ? '>' : ' '} ${row.text}`, columns);
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;5;208m${line}\x1b[0m`;
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[0m`;
70
- if (row.id === view.selected)
71
- line = `\x1b[7m${line}\x1b[0m`;
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 - 4)
67
+ while (lines.length < height - 5)
76
68
  lines.push('');
77
- lines.push(view.prompt ?? view.message, 'Enter open o open in n session N agent f fork p park', `l logs / filter q quit${view.extraKeys.length ? ` ${view.extraKeys.join(' ')}` : ''}`, view.menubarHint ?? `${view.rows.length} session${view.rows.length === 1 ? '' : 's'} · refresh every 2s`);
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 ? '>' : ' '} ${item}`), 'Enter select Esc cancel'];
80
- const offset = Math.min(3, Math.max(0, height - menu.length));
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
  }
@@ -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
- view.rows = centerRows(data, scope, view.filter);
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' ? 'Filter' : 'New agent name'}: ${edit.text} (Enter accepts, Esc cancels)`;
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 = `Filter: ${view.filter}`;
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)
@@ -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;
@@ -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
- const ws = await conversationWorkspace(ctx, c);
108
- // This is a one-time seed. A conversation never watches or writes the owner's checkout.
109
- await seedAgentRepository(ctx, ws, project.folder, { root: c.worktree_path, populate: true });
110
- const cell = ws.cell();
111
- const result = await cell.exec.run(['git', '-C', c.worktree_path, 'checkout', '-B', c.branch, c.base_commit], { timeoutMs: 30_000, signal: ctx.signal });
112
- if (result.exitCode !== 0)
113
- throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
114
- return { conversation: c, workspace: ws };
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>;