@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +76 -12
  3. package/dist/account-args.js +31 -10
  4. package/dist/account-commands.d.ts +2 -0
  5. package/dist/account-commands.js +158 -14
  6. package/dist/acp-catalog.js +2 -2
  7. package/dist/acp-login.d.ts +20 -0
  8. package/dist/acp-login.js +122 -0
  9. package/dist/acp.js +36 -13
  10. package/dist/agent-commands.js +71 -6
  11. package/dist/agent-git-commands.js +2 -1
  12. package/dist/agent-reachability.js +1 -1
  13. package/dist/agent-target-commands.d.ts +14 -0
  14. package/dist/agent-target-commands.js +79 -13
  15. package/dist/agent-targets.d.ts +7 -0
  16. package/dist/agent-targets.js +44 -32
  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/codex-cloud.d.ts +49 -0
  22. package/dist/codex-cloud.js +157 -0
  23. package/dist/command-center-actions.js +7 -0
  24. package/dist/command-center-renderer.d.ts +3 -0
  25. package/dist/command-center-renderer.js +42 -38
  26. package/dist/command-center.js +65 -11
  27. package/dist/conversation-terminal.js +19 -0
  28. package/dist/conversations.d.ts +1 -1
  29. package/dist/conversations.js +21 -8
  30. package/dist/credential-prompts.d.ts +4 -2
  31. package/dist/credential-prompts.js +38 -16
  32. package/dist/device-bridge.d.ts +13 -0
  33. package/dist/device-bridge.js +14 -4
  34. package/dist/device-helper.js +2 -1
  35. package/dist/errors.d.ts +9 -0
  36. package/dist/errors.js +15 -2
  37. package/dist/format.js +3 -1
  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
@@ -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>;
@@ -21,12 +21,20 @@ export class SiteVaultStore {
21
21
  throw new UsageError('Could not read site vault choices.');
22
22
  return raw;
23
23
  }
