@shardflux/cli 0.22.0 → 0.23.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 (58) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +37 -10
  3. package/dist/acp-journal.js +58 -6
  4. package/dist/acp-login.d.ts +10 -3
  5. package/dist/acp-login.js +31 -4
  6. package/dist/acp-models.d.ts +6 -1
  7. package/dist/acp-models.js +21 -16
  8. package/dist/acp.js +60 -7
  9. package/dist/agent-commands.js +29 -15
  10. package/dist/agent-target-commands.d.ts +20 -1
  11. package/dist/agent-target-commands.js +49 -18
  12. package/dist/agent-targets.js +6 -1
  13. package/dist/args.d.ts +1 -1
  14. package/dist/args.js +8 -7
  15. package/dist/cli-proxy-forward.d.ts +0 -2
  16. package/dist/cli-proxy-forward.js +2 -9
  17. package/dist/cli-proxy-hub.d.ts +22 -30
  18. package/dist/cli-proxy-hub.js +65 -94
  19. package/dist/cli-proxy.d.ts +12 -38
  20. package/dist/cli-proxy.js +51 -168
  21. package/dist/codex-cloud.d.ts +9 -4
  22. package/dist/codex-cloud.js +47 -14
  23. package/dist/codex-linux.d.ts +14 -0
  24. package/dist/codex-linux.js +69 -0
  25. package/dist/command-center-actions.js +13 -4
  26. package/dist/command-center-model.d.ts +2 -0
  27. package/dist/command-center-model.js +6 -1
  28. package/dist/command-center.js +3 -3
  29. package/dist/commands.js +2 -2
  30. package/dist/conversation-terminal.js +3 -2
  31. package/dist/conversations.d.ts +14 -1
  32. package/dist/conversations.js +55 -6
  33. package/dist/desktop-linux.js +16 -3
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +5 -2
  36. package/dist/filesystem-commands.js +25 -3
  37. package/dist/menubar.d.ts +6 -5
  38. package/dist/menubar.js +38 -28
  39. package/dist/move-worktrees.d.ts +13 -7
  40. package/dist/move-worktrees.js +68 -17
  41. package/dist/move.js +20 -25
  42. package/dist/onboarding.js +4 -2
  43. package/dist/prepared-repository.d.ts +2 -0
  44. package/dist/prepared-repository.js +1 -1
  45. package/dist/prepared-repository.py +3 -2
  46. package/dist/project-preparation.d.ts +14 -0
  47. package/dist/project-preparation.js +16 -4
  48. package/dist/project-sync.d.ts +3 -0
  49. package/dist/project-sync.js +4 -1
  50. package/dist/t3-launch.d.ts +2 -1
  51. package/dist/t3-launch.js +11 -2
  52. package/dist/t3-proxy.d.ts +81 -0
  53. package/dist/t3-proxy.js +325 -0
  54. package/dist/terminal-apps.d.ts +11 -0
  55. package/dist/terminal-apps.js +18 -5
  56. package/dist/update.d.ts +2 -0
  57. package/dist/update.js +32 -6
  58. package/package.json +5 -5
@@ -60,8 +60,14 @@ export const CENTER_HANDLERS = {
60
60
  }
61
61
  if (values.review === true)
62
62
  throw new UsageError('--review needs --on <conversation>.');