24
- get(api, project, site) {
25
- return this.read()[this.key(api, project, site)];
24
+ get(api, project, site, profile) {
25
+ const all = this.read();
26
+ return all[this.key(api, project, site)] ?? (profile ? all[this.key(api, `machine:${profile}`, site)] : undefined);
26
27
  }
27
- save(api, project, site, selection) {
28
+ save(api, project, site, selection, profile) {
28
29
  const all = this.read();
29
- all[this.key(api, project, site)] = selection;
30
+ if (selection.scope === 'machine') {
31
+ if (!profile)
32
+ throw new UsageError('Sign in or save a project profile before choosing machine scope.');
33
+ delete all[this.key(api, project, site)];
34
+ all[this.key(api, `machine:${profile}`, site)] = selection;
35
+ }
36
+ else
37
+ all[this.key(api, project, site)] = selection;
30
38
  mkdirSync(join(this.file, '..'), { recursive: true, mode: 0o700 });
31
39
  const temp = `${this.file}.${process.pid}.tmp`;
32
40
  writeFileSync(temp, JSON.stringify(all), { mode: 0o600, flag: 'wx' });
@@ -34,9 +42,20 @@ export class SiteVaultStore {
34
42
  chmodSync(this.file, 0o600);
35
43
  }
36
44
  }
45
+ function vaultProfile(ctx) {
46
+ const profile = ctx.store.profile(ctx.apiUrl);
47
+ return profile.organization_id ? `org:${profile.organization_id}${profile.session?.user?.id ? `|user:${profile.session.user.id}` : ""}` : profile.session?.user?.id ? `user:${profile.session.user.id}` : profile.api_key?.key_id ? `key:${profile.api_key.key_id}` : undefined;
48
+ }
49
+ function approvalScope(value) {
50
+ if (value !== undefined && !['ask', 'project', 'machine'].includes(value))
51
+ throw new UsageError('--scope must be ask, project or machine');
52
+ return value;
53
+ }
37
54
  export async function approveCredential(ctx, request, selection, remember = false) {
38
55
  if (request.state !== 'pending' || Date.parse(request.expires_at) <= Date.now())
39
56
  throw new UsageError(`Credential request ${request.id} has expired or ended. ${VAULT_SECURITY_NOTE}`);
57
+ if (selection.scope === 'machine' && !vaultProfile(ctx))
58
+ throw new UsageError('Sign in or save a project profile before choosing machine scope.');
40
59
  const credential = await vaultCredential(selection.vault, selection.item, request.site, { signal: ctx.signal });
41
60
  for (const value of Object.values(credential))
42
61
  if (typeof value === 'string' && value)
@@ -50,8 +69,8 @@ export async function approveCredential(ctx, request, selection, remember = fals
50
69
  if (answered.state !== 'fulfilled')
51
70
  throw new UsageError(`Credential request is ${answered.state}. ${VAULT_SECURITY_NOTE}`);
52
71
  if (remember)
53
- await ctx.cloud().credentialRequests.remember(request.workspace_id, request.site, credential);
54
- new SiteVaultStore(ctx.store.dir).save(ctx.apiUrl, request.project_id, request.site, selection);
72
+ await ctx.cloud().credentialRequests.remember(request.workspace_id, request.site, subset, selection.scope ? { scope: selection.scope === 'ask' ? 'conversation' : selection.scope } : {});
73
+ new SiteVaultStore(ctx.store.dir).save(ctx.apiUrl, request.project_id, request.site, { ...selection, always: selection.scope ? selection.scope !== 'ask' : selection.always, remember }, vaultProfile(ctx));
55
74
  ctx.io.stderr.write(`Approved ${request.site}. ${remember ? REMEMBERED_SECURITY_NOTE : VAULT_SECURITY_NOTE}\n`);
56
75
  }
57
76
  export async function configureCredentialFill(ctx, ws) {
@@ -88,23 +107,23 @@ export async function watchCredentialPrompts(ctx, ws, ask, signal) {
88
107
  continue;
89
108
  seen.add(r.id);
90
109
  try {
91
- const saved = store.get(ctx.apiUrl, r.project_id, r.site);
92
- if (saved?.always && r.fields.every((f) => saved.fields.includes(f))) {
93
- await approveCredential(ctx, r, saved);
110
+ const saved = store.get(ctx.apiUrl, r.project_id, r.site, vaultProfile(ctx));
111
+ if (saved && (saved.scope ? saved.scope !== 'ask' : saved.always) && r.fields.every((f) => saved.fields.includes(f))) {
112
+ await approveCredential(ctx, r, saved, saved.remember);
94
113
  continue;
95
114
  }
96
- const choice = (await ask(`${r.site} requests ${r.fields.join(', ')}. Allow once / Always for this project / Deny [once/always/deny]. ${VAULT_SECURITY_NOTE}\n`)).toLowerCase();
115
+ const choice = (await ask(`${r.site} requests ${r.fields.join(', ')}. Ask every time / Allow for this project / Allow on my machine / Deny [once/project/machine/deny]. ${VAULT_SECURITY_NOTE}\n`)).toLowerCase();
97
116
  if (choice === 'deny') {
98
117
  await ctx.cloud().credentialRequests.deny(r.id);
99
118
  ctx.io.stderr.write(`Denied ${r.site}. ${VAULT_SECURITY_NOTE}\n`);
100
119
  continue;
101
120
  }
102
- if (choice !== 'once' && choice !== 'always')
103
- throw new UsageError(`Choose once, always or deny. ${VAULT_SECURITY_NOTE}`);
121
+ if (!['once', 'always', 'project', 'machine'].includes(choice))
122
+ throw new UsageError(`Choose once, project, machine or deny. ${VAULT_SECURITY_NOTE}`);
104
123
  const vault = vaultType(saved?.vault ?? (await ask(`Choose hsec, 1password, bitwarden or keychain. ${VAULT_SECURITY_NOTE}\n`)));
105
124
  const item = saved?.item ?? (await ask(`Vault item name for ${r.site}. ${VAULT_SECURITY_NOTE}\n`));
106
125
  const remember = (await ask(`Remember for unattended use? [yes/no]. ${REMEMBERED_SECURITY_NOTE}\n`)).toLowerCase() === 'yes';
107
- await approveCredential(ctx, r, { vault, item, always: choice === 'always', fields: r.fields }, remember);
126
+ await approveCredential(ctx, r, { vault, item, always: choice !== 'once', scope: choice === 'once' ? 'ask' : choice === 'machine' ? 'machine' : 'project', fields: r.fields }, remember);
108
127
  }
109
128
  catch (e) {
110
129
  if (signal.aborted)
@@ -162,7 +181,7 @@ export const CREDENTIAL_HANDLERS = {
162
181
  const item = str(values.item);
163
182
  if (!item)
164
183
  throw new UsageError('--item names a vault login');
165
- await approveCredential(ctx, request, { vault, item, always: values.always === true, fields: request.fields }, values.remember === true);
184
+ await approveCredential(ctx, request, { vault, item, always: values.always === true || ['project', 'machine'].includes(str(values.scope) ?? ''), scope: approvalScope(str(values.scope)), fields: request.fields }, values.remember === true);
166
185
  if (ctx.json)
167
186
  ctx.io.stdout.write(JSON.stringify({
168
187
  approved: true,
@@ -178,9 +197,12 @@ export const CREDENTIAL_HANDLERS = {
178
197
  : `Credential request ${r.state}. ${VAULT_SECURITY_NOTE}\n`);
179
198
  return 0;
180
199
  },
181
- async 'credentials forget'(ctx, _values, [ref, site]) {
200
+ async 'credentials forget'(ctx, values, [ref, site]) {
182
201
  const ws = await resolveWorkspace(ctx, ref);
183
- await ctx.cloud().credentialRequests.forget(ws.id, site);
202
+ const scope = str(values.scope);
203
+ if (scope && !['conversation', 'project', 'machine'].includes(scope))
204
+ throw new UsageError('--scope must be conversation, project or machine');
205
+ await ctx.cloud().credentialRequests.forget(ws.id, site, { scope: scope });
184
206
  ctx.io.stdout.write(ctx.json
185
207
  ? JSON.stringify({
186
208
  forgotten: true,
@@ -31,6 +31,14 @@ export interface DeviceApproval {
31
31
  conversation_id: string;
32
32
  summary: string;
33
33
  outside: boolean;
34
+ kind?: DeviceRequest['kind'];
35
+ argv?: string[];
36
+ local_path?: string;
37
+ remote_path?: string;
38
+ project_name?: string;
39
+ conversation_title?: string;
40
+ local_port?: number;
41
+ remote_port?: number;
34
42
  }
35
43
  type Result = {
36
44
  state: 'approved' | 'denied' | 'running' | 'done' | 'failed';
@@ -56,6 +64,11 @@ export declare class DeviceGrants {
56
64
  save(api: string, project: string, root: string, kind: string): Promise<void>;
57
65
  }
58
66
  export declare function deviceSummary(r: DeviceRequest): string;
67
+ /** Structured local approval details; the summary remains for older menu bar and ACP clients. */
68
+ export declare function deviceApproval(r: DeviceRequest, c: Conversation, target: {
69
+ path: string;
70
+ outside: boolean;
71
+ }, folder: string): DeviceApproval;
59
72
  export declare function tryLocallyAction(r: DeviceRequest): 'start' | 'stop' | null;
60
73
  export declare function ensureConversationWorktree(ctx: Ctx, c: Conversation, folder: string, ws?: Workspace): Promise<string>;
61
74
  export declare function deviceExec(argv: string[], cwd: string, timeoutSeconds?: number, signal?: AbortSignal): Promise<Result>;
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process';
3
3
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
4
4
  import { readFile, writeFile, mkdir, realpath, stat } from 'node:fs/promises';
5
5
  import { createConnection, createServer } from 'node:net';
6
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
7
  import { WorkspaceTunnels } from '@shardflux/sdk';
8
8
  import { writePrivateFile } from "./credentials.js";
9
9
  import { conversationName, conversationWorkspace, getConversation, localConversations } from "./conversations.js";
@@ -73,8 +73,18 @@ export function deviceSummary(r) {
73
73
  if (r.kind === 'exec')
74
74
  return `Run on your Mac: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
75
75
  if (r.files)
76
- return `${r.kind === 'files_pull' ? 'Send to VM' : 'Save on your Mac'}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
77
- return `${r.kind === 'port_forward' ? 'Open VM port on your Mac' : 'Reach your Mac from VM'}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
76
+ return `${r.kind === 'files_pull' ? 'Share with conversation' : 'Save on your Mac'}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
77
+ return `${r.kind === 'port_forward' ? 'Open conversation preview on your Mac' : 'Connect conversation to your Mac'}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
78
+ }
79
+ /** Structured local approval details; the summary remains for older menu bar and ACP clients. */
80
+ export function deviceApproval(r, c, target, folder) {
81
+ return {
82
+ type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id,
83
+ summary: deviceSummary(r), outside: target.outside, kind: r.kind, local_path: target.path,
84
+ project_name: basename(folder), conversation_title: c.title || conversationName(c),
85
+ ...(r.exec ? { argv: r.exec.argv } : {}), ...(r.files ? { remote_path: r.files.remote_path } : {}),
86
+ ...(r.port ? { local_port: r.port.local_port, remote_port: r.port.remote_port } : {}),
87
+ };
78
88
  }
79
89
  export function tryLocallyAction(r) {
80
90
  if (r.kind === 'try_locally')
@@ -172,7 +182,7 @@ export class DeviceExecutor {
172
182
  const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
173
183
  const target = devicePath(planned.path, requested), grants = new DeviceGrants(ctx.store.dir);
174
184
  target.outside ||= planned.outside;
175
- const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask({ type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id, summary: deviceSummary(r), outside: target.outside });
185
+ const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask(deviceApproval(r, c, target, record.folder));
176
186
  if (choice === 'deny') {
177
187
  await this.result(r, { state: 'denied' });
178
188
  return;
@@ -46,7 +46,8 @@ export async function runDeviceHelper(ctx) {
46
46
  ctx.io.stdout.write(JSON.stringify(approval) + '\n');
47
47
  if (signal.aborted)
48
48
  denied();
49
- });
49
+ }).finally(() => { if (!signal.aborted)
50
+ ctx.io.stdout.write(JSON.stringify({ type: 'approval_resolved', request_id: approval.request_id }) + '\n'); });
50
51
  };
51
52
  const working = new Map();
52
53
  const mirrored = new ConversationMirrorState();
package/dist/errors.d.ts CHANGED
@@ -45,6 +45,15 @@ export declare class CodedError extends HintedError {
45
45
  readonly details: Record<string, unknown> | undefined;
46
46
  constructor(code: string, message: string, hint?: string, details?: Record<string, unknown>);
47
47
  }
48
+ /**
49
+ * A command that can create an account ran without the agreement to the Terms of Service (0.15.0+): no
50
+ * --accept-terms / SHARDFLUX_ACCEPT_TERMS=1 and no terminal to ask on, or the person answered no. Nothing was sent.
51
+ * Code `terms_not_accepted`, exit 2 (usage); `details` has both links and the flag.
52
+ */
53
+ export declare class TermsNotAcceptedError extends CodedError {
54
+ name: string;
55
+ constructor(declined?: boolean);
56
+ }
48
57
  /** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
49
58
  export declare class AuthConfigError extends HintedError {
50
59
  name: string;
package/dist/errors.js CHANGED
@@ -66,6 +66,19 @@ export class CodedError extends HintedError {
66
66
  this.details = details;
67
67
  }
68
68
  }
69
+ /**
70
+ * A command that can create an account ran without the agreement to the Terms of Service (0.15.0+): no
71
+ * --accept-terms / SHARDFLUX_ACCEPT_TERMS=1 and no terminal to ask on, or the person answered no. Nothing was sent.
72
+ * Code `terms_not_accepted`, exit 2 (usage); `details` has both links and the flag.
73
+ */
74
+ export class TermsNotAcceptedError extends CodedError {
75
+ name = 'TermsNotAcceptedError';
76
+ constructor(declined = false) {
77
+ super('terms_not_accepted', declined
78
+ ? 'Not signed up: creating an account needs your agreement to the Terms of Service and Privacy Policy.'
79
+ : 'Re-run with --accept-terms to agree to the Terms of Service (https://shardflux.dev/terms) and Privacy Policy (https://shardflux.dev/privacy); creating an account needs that agreement.', 'read https://shardflux.dev/terms and https://shardflux.dev/privacy, then re-run with --accept-terms (or set SHARDFLUX_ACCEPT_TERMS=1)', { terms_url: 'https://shardflux.dev/terms', privacy_url: 'https://shardflux.dev/privacy', flag: '--accept-terms' });
80
+ }
81
+ }
69
82
  /** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
70
83
  export class AuthConfigError extends HintedError {
71
84
  name = 'AuthConfigError';
@@ -139,7 +152,7 @@ export class ExecSessionRunningError extends HintedError {
139
152
  this.session = session;
140
153
  }
141
154
  }
142
- const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed']);
155
+ const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed', 'access_denied', 'expired_token']);
143
156
  const KEY_PATTERN = /sfk_[A-Za-z0-9]{8,}_[A-Za-z0-9]+/g;
144
157
  /** User session tokens (`sfu_` + 43 base64url characters), also cut short. */
145
158
  const SESSION_PATTERN = /sfu_[A-Za-z0-9_-]{16,}/g;
@@ -261,7 +274,7 @@ export function describeError(err) {
261
274
  export function exitCodeFor(err, interrupted = false) {
262
275
  if (interrupted || err instanceof InterruptedError)
263
276
  return EXIT.interrupted;
264
- if (err instanceof UsageError || err instanceof TemplateFileError)
277
+ if (err instanceof UsageError || err instanceof TemplateFileError || err instanceof TermsNotAcceptedError)
265
278
  return EXIT.usage;
266
279
  if (err instanceof AuthConfigError || err instanceof MfaPendingError)
267
280
  return EXIT.auth;
package/dist/format.js CHANGED
@@ -813,10 +813,12 @@ export function keyStatus(k, now = Date.now()) {
813
813
  return 'expired';
814
814
  return 'active';
815
815
  }
816
+ /** Every workspace tool a key can carry (`computer` since the API's computer use). */
817
+ const ALL_TOOLS = ['exec', 'files', 'pty', 'process', 'git', 'browser', 'computer'];
816
818
  export function toolsText(tools) {
817
819
  if (tools.length === 0)
818
820
  return '(none: read-only)';
819
- return tools.length === 6 ? 'all' : tools.join(',');
821
+ return ALL_TOOLS.every((t) => tools.includes(t)) ? 'all' : tools.join(',');
820
822
  }
821
823
  export function apiKeyTable(rows, savedId) {
822
824
  return table(['', 'ID', 'KEY ID', 'NAME', 'TOOLS', 'STATUS', 'CREATED', 'EXPIRES', 'LAST USED'], rows.map((k) => [mark(k.id === savedId), k.id, k.key_id, k.name, toolsText(k.tool_permissions), keyStatus(k), time(k.created_at), time(k.expires_at), time(k.last_used_at)]));
package/dist/handlers.js CHANGED
@@ -15,4 +15,5 @@ import { MENUBAR_HANDLERS } from "./menubar.js";
15
15
  import { UPDATE_HANDLERS } from "./update.js";
16
16
  import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
17
17
  import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversation-local.js";
18
- export const HANDLERS = { ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
18
+ import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
19
+ export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
@@ -0,0 +1,26 @@
1
+ import type { Ctx } from './commands.js';
2
+ import type { Values } from './args.js';
3
+ import type { TerminalConnection } from './agent-terminal.js';
4
+ import type { CellClient } from '@shardflux/sdk';
5
+ /** The user chose this repository. Trust exactly that new VM worktree before launching Codex's first turn. */
6
+ export declare function prepareInitialCodexProject(cell: CellClient, cwd: string, ptyId: string): Promise<void>;
7
+ export declare function readInitialPrompt(ctx: Ctx, values: Values): Promise<string | undefined>;
8
+ /** Images for a new conversation's first message. They are held in memory and written only into the conversation VM. */
9
+ export interface InitialImage {
10
+ path: string;
11
+ data: Uint8Array;
12
+ type: 'png' | 'jpeg' | 'gif' | 'webp';
13
+ }
14
+ export declare const ATTACHMENT_DIR = "/home/user/.shardflux/attachments";
15
+ export declare const MAX_INITIAL_IMAGES = 5;
16
+ export declare const MAX_INITIAL_IMAGE_BYTES: number;
17
+ /** Standard input may carry several PNG files back to back (the menu bar's in-memory captures). */
18
+ export declare function splitImages(data: Uint8Array): Uint8Array[];
19
+ /** Reads and validates `--image <file|->` before any VM is allocated. */
20
+ export declare function readInitialImages(ctx: Ctx, values: Values, prompt: string | undefined): Promise<InitialImage[]>;
21
+ /** One VM path per line: Claude Code attaches each as an image when the prompt is pasted; Codex opens it with view_image. */
22
+ export declare function promptWithImages(prompt: string, images: readonly InitialImage[]): string;
23
+ /** Writes the images into the conversation VM before the first message refers to them. */
24
+ export declare function uploadInitialImages(cell: Pick<CellClient, 'files'>, images: readonly InitialImage[], signal?: AbortSignal): Promise<void>;
25
+ /** Wait for the native terminal to enable paste before submitting one first turn. No blind timing or input replay. */
26
+ export declare function submitInitialPrompt(connect: (signal: AbortSignal) => Promise<TerminalConnection>, prompt: string, workspaceKey: string, signal: AbortSignal, app?: 'claude' | 'codex', write?: (bytes: Uint8Array) => Promise<unknown>): Promise<void>;