63
- if ((values.prompt !== undefined || values['prompt-file'] !== undefined || values.image !== undefined) && (ref || values.name !== undefined || values.like !== undefined || values.in !== undefined)) {
64
- throw new UsageError('An initial prompt needs a new conversation: use agents new --path <folder> --app claude|codex.');
63
+ if (values.prompt !== undefined || values['prompt-file'] !== undefined || values.image !== undefined) {
64
+ // --in terminal is where a new conversation opens anyway: it starts one with its first message (0.23.0+). Codex
65
+ // opens in Codex Cloud, which says so.
66
+ if (fresh && values.in === 'terminal')
67
+ return launchConversationProject(ctx, values, [str(values.path) ?? './', ...(str(values.app) ? [str(values.app)] : [])]);
68
+ const other = ref ? `the agent argument ${ref}` : values.name !== undefined ? '--name' : values.like !== undefined ? '--like' : values.in !== undefined ? `--in ${str(values.in)}` : undefined;
69
+ if (other)
70
+ throw new UsageError(`--prompt, --prompt-file and --image are for a new conversation and cannot be combined with ${other}: omit it.`, 'start one with shard agents new --path <folder> --app claude|codex --prompt "<task>"; --in auto opens it where agents targets --open-in says');
65
71
  }
66
72
  if (!ref && values.name === undefined && values.like === undefined && values.in === undefined) {
67
73
  return launchConversationProject(ctx, values, [str(values.path) ?? './', ...(str(values.app) ? [str(values.app)] : [])]);
@@ -145,8 +151,11 @@ export const CENTER_HANDLERS = {
145
151
  if (ws.labels.shard_conversation && CONVERSATION_ID.test(ws.labels.shard_conversation))
146
152
  conversation = await getConversation(ctx, ws.labels.shard_conversation);
147
153
  }
148
- if (conversation?.route === 'codex-cloud')
149
- throw new UsageError(`Codex Cloud runs this conversation; follow it with shard agents open ${conversation.id}.`);
154
+ // Codex Cloud holds the conversation's output in its Codex thread; no terminal in the VM shows it (0.23.0+: the
155
+ // refusal names the commands that do).
156
+ if (conversation?.route === 'codex-cloud') {
157
+ throw new UsageError(`Codex Cloud runs this conversation, so its output is in its Codex thread, not a terminal: open the thread with shard agents open ${conversation.id}; shard c wait ${conversation.id} waits for its reply and shard c get ${conversation.id} shows it.`);
158
+ }
150
159
  if (conversation?.route === 'acp')
151
160
  throw new UsageError('This conversation runs through its ACP client, which shows its output; it has no terminal to follow.');
152
161
  if (conversation && values.app !== undefined && values.app !== conversation.harness)
@@ -24,6 +24,8 @@ export interface CenterRow {
24
24
  /** Older K5 captures used the workspace key as the agent label. K1 needs its leaf name. */
25
25
  export declare const agentName: (key: string, labels: Record<string, string>) => string;
26
26
  export declare const projectName: (key: string, labels: Record<string, string>) => string;
27
+ /** What `shard agents open` gets for a row: a Cloud thread's conversation id, else the VM's key. */
28
+ export declare const openReference: (row: Pick<CenterRow, "key" | "agent">) => string;
27
29
  export declare function loadCenter(ctx: Ctx): Promise<CenterData>;
28
30
  export declare function scopedProject(data: CenterData, scope: ProjectScope): string;
29
31
  export declare function centerRows(data: CenterData, scope?: ProjectScope, filter?: string): CenterRow[];
@@ -6,6 +6,11 @@ import { readSetupProgress } from "./assisted-setup.js";
6
6
  export const agentName = (key, labels) => (labels.agent || key).split('/').at(-1);
7
7
  export const projectName = (key, labels) => labels.shard_project || key.split('/')[0];
8
8
  const parked = (workspace) => ['suspended', 'hibernated', 'parked'].includes(workspace?.observed_state ?? '');
9
+ /** A Codex Cloud thread (API rows labeled shard_route=codex-cloud, 0.23.0+): it runs in Codex, so its VM being parked
10
+ * says nothing about it, and it opens by its conversation id, in Codex, without waking that VM. */
11
+ const cloudThread = (agent) => agent?.labels.shard_route === 'codex-cloud' && !!agent.conversation_id;
12
+ /** What `shard agents open` gets for a row: a Cloud thread's conversation id, else the VM's key. */
13
+ export const openReference = (row) => cloudThread(row.agent) ? row.agent.conversation_id : row.key;
9
14
  export async function loadCenter(ctx) {
10
15
  const collect = async (path) => {
11
16
  const rows = [], cursors = new Set();
@@ -62,7 +67,7 @@ export function centerRows(data, scope, filter = '') {
62
67
  const workspace = workspaces.get(agent.workspace_id);
63
68
  if (workspace?.deleted_at)
64
69
  continue;
65
- const badge = agent.app === 'claude' && agent.credential_status === 'needs_sign_in' ? 'Claude sign-in needed' : agent.state === 'exited' ? 'done' : parked(workspace) ? 'parked' : agent.state === 'waiting' ? 'needs you' : 'working';
70
+ const badge = agent.app === 'claude' && agent.credential_status === 'needs_sign_in' ? 'Claude sign-in needed' : agent.state === 'exited' ? 'done' : !cloudThread(agent) && parked(workspace) ? 'parked' : agent.state === 'waiting' ? 'needs you' : 'working';
66
71
  rows.push({ id: agent.id, key: agent.workspace_key, project: projectName(agent.workspace_key, agent.labels), agent, workspace, badge,
67
72
  attention: badge === 'Claude sign-in needed' ? -1 : ['needs you', 'working', 'parked', 'done'].indexOf(badge) });
68
73
  withSessions.add(agent.workspace_id);
@@ -5,7 +5,7 @@ import { AgentStateStore } from "./agent-state.js";
5
5
  import { installedTargets } from "./agent-targets.js";
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { agentAction, availableTargets } from "./command-center-actions.js";
8
- import { centerRows, loadCenter } from "./command-center-model.js";
8
+ import { centerRows, loadCenter, openReference } from "./command-center-model.js";
9
9
  import { AnsiRenderer, renderCenter } from "./command-center-renderer.js";
10
10
  import { CenterKeys } from "./command-center-keys.js";
11
11
  import { ensureMenubar } from "./menubar.js";
@@ -320,13 +320,13 @@ export async function showCenter(ctx, scope, options = {}) {
320
320
  const app = row.agent?.app || options.app || new AgentStateStore(ctx.store.dir).app(project, row.workspace?.labels.app === 'codex' ? 'codex' : 'claude');
321
321
  if (key === 'enter') {
322
322
  const target = new AgentStateStore(ctx.store.dir).target(project, ['terminal', ...installedTargets(ctx.io.env).targets]);
323
- await dispatch('open', { app, project, in: target }, [row.key], true);
323
+ await dispatch('open', { app, project, in: target }, [openReference(row)], true);
324
324
  }
325
325
  else if (key === 'o') {
326
326
  const targets = await availableTargets(ctx);
327
327
  menu('Open in…', targets.map(target => ({ label: target, run: async () => {
328
328
  new AgentStateStore(ctx.store.dir).saveTarget(project, target);
329
- await dispatch('open', { app, project, in: target }, [row.key], true);
329
+ await dispatch('open', { app, project, in: target }, [openReference(row)], true);
330
330
  } })));
331
331
  }
332
332
  else if (key === 'N' || key === 'f') {
package/dist/commands.js CHANGED
@@ -1104,7 +1104,7 @@ export const KEY_HANDLERS = {
1104
1104
  })));
1105
1105
  }
1106
1106
  if (s.state === 'starting' || s.state === 'running') {
1107
- throw new ExecSessionRunningError(`Exec session ${s.session_id} is still ${s.state}.`, s, `follow it to its end with: shard ws exec-read ${ref} ${s.session_id} --follow`);
1107
+ throw new ExecSessionRunningError(`Exec session ${s.session_id} is still ${s.state}${s.error ? `: ${s.error}` : ''}.`, s, `follow it to its end with: shard ws exec-read ${ref} ${s.session_id} --follow`);
1108
1108
  }
1109
1109
  const result = { exitCode: s.exit_code ?? null, termSignal: s.term_signal ?? null, timedOut: s.timed_out ?? false, canceled: s.canceled ?? false };
1110
1110
  if (!ctx.json && result.exitCode === null && !result.timedOut && !(result.termSignal !== null && result.termSignal > 0)) {
@@ -1121,7 +1121,7 @@ export const KEY_HANDLERS = {
1121
1121
  const s = await cellOf(ctx, ws).exec.cancel(sessionId, graceMs);
1122
1122
  const ended = s.state !== 'starting' && s.state !== 'running';
1123
1123
  const status = !ended ? `${s.state} (cancel sent)` : s.canceled ? `canceled${s.term_signal ? ` (signal ${s.term_signal})` : ''}` : `already ${s.state}${typeof s.exit_code === 'number' ? ` (exit code ${s.exit_code})` : ''}`;
1124
- out(ctx, `exec session ${s.session_id}: ${status}\n`, s);
1124
+ out(ctx, `exec session ${s.session_id}: ${status}${s.error ? `: ${s.error}` : ''}${!ended || s.error?.startsWith('cancel accepted; execution drain') ? `; follow with: shard ws exec-read ${ref} ${s.session_id} --follow` : ''}\n`, s);
1125
1125
  return 0;
1126
1126
  },
1127
1127
  async 'workspaces exec-input'(ctx, values, [ref, sessionId, data]) {
@@ -1,6 +1,6 @@
1
1
  import { linuxTerminalSession } from "./linux-terminal.js";
2
2
  import { resolve } from 'node:path';
3
- import { attachable, openShardInTerminal, reportTerminalOpen } from "./terminal-apps.js";
3
+ import { attachable, handedToTerminal, openShardInTerminal, reportTerminalOpen } from "./terminal-apps.js";
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { AgentStateStore } from "./agent-state.js";
6
6
  import { projectIdentity, projectLabels } from "./project-identity.js";
@@ -71,7 +71,8 @@ export async function reopenConversation(ctx, c, values) {
71
71
  */
72
72
  export const launchProjectCommand = async (ctx, values, [path = './', explicit]) => {
73
73
  const named = explicit ?? (values.claude === true ? 'claude' : values.codex === true ? 'codex' : undefined);
74
- const typing = attachable(ctx.io), here = typing || ctx.json;
74
+ // A terminal this CLI opened for it runs it there, never in yet another one (0.23.0+).
75
+ const typing = attachable(ctx.io), here = typing || ctx.json || handedToTerminal(ctx.io.env);
75
76
  if (values.resume !== true && values.review !== true) {
76
77
  const project = projectIdentity(path);
77
78
  const app = named === 'claude' || named === 'codex' ? named : new AgentStateStore(ctx.store.dir).app(project.remote ?? project.folder);
@@ -111,6 +111,9 @@ export declare function rememberConversation(ctx: Pick<Ctx, 'store' | 'apiUrl'>,
111
111
  export declare function conversationsUnavailable(e: unknown): boolean;
112
112
  export declare function getConversation(ctx: Ctx, id: string): Promise<Conversation>;
113
113
  export declare function listConversations(ctx: Ctx, projectId?: string): Promise<Conversation[]>;
114
+ /** Resolve an open reference without using the local cwd or waking a VM. A conversation key survives a VM move;
115
+ * a computer key names its bound conversation. Legacy computers without that binding keep the workspace path. */
116
+ export declare function conversationForOpen(ctx: Ctx, ref: string): Promise<Conversation | undefined>;
114
117
  export declare function resolveConversation(ctx: Ctx, ref: string, folder?: string): Promise<Conversation>;
115
118
  /** Re-resolve the binding on every attach: a move changes the VM, never the conversation identity. */
116
119
  export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promise<Workspace>;
@@ -131,6 +134,11 @@ export interface MovedInSession {
131
134
  * they pass. The conversation API's availability is read meanwhile too.
132
135
  */
133
136
  export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, allocated?: (workspace: WorkspaceView) => void, idempotencyKey?: string, choice?: ModelChoice, movedIn?: MovedInSession, ready?: Promise<unknown>): Promise<Conversation>;
137
+ /**
138
+ * Files of a conversation's prepared start still on their way (large media that follow the first prompt): an editor's
139
+ * file sync leaves them alone until they land, instead of sending them a second time.
140
+ */
141
+ export declare function preparedInFlight(ctx: Pick<Ctx, 'store' | 'apiUrl'>, id: string): string[];
134
142
  /**
135
143
  * Brings the conversation's accepted revision into its VM (0.22.0+): only what the project cache lacks is sent, and
136
144
  * with `defer` large incompressible files follow the first prompt. Resumable: a later call (the device helper, or
@@ -142,6 +150,7 @@ export declare function prepareAcceptedConversation(ctx: Ctx, c: Conversation, w
142
150
  setup?: Promise<{
143
151
  depsHash: string;
144
152
  } | undefined>;
153
+ landNow?: boolean;
145
154
  }): Promise<boolean>;
146
155
  /**
147
156
  * Sends a conversation's deferred large files and lands them in its worktree. Runs while its VM is awake (it never
@@ -157,7 +166,11 @@ export declare function seedConversation(ctx: Ctx, c: Conversation, ws: Workspac
157
166
  seedRepository?(ws: Workspace, c: Conversation): Promise<void>;
158
167
  }, changes?: Promise<LocalChanges>, setup?: Promise<{
159
168
  depsHash: string;
160
- } | undefined>): Promise<void>;
169
+ } | undefined>, editor?: {
170
+ initialFiles?: readonly string[];
171
+ onInitialFiles?: () => void;
172
+ landNow?: boolean;
173
+ }): Promise<void>;
161
174
  export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, choice?: ModelChoice, movedIn?: MovedInSession & {
162
175
  beforeSeed(ws: Workspace, c: Conversation): Promise<void>;
163
176
  seedRepository?(ws: Workspace, c: Conversation): Promise<void>;
@@ -96,6 +96,41 @@ export async function listConversations(ctx, projectId) {
96
96
  } while (cursor);
97
97
  return all;
98
98
  }
99
+ /** Resolve an open reference without using the local cwd or waking a VM. A conversation key survives a VM move;
100
+ * a computer key names its bound conversation. Legacy computers without that binding keep the workspace path. */
101
+ export async function conversationForOpen(ctx, ref) {
102
+ if (/^conv_[a-z0-9]{26}$/.test(ref))
103
+ return getConversation(ctx, ref);
104
+ if (!ref.includes('/'))
105
+ return undefined;
106
+ const ws = await ctx.cloud().workspaces.findByKey(ref, { signal: ctx.signal });
107
+ if (ws) {
108
+ const id = ws.labels.shard_conversation;
109
+ if (!id)
110
+ return undefined;
111
+ try {
112
+ const c = await getConversation(ctx, id);
113
+ return c.workspace_id === ws.data.id ? c : undefined;
114
+ }
115
+ catch (error) {
116
+ if (conversationsUnavailable(error))
117
+ return undefined;
118
+ throw error;
119
+ }
120
+ }
121
+ let matches;
122
+ try {
123
+ matches = (await listConversations(ctx)).filter(c => c.key === ref);
124
+ }
125
+ catch (error) {
126
+ if (conversationsUnavailable(error))
127
+ return undefined;
128
+ throw error;
129
+ }
130
+ if (matches.length > 1)
131
+ throw new UsageError('Use the conversation id.');
132
+ return matches[0] ? getConversation(ctx, matches[0].id) : undefined;
133
+ }
99
134
  export async function resolveConversation(ctx, ref, folder) {
100
135
  if (ref.startsWith('conv_'))
101
136
  return getConversation(ctx, ref);
@@ -190,8 +225,8 @@ export async function createConversationRecord(ctx, folder, harness, route, name
190
225
  const commit = seed.commit;
191
226
  // The prepared revision of that state (0.22.0+): its media and what Git does not hold (.env files, filter output),
192
227
  // captured while the VM is requested and boots. A capture that cannot complete falls back to the copy seed, never fails
193
- // the start. ACP sessions seed their own way (acp-models.ts).
194
- const capture = movedIn || route === 'acp' ? Promise.resolve(undefined) : capturePreparedSnapshot(ctx.store.dir, project.folder, seed, project.remote, ctx.signal).catch((error) => {
228
+ // the start. Every route but a moved-in session (its own worktrees) starts this way, editors' (ACP) too (0.22.0+).
229
+ const capture = movedIn ? Promise.resolve(undefined) : capturePreparedSnapshot(ctx.store.dir, project.folder, seed, project.remote, ctx.signal).catch((error) => {
195
230
  if (ctx.signal.aborted)
196
231
  throw error;
197
232
  ctx.io.stderr.write(`Project snapshot: ${error instanceof Error ? error.message : String(error)}; copying the project instead.\n`);
@@ -211,6 +246,7 @@ export async function createConversationRecord(ctx, folder, harness, route, name
211
246
  // The seed awaits the capture (prepareAcceptedConversation), so it runs on while the VM boots, with the Git settings
212
247
  // the VM repository copies from this one.
213
248
  pendingCaptures.set(c.id, capture.then(async (prepared) => {
249
+ seedPhase('capture_done');
214
250
  if (!prepared)
215
251
  return undefined;
216
252
  new AgentStateStore(ctx.store.dir).update(doc => {
@@ -223,6 +259,14 @@ export async function createConversationRecord(ctx, folder, harness, route, name
223
259
  acceptedProgress(c.id, c.key);
224
260
  return c;
225
261
  }
262
+ /**
263
+ * Files of a conversation's prepared start still on their way (large media that follow the first prompt): an editor's
264
+ * file sync leaves them alone until they land, instead of sending them a second time.
265
+ */
266
+ export function preparedInFlight(ctx, id) {
267
+ const preparation = localConversations(ctx)[id]?.preparation;
268
+ return preparation?.state === 'deferred' ? preparation.pending ?? [] : [];
269
+ }
226
270
  /** Captures still running for conversations this process created (createConversationRecord). */
227
271
  const pendingCaptures = new Map();
228
272
  /** A start the copy seed took over: its prepared revision is no longer this conversation's (recorded as the reason). */
@@ -280,7 +324,7 @@ export async function prepareAcceptedConversation(ctx, c, ws, options = {}) {
280
324
  const target = { root: c.worktree_path, branch: c.branch, options: { signal: ctx.signal, adopt: true,
281
325
  git: fresh?.git ?? await preparedGitConfig(local.folder, snapshot.manifest, ctx.signal), ...(outside ? { dependencySource: templateCheckout(c) } : {}),
282
326
  after: { script: SEED_SNAPSHOT, args: [c.worktree_path, c.branch.replace(/^shard\//, ''), HUB_PATH, 'background'] },
283
- ...(intent ? { setupIntent: { conversation: c.id, depsHash: intent.depsHash } } : {}), workspace: ws.id } };
327
+ ...(intent ? { setupIntent: { conversation: c.id, depsHash: intent.depsHash } } : {}), workspace: ws.id, ...(options.landNow ? { landNow: true } : {}) } };
284
328
  let transfer, apply;
285
329
  try {
286
330
  // A warm start: the probe checks the revision out in its own exec.
@@ -363,7 +407,7 @@ export async function completeDeferredPreparation(ctx, c) {
363
407
  * its prepared revision (0.22.0+), or the copy seed when the start has none or the VM cannot take one. Repeatable: a
364
408
  * start resumed after a deadline continues here.
365
409
  */
366
- export async function seedConversation(ctx, c, ws, folder, movedIn, changes, setup) {
410
+ export async function seedConversation(ctx, c, ws, folder, movedIn, changes, setup, editor = {}) {
367
411
  // Requests the copy seed starts and nothing waits for (the history backfill's start), settled before this returns.
368
412
  const background = [];
369
413
  const cell = ws.cell();
@@ -383,9 +427,14 @@ export async function seedConversation(ctx, c, ws, folder, movedIn, changes, set
383
427
  void recordIntent();
384
428
  await movedIn.seedRepository(ws, c);
385
429
  }
386
- else if (!await prepareAcceptedConversation(ctx, c, ws, { fallback: true, ...(setup ? { setup } : {}) })) {
430
+ else if (await prepareAcceptedConversation(ctx, c, ws, { fallback: true, ...(setup ? { setup } : {}), ...(editor.landNow ? { landNow: true } : {}) })) {
431
+ // A prepared worktree appears whole: an editor's agent settings are there with everything else.
432
+ editor.onInitialFiles?.();
433
+ }
434
+ else {
387
435
  await seedAgentRepository(ctx, ws, folder, { root: c.worktree_path, populate: true, changes: changes ?? localChanges(folder, ctx.signal),
388
- background: started => background.push(started), beforeHistory: recordIntent() });
436
+ background: started => background.push(started), beforeHistory: recordIntent(),
437
+ ...(editor.initialFiles ? { initialFiles: editor.initialFiles } : {}), ...(editor.onInitialFiles ? { onInitialFiles: editor.onInitialFiles } : {}) });
389
438
  }
390
439
  await recorded;
391
440
  seedPhase('seed_done');
@@ -1,6 +1,6 @@
1
1
  import { spawn, execFile } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, chmodSync, mkdtempSync } from 'node:fs';
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, chmodSync, mkdtempSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { promisify } from 'node:util';
@@ -19,6 +19,15 @@ export function linuxDesktopPackage(arch = process.arch) {
19
19
  }
20
20
  export function graphicalSession(env) { return !!(env.DISPLAY || env.WAYLAND_DISPLAY); }
21
21
  const desktopString = (text) => `"${text.replace(/(["`$\\])/g, '\\$1')}"`;
22
+ // fnm's multishell aliases belong to the launching shell; the desktop and its autostart entry outlive it.
23
+ function stableDesktopRuntime(runtime) {
24
+ try {
25
+ return { node: realpathSync(runtime.node), script: realpathSync(runtime.script) };
26
+ }
27
+ catch {
28
+ throw new UsageError('Open shard from its installed CLI to start the Shardflux window.');
29
+ }
30
+ }
22
31
  export class LinuxDesktopManager {
23
32
  home;
24
33
  app;
@@ -117,6 +126,7 @@ export class LinuxDesktopManager {
117
126
  throw new UsageError('Install the window with npm i -g shardflux --include=optional, then run shard.');
118
127
  if (!graphicalSession(this.#env))
119
128
  throw new UsageError('Open shard from your desktop session to show the window.');
129
+ const stable = stableDesktopRuntime(runtime);
120
130
  const current = await this.status(dir);
121
131
  if (!current.version || (compareVersions(current.version, version) ?? -1) < 0) {
122
132
  if (existsSync(this.app) && (lstatSync(this.app).isSymbolicLink() || !current.version))
@@ -148,11 +158,14 @@ export class LinuxDesktopManager {
148
158
  }
149
159
  }
150
160
  mkdirSync(dir, { recursive: true, mode: 0o700 });
151
- const env = { ...this.#env, SHARDFLUX_CONFIG_DIR: dir, SHARDFLUX_API_URL: api, SHARDFLUX_NODE: runtime.node, SHARDFLUX_CLI: runtime.script };
161
+ const env = { ...this.#env, SHARDFLUX_CONFIG_DIR: dir, SHARDFLUX_API_URL: api, SHARDFLUX_NODE: stable.node, SHARDFLUX_CLI: stable.script };
152
162
  const desktop = join(this.home, '.local/share/applications/dev.shardflux.Desktop.desktop');
153
163
  mkdirSync(dirname(desktop), { recursive: true });
154
- const args = ['/usr/bin/env', `SHARDFLUX_CONFIG_DIR=${dir}`, `SHARDFLUX_API_URL=${api}`, `SHARDFLUX_NODE=${runtime.node}`, `SHARDFLUX_CLI=${runtime.script}`, join(this.app, 'shardflux-desktop')];
164
+ const args = ['/usr/bin/env', `SHARDFLUX_CONFIG_DIR=${dir}`, `SHARDFLUX_API_URL=${api}`, `SHARDFLUX_NODE=${stable.node}`, `SHARDFLUX_CLI=${stable.script}`, join(this.app, 'shardflux-desktop')];
155
165
  writeFileSync(desktop, `[Desktop Entry]\nType=Application\nName=Shardflux\nComment=Your agents, a shortcut away\nExec=${args.map(desktopString).join(' ')}\nIcon=${join(this.app, 'ui/assets/shardflux-icon.png')}\nTerminal=false\nCategories=Development;\nStartupWMClass=dev.shardflux.Desktop\n`, { mode: 0o644 });
166
+ const autostart = join(this.home, '.config/autostart/shardflux.desktop');
167
+ if (existsSync(autostart))
168
+ cpSync(desktop, autostart);
156
169
  if ((await this.status(dir)).running)
157
170
  await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', view], { env, timeout: 5_000 });
158
171
  else {
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CheckoutStatus, ExecSession, ExecutionResult } from '@shardflux/sdk';
1
+ import type { RateLimitInfo, CheckoutStatus, ExecSession, ExecutionResult } from '@shardflux/sdk';
2
2
  export declare const EXIT: {
3
3
  readonly ok: 0;
4
4
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -145,6 +145,7 @@ export interface ErrorInfo {
145
145
  execution_id?: string;
146
146
  /** The refusal's Retry-After in seconds (0.9.0+), e.g. 429 quota_exceeded from the cell gateway. */
147
147
  retry_after_seconds?: number;
148
+ rate_limit?: RateLimitInfo;
148
149
  }
149
150
  /**
150
151
  * Removes anything shaped like a Shardflux API key or user session token from text, and each of the given secrets
package/dist/errors.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * stderr with the API's error code, message and request id; the process exit
4
4
  * code tells scripts what kind of failure it was (README "Exit codes").
5
5
  */
6
- import { CheckoutTimeoutError, DurabilityLostError, ExecStartError, FilesystemSaveFailedError, FilesystemSaveTimeoutError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
6
+ import { budgetHint, CheckoutTimeoutError, DurabilityLostError, ExecStartError, FilesystemSaveFailedError, FilesystemSaveTimeoutError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
7
7
  export const EXIT = {
8
8
  ok: 0,
9
9
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -212,7 +212,9 @@ export function planDiskLimit(failure) {
212
212
  /** Bytes as the plan names a disk: `2 GiB`, `2.5 GiB`. */
213
213
  export const gib = (bytes) => `${Number((bytes / 2 ** 30).toFixed(2))} GiB`;
214
214
  export function ownHint(err) {
215
- return err instanceof HintedError ? err.hint : undefined;
215
+ if (err instanceof ShardfluxApiError && err.reason === 'codex_chatgpt_login_required')
216
+ return 'Sign in to Codex with ChatGPT using codex login, then copy your Codex login with shard setup again.';
217
+ return budgetHint(err) ?? (err instanceof HintedError ? err.hint : undefined);
216
218
  }
217
219
  function isNamed(err, name) {
218
220
  return typeof err === 'object' && err !== null && err.name === name;
@@ -238,6 +240,7 @@ export function describeError(err) {
238
240
  ...(err.operationId ? { operation_id: err.operationId } : {}),
239
241
  ...(err.details ? { details: err.details } : {}),
240
242
  source: err.source,
243
+ ...(err.rateLimit ? { rate_limit: err.rateLimit } : {}),
241
244
  ...(err.retryAfterSeconds !== undefined ? { retry_after_seconds: err.retryAfterSeconds } : {}),
242
245
  };
243
246
  }
@@ -8,10 +8,32 @@ import { AGENT_HANDLERS } from "./agent-commands.js";
8
8
  import { readSetupProgress } from "./assisted-setup.js";
9
9
  import { syncAgentConfig } from "./agent-config-sync.js";
10
10
  import { progress } from "./progress.js";
11
+ import { getConversation, localConversations } from "./conversations.js";
11
12
  function remote() {
12
13
  const project = projectIdentity('./');
13
14
  return { project, repoRemote: project.remote ?? normalizeRemote(`file://${project.folder}`) };
14
15
  }
16
+ /**
17
+ * The VM this computer saves a project's template from (0.23.0+): the newest conversation this checkout started whose
18
+ * VM is there now, else the project's workspace (`<project>/main`, setups from before conversations). Each
19
+ * conversation's VM is read from the API: a restore by any client moves it to a new VM. In a VM, `shard template save`
20
+ * saves that VM itself.
21
+ */
22
+ async function templateSource(ctx, project) {
23
+ const gone = (error) => { if (error instanceof ShardfluxApiError && error.status === 404)
24
+ return undefined; throw error; };
25
+ const started = Object.values(localConversations(ctx)).filter(r => r.folder === project.folder && r.api_url === ctx.apiUrl)
26
+ .sort((a, b) => Date.parse(b.conversation.created_at) - Date.parse(a.conversation.created_at));
27
+ for (const r of started) {
28
+ const c = await getConversation(ctx, r.conversation.id).catch(gone);
29
+ if (!c || c.ended_at || !c.workspace_id)
30
+ continue;
31
+ const ws = await ctx.cloud().workspaces.get(c.workspace_id, { signal: ctx.signal }).catch(gone);
32
+ if (ws && !ws.data.deleted_at)
33
+ return ws;
34
+ }
35
+ return await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal }) ?? undefined;
36
+ }
15
37
  /**
16
38
  * One line per status: what is in use and what is saving or failed, its platform base and setup cache. A project
17
39
  * template unused for 30 days (rebase_deferred, 0.20.1+) follows the machine when the next conversation starts.
@@ -202,7 +224,7 @@ export const FILESYSTEM_HANDLERS = {
202
224
  async template(ctx) {
203
225
  const { project, repoRemote } = remote();
204
226
  const state = await ctx.cloud().templates.getProject(repoRemote, { signal: ctx.signal });
205
- const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
227
+ const ws = await templateSource(ctx, project);
206
228
  const progress = ws ? await readSetupProgress(ctx, ws.data) : undefined;
207
229
  const attachments = ws ? await ctx.cloud().volumes.listAttached(ws.id) : undefined;
208
230
  const mounted = attachments?.data.some((a) => a.mount_path === '/mnt/shardflux-cache' && a.mounted && a.volume.name === 'shardflux-setup-cache' && a.volume.org_shared);
@@ -210,9 +232,9 @@ export const FILESYSTEM_HANDLERS = {
210
232
  },
211
233
  async 'template save'(ctx, values) {
212
234
  const { project, repoRemote } = remote();
213
- const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
235
+ const ws = await templateSource(ctx, project);
214
236
  if (!ws)
215
- throw new UsageError('Run shard ./ for this project before saving its template');
237
+ throw new UsageError('Start a conversation in this project with shard ./ before saving its template', 'or run shard template save in a conversation\'s VM');
216
238
  return saved(ctx, values, options => ctx.cloud().templates.saveProject({ repoRemote, workspaceId: ws.id }, options), () => ctx.cloud().templates.preflightProject({ workspaceId: ws.id }, { signal: ctx.signal }), 'template');
217
239
  },
218
240
  async 'template rollback'(ctx, values) {
package/dist/menubar.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { Handler } from './commands.js';
2
- export type NativeRunner = (tool: string, args: string[]) => Promise<string>;
2
+ /** `env` adds variables to this process's environment for the tool (the app's --control reads its config directory). */
3
+ export type NativeRunner = (tool: string, args: string[], env?: Record<string, string>) => Promise<string>;
3
4
  /**
4
5
  * Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
5
6
  * `nativeTools.run`. The CLI test support replaces it, so a test run never starts, stops or inspects a real app,
6
7
  * whether or not a built Shardflux.app is in the package.
7
8
  */
8
9
  export declare const nativeTools: {
9
- run: (tool: string, args: string[], signal?: AbortSignal) => Promise<string>;
10
+ run: (tool: string, args: string[], signal?: AbortSignal, env?: Record<string, string>) => Promise<string>;
10
11
  };
11
12
  export interface MenubarStatus {
12
13
  installed: boolean;
@@ -57,9 +58,9 @@ export declare class MenubarManager {
57
58
  packagedVersion(): Promise<string>;
58
59
  /** Whether the app shows any window (see windowsOpen); null when that could not be read. */
59
60
  windowsOpen(pids: number[]): Promise<boolean | null>;
60
- status(): Promise<MenubarStatus>;
61
- stop(): Promise<MenubarStatus>;
62
- login(value: 'on' | 'off'): Promise<MenubarStatus>;
61
+ status(configDir?: string): Promise<MenubarStatus>;
62
+ stop(configDir?: string): Promise<MenubarStatus>;
63
+ login(value: 'on' | 'off', configDir?: string): Promise<MenubarStatus>;
63
64
  start(configDir: string, apiUrl: string): Promise<MenubarStatus>;
64
65
  /**
65
66
  * Brings an installed app up to the one this CLI's npm package carries, without disturbing the person: nothing when