@shardflux/cli 0.20.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 (124) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +242 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-journal.js +58 -6
  10. package/dist/acp-login.d.ts +13 -5
  11. package/dist/acp-login.js +37 -8
  12. package/dist/acp-models.d.ts +6 -1
  13. package/dist/acp-models.js +29 -19
  14. package/dist/acp.js +120 -14
  15. package/dist/agent-commands.d.ts +4 -1
  16. package/dist/agent-commands.js +110 -42
  17. package/dist/agent-config-return.d.ts +46 -2
  18. package/dist/agent-config-return.js +110 -4
  19. package/dist/agent-credentials.js +7 -3
  20. package/dist/agent-repository.d.ts +16 -3
  21. package/dist/agent-repository.js +149 -74
  22. package/dist/agent-setup.d.ts +14 -3
  23. package/dist/agent-setup.js +78 -36
  24. package/dist/agent-target-commands.d.ts +20 -1
  25. package/dist/agent-target-commands.js +73 -31
  26. package/dist/agent-targets.js +6 -1
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +95 -54
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-hub.d.ts +61 -25
  35. package/dist/cli-proxy-hub.js +202 -54
  36. package/dist/cli-proxy.d.ts +44 -1
  37. package/dist/cli-proxy.js +177 -28
  38. package/dist/client-settings.d.ts +39 -0
  39. package/dist/client-settings.js +91 -0
  40. package/dist/codex-cloud.d.ts +14 -4
  41. package/dist/codex-cloud.js +135 -31
  42. package/dist/codex-linux.d.ts +14 -0
  43. package/dist/codex-linux.js +69 -0
  44. package/dist/command-center-actions.js +18 -5
  45. package/dist/command-center-model.d.ts +2 -0
  46. package/dist/command-center-model.js +6 -1
  47. package/dist/command-center.js +3 -3
  48. package/dist/commands.js +173 -24
  49. package/dist/conversation-commands.d.ts +5 -0
  50. package/dist/conversation-commands.js +8 -5
  51. package/dist/conversation-terminal.d.ts +10 -2
  52. package/dist/conversation-terminal.js +78 -13
  53. package/dist/conversations.d.ts +86 -1
  54. package/dist/conversations.js +306 -23
  55. package/dist/desktop-linux.js +16 -3
  56. package/dist/device-bridge.d.ts +1 -1
  57. package/dist/device-bridge.js +6 -4
  58. package/dist/device-helper.js +43 -22
  59. package/dist/errors.d.ts +29 -2
  60. package/dist/errors.js +50 -7
  61. package/dist/filesystem-commands.d.ts +16 -9
  62. package/dist/filesystem-commands.js +133 -57
  63. package/dist/format.d.ts +2 -1
  64. package/dist/format.js +10 -1
  65. package/dist/handlers.js +3 -1
  66. package/dist/initial-prompt.d.ts +8 -0
  67. package/dist/initial-prompt.js +43 -1
  68. package/dist/large-media.d.ts +14 -0
  69. package/dist/large-media.js +100 -0
  70. package/dist/linux-terminal.d.ts +5 -3
  71. package/dist/linux-terminal.js +6 -20
  72. package/dist/local-sessions.d.ts +26 -2
  73. package/dist/local-sessions.js +113 -24
  74. package/dist/machine-setup.d.ts +11 -1
  75. package/dist/machine-setup.js +69 -22
  76. package/dist/machine-status.d.ts +67 -0
  77. package/dist/machine-status.js +140 -0
  78. package/dist/main.js +11 -13
  79. package/dist/menubar.d.ts +6 -5
  80. package/dist/menubar.js +38 -28
  81. package/dist/move-worktrees-restore.py +144 -0
  82. package/dist/move-worktrees.d.ts +69 -0
  83. package/dist/move-worktrees.js +252 -0
  84. package/dist/move.d.ts +10 -1
  85. package/dist/move.js +111 -16
  86. package/dist/onboarding.d.ts +11 -7
  87. package/dist/onboarding.js +63 -29
  88. package/dist/prepared-repository.d.ts +204 -0
  89. package/dist/prepared-repository.js +860 -0
  90. package/dist/prepared-repository.py +1307 -0
  91. package/dist/project-preparation.d.ts +62 -0
  92. package/dist/project-preparation.js +316 -0
  93. package/dist/project-sync-manifest.d.ts +4 -0
  94. package/dist/project-sync-manifest.js +5 -1
  95. package/dist/project-sync-transfer.js +1 -1
  96. package/dist/project-sync.d.ts +22 -4
  97. package/dist/project-sync.js +34 -8
  98. package/dist/project-template-setup.d.ts +33 -1
  99. package/dist/project-template-setup.js +59 -2
  100. package/dist/secret-ask.d.ts +72 -0
  101. package/dist/secret-ask.js +351 -0
  102. package/dist/seed-phases.d.ts +8 -1
  103. package/dist/seed-phases.js +17 -2
  104. package/dist/setup-cache.d.ts +7 -0
  105. package/dist/setup-cache.js +32 -0
  106. package/dist/t3-launch.d.ts +2 -1
  107. package/dist/t3-launch.js +12 -6
  108. package/dist/t3-mode.d.ts +8 -2
  109. package/dist/t3-mode.js +19 -4
  110. package/dist/t3-phone.d.ts +4 -2
  111. package/dist/t3-phone.js +12 -4
  112. package/dist/t3-proxy.d.ts +81 -0
  113. package/dist/t3-proxy.js +325 -0
  114. package/dist/terminal-apps.d.ts +168 -0
  115. package/dist/terminal-apps.js +574 -0
  116. package/dist/terminal-onboarding.js +6 -1
  117. package/dist/this-computer.d.ts +11 -0
  118. package/dist/this-computer.js +36 -0
  119. package/dist/thread-on-computer.js +4 -1
  120. package/dist/update.d.ts +2 -0
  121. package/dist/update.js +43 -9
  122. package/dist/worktree-twin.d.ts +10 -2
  123. package/dist/worktree-twin.js +27 -9
  124. package/package.json +5 -5
@@ -1,6 +1,8 @@
1
1
  import type { CellClient, Workspace, WorkspaceView } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
+ import type { LocalChanges } from './agent-repository.js';
3
4
  import type { ModelChoice } from './agent-models.js';
5
+ import type { PreparedApply, PreparedTransfer } from './prepared-repository.js';
4
6
  export type Harness = 'claude' | 'codex';
5
7
  export interface Conversation {
6
8
  id: string;
@@ -62,6 +64,33 @@ export interface LocalConversation {
62
64
  handed_off?: boolean;
63
65
  /** The branch the conversation started from (0.16.0+), for APIs that do not return base_branch yet. */
64
66
  base_branch?: string;
67
+ /**
68
+ * The prepared revision this conversation starts from (0.22.0+): captured while its VM was allocated and kept until
69
+ * the start is done, so a start resumed after a deadline uses exactly that state. `deferred`: large files still
70
+ * following the first prompt.
71
+ */
72
+ preparation?: {
73
+ revision: string;
74
+ cache: string;
75
+ state: 'accepted' | 'syncing' | 'deferred' | 'ready' | 'error';
76
+ error?: string;
77
+ workspace?: string;
78
+ transfer?: PreparedTransfer;
79
+ apply?: PreparedApply;
80
+ pending?: string[];
81
+ };
82
+ cloud_start?: {
83
+ prompt?: string;
84
+ computer: boolean;
85
+ attachments_pending: boolean;
86
+ submitted?: boolean;
87
+ state: 'accepted' | 'active' | 'error';
88
+ error?: string;
89
+ /** When the process carrying this start last said so (carryStart): the device helper resumes only one nobody carries. */
90
+ heartbeat_at?: number;
91
+ };
92
+ /** Why this start left its prepared revision for the copy seed. */
93
+ preparation_fallback?: string;
65
94
  }
66
95
  /**
67
96
  * Whether the conversation has ended: its VM was reclaimed, or it was archived or discarded. A finished turn is `done`
@@ -82,6 +111,9 @@ export declare function rememberConversation(ctx: Pick<Ctx, 'store' | 'apiUrl'>,
82
111
  export declare function conversationsUnavailable(e: unknown): boolean;
83
112
  export declare function getConversation(ctx: Ctx, id: string): Promise<Conversation>;
84
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>;
85
117
  export declare function resolveConversation(ctx: Ctx, ref: string, folder?: string): Promise<Conversation>;
86
118
  /** Re-resolve the binding on every attach: a move changes the VM, never the conversation identity. */
87
119
  export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promise<Workspace>;
@@ -95,14 +127,67 @@ export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws
95
127
  export interface MovedInSession {
96
128
  session_id: string;
97
129
  title?: string;
130
+ preserveWorktrees?: boolean;
98
131
  }
99
- 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): Promise<Conversation>;
132
+ /**
133
+ * `ready`: checks the caller runs beside this local work (a sign-in check, 0.21.1+); the VM is requested only after
134
+ * they pass. The conversation API's availability is read meanwhile too.
135
+ */
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[];
142
+ /**
143
+ * Brings the conversation's accepted revision into its VM (0.22.0+): only what the project cache lacks is sent, and
144
+ * with `defer` large incompressible files follow the first prompt. Resumable: a later call (the device helper, or
145
+ * `shard agents open`) continues the same revision, never a newer checkout. False when the start has no revision.
146
+ */
147
+ export declare function prepareAcceptedConversation(ctx: Ctx, c: Conversation, ws: Workspace, options?: {
148
+ defer?: boolean;
149
+ fallback?: boolean;
150
+ setup?: Promise<{
151
+ depsHash: string;
152
+ } | undefined>;
153
+ landNow?: boolean;
154
+ }): Promise<boolean>;
155
+ /**
156
+ * Sends a conversation's deferred large files and lands them in its worktree. Runs while its VM is awake (it never
157
+ * wakes one); the device helper retries until the start is whole.
158
+ */
159
+ export declare function completeDeferredPreparation(ctx: Ctx, c: Conversation): Promise<boolean>;
160
+ /**
161
+ * The one-time seed of a new conversation's worktree (a conversation never watches or writes the owner's checkout):
162
+ * its prepared revision (0.22.0+), or the copy seed when the start has none or the VM cannot take one. Repeatable: a
163
+ * start resumed after a deadline continues here.
164
+ */
165
+ export declare function seedConversation(ctx: Ctx, c: Conversation, ws: Workspace, folder: string, movedIn?: MovedInSession & {
166
+ seedRepository?(ws: Workspace, c: Conversation): Promise<void>;
167
+ }, changes?: Promise<LocalChanges>, setup?: Promise<{
168
+ depsHash: string;
169
+ } | undefined>, editor?: {
170
+ initialFiles?: readonly string[];
171
+ onInitialFiles?: () => void;
172
+ landNow?: boolean;
173
+ }): Promise<void>;
100
174
  export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, choice?: ModelChoice, movedIn?: MovedInSession & {
101
175
  beforeSeed(ws: Workspace, c: Conversation): Promise<void>;
176
+ seedRepository?(ws: Workspace, c: Conversation): Promise<void>;
177
+ }, setup?: Promise<{
178
+ depsHash: string;
179
+ } | undefined>, ready?: Promise<unknown>, startup?: {
180
+ accepted(c: Conversation): void;
181
+ beforeSeed(ws: Workspace, c: Conversation): Promise<void>;
102
182
  }): Promise<{
103
183
  conversation: Conversation;
104
184
  workspace: Workspace;
105
185
  }>;
186
+ /**
187
+ * The conversation branch, then the start state's snapshot, in one request (0.21.1+): a failed checkout fails the
188
+ * start, a failed snapshot only leaves it without one (recordSeedSnapshot). Its hub push stays detached in the VM.
189
+ */
190
+ export declare function checkoutConversationBranch(ctx: Pick<Ctx, 'store' | 'signal'>, cell: Pick<CellClient, 'exec'>, c: Conversation): Promise<void>;
106
191
  export declare function resumeConversation(ctx: Ctx, c: Conversation): Promise<Conversation>;
107
192
  /** The model and effort a conversation's harness starts with, as its record keeps them. */
108
193
  export declare const recordedChoice: (c: Pick<Conversation, "model" | "effort">) => ModelChoice;
@@ -4,13 +4,19 @@ import { AgentStateStore } from "./agent-state.js";
4
4
  import { gitRunner } from "./agent-git.js";
5
5
  import { projectIdentity } from "./project-identity.js";
6
6
  import { ensureProjectGit } from "./project-git.js";
7
- import { preflightProjectSeed, seedAgentRepository } from "./agent-repository.js";
8
- import { UsageError } from "./errors.js";
7
+ import { localChanges, preflightProjectSeed, runGitStep, seedAgentRepository } from "./agent-repository.js";
8
+ import { CodedError, UsageError } from "./errors.js";
9
9
  import { conversationClient } from "./conversation-commands.js";
10
- import { recordSeedSnapshot } from "./files-mirror.js";
10
+ import { HUB_PATH, recordSeedSnapshot, SEED_SNAPSHOT } from "./files-mirror.js";
11
11
  import { rememberTwinSeed, sendTwinSeed, twinCheckoutArgv, twinSeed } from "./worktree-twin.js";
12
12
  import { progress } from "./progress.js";
13
13
  import { seedPhase, startProgress } from "./seed-phases.js";
14
+ import { storedTitlePrefix } from "./client-settings.js";
15
+ import { applyPreparedSnapshot, capturePreparedSnapshot, landDeferredFiles, preparedCachePath, preparedGitConfig, PreparedRefusal, pruneLocalPrepared, readPreparedSnapshot, releaseLocalSnapshot, transferPreparedSnapshot } from "./prepared-repository.js";
16
+ import { withTargetLock } from "./target-lock.js";
17
+ import { join } from 'node:path';
18
+ import { acceptedProgress } from "./seed-phases.js";
19
+ import { recordSetupIntent } from "./project-template-setup.js";
14
20
  /**
15
21
  * Whether the conversation has ended: its VM was reclaimed, or it was archived or discarded. A finished turn is `done`
16
22
  * while its VM still takes the next turn; on Codex Cloud, `done` ends the conversation.
@@ -68,7 +74,7 @@ export function rememberConversation(ctx, c, folder, handedOff = false, baseBran
68
74
  new AgentStateStore(ctx.store.dir).update(doc => {
69
75
  const records = doc.conversations ?? {};
70
76
  const base = baseBranch ?? records[c.id]?.base_branch;
71
- records[c.id] = { api_url: ctx.apiUrl, folder, project_id: c.project_id, conversation: c, ...(handedOff ? { handed_off: true } : {}), ...(base ? { base_branch: base } : {}) };
77
+ records[c.id] = { ...records[c.id], api_url: ctx.apiUrl, folder, project_id: c.project_id, conversation: c, ...(handedOff ? { handed_off: true } : {}), ...(base ? { base_branch: base } : {}) };
72
78
  doc.conversations = records;
73
79
  });
74
80
  }
@@ -90,6 +96,41 @@ export async function listConversations(ctx, projectId) {
90
96
  } while (cursor);
91
97
  return all;
92
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
+ }
93
134
  export async function resolveConversation(ctx, ref, folder) {
94
135
  if (ref.startsWith('conv_'))
95
136
  return getConversation(ctx, ref);
@@ -157,58 +198,287 @@ export async function restoreConversationBinding(ctx, c, ws) {
157
198
  if (c.restore.kind === 'resume')
158
199
  progress(ctx, 'Restored');
159
200
  }
160
- export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}, movedIn) {
201
+ /**
202
+ * `ready`: checks the caller runs beside this local work (a sign-in check, 0.21.1+); the VM is requested only after
203
+ * they pass. The conversation API's availability is read meanwhile too.
204
+ */
205
+ export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}, movedIn, ready) {
161
206
  const project = projectIdentity(folder);
162
207
  await ensureProjectGit(ctx, project.folder);
163
208
  // A start refuses before a VM is allocated, never after (0.19.0+).
164
209
  await preflightProjectSeed(project.folder, ctx.signal);
165
- // Both the conversation API and its legacy workspace fallback need a usable local HEAD.
166
- await conversationClient(ctx).conversations.list({ limit: 1 });
210
+ // Both the conversation API and its legacy workspace fallback need a usable local HEAD. The API's answer (a legacy
211
+ // control plane refuses) is awaited before the create, after the local snapshot it runs beside.
212
+ const probe = conversationClient(ctx).conversations.list({ limit: 1 }).then(() => undefined, (error) => ({ error }));
167
213
  const git = gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal);
168
214
  const head = await git(['rev-parse', 'HEAD']);
169
215
  const branch = await git(['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => undefined);
170
216
  // Uncommitted edits ride along as the twin snapshot the conversation starts from (worktree-twin.ts).
171
- const seed = await twinSeed(ctx.store.dir, project.folder, head, ctx.store.profile(ctx.apiUrl).project_id).catch(() => ({ commit: head, snapshot: false }));
217
+ const seed = movedIn?.preserveWorktrees ? { commit: head, snapshot: false }
218
+ : await twinSeed(ctx.store.dir, project.folder, head, ctx.store.profile(ctx.apiUrl).project_id).catch(() => ({ commit: head, snapshot: false }));
172
219
  seedPhase('local_snapshot');
220
+ const refused = await probe;
221
+ if (refused)
222
+ throw refused.error;
223
+ await ready;
224
+ seedPhase('api_probe');
173
225
  const commit = seed.commit;
226
+ // The prepared revision of that state (0.22.0+): its media and what Git does not hold (.env files, filter output),
227
+ // captured while the VM is requested and boots. A capture that cannot complete falls back to the copy seed, never fails
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) => {
230
+ if (ctx.signal.aborted)
231
+ throw error;
232
+ ctx.io.stderr.write(`Project snapshot: ${error instanceof Error ? error.message : String(error)}; copying the project instead.\n`);
233
+ return undefined;
234
+ });
235
+ capture.catch(() => undefined);
236
+ // The title marker this computer chose (`shard settings --title-prefix`, 0.20.1+); none sent means the platform's.
237
+ const titlePrefix = storedTitlePrefix(ctx.store.dir);
174
238
  const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
175
239
  base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id,
240
+ ...(titlePrefix !== undefined ? { titlePrefix } : {}),
176
241
  ...(movedIn ? { harnessSessionId: movedIn.session_id, ...(movedIn.title ? { title: movedIn.title.slice(0, 200) } : {}) } : {}) }, { idempotencyKey, signal: ctx.signal });
177
242
  const c = validateConversation({ ...response.conversation, restore: response.restore });
178
243
  allocated?.(response.workspace);
179
244
  rememberTwinSeed(ctx.store.dir, c.id, project.folder, seed, branch);
180
245
  rememberConversation(ctx, c, project.folder, false, branch);
246
+ // The seed awaits the capture (prepareAcceptedConversation), so it runs on while the VM boots, with the Git settings
247
+ // the VM repository copies from this one.
248
+ pendingCaptures.set(c.id, capture.then(async (prepared) => {
249
+ seedPhase('capture_done');
250
+ if (!prepared)
251
+ return undefined;
252
+ new AgentStateStore(ctx.store.dir).update(doc => {
253
+ const record = doc.conversations[c.id];
254
+ if (record)
255
+ record.preparation = { revision: prepared.revision, cache: preparedCachePath(project.remote ?? `file://${project.folder}`), state: 'accepted' };
256
+ });
257
+ return { snapshot: prepared, git: await preparedGitConfig(project.folder, prepared.manifest, ctx.signal) };
258
+ }));
259
+ acceptedProgress(c.id, c.key);
181
260
  return c;
182
261
  }
183
- export async function createConversation(ctx, folder, harness, route, name, choice = {}, movedIn) {
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
+ }
270
+ /** Captures still running for conversations this process created (createConversationRecord). */
271
+ const pendingCaptures = new Map();
272
+ /** A start the copy seed took over: its prepared revision is no longer this conversation's (recorded as the reason). */
273
+ function dropPreparation(ctx, id, reason) {
274
+ new AgentStateStore(ctx.store.dir).update(doc => {
275
+ const record = doc.conversations[id];
276
+ if (record?.preparation) {
277
+ delete record.preparation;
278
+ record.preparation_fallback = reason;
279
+ }
280
+ });
281
+ }
282
+ function updatePreparation(ctx, id, patch) {
283
+ new AgentStateStore(ctx.store.dir).update(doc => {
284
+ const record = doc.conversations[id];
285
+ if (record?.preparation)
286
+ record.preparation = { ...record.preparation, ...patch };
287
+ });
288
+ }
289
+ const errorText = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 2000);
290
+ /** The template checkout a conversation outside it copies dependency folders from (a Codex Cloud worktree). */
291
+ const templateCheckout = (c) => `/home/user/${c.key.split('/')[0]}`;
292
+ /**
293
+ * Brings the conversation's accepted revision into its VM (0.22.0+): only what the project cache lacks is sent, and
294
+ * with `defer` large incompressible files follow the first prompt. Resumable: a later call (the device helper, or
295
+ * `shard agents open`) continues the same revision, never a newer checkout. False when the start has no revision.
296
+ */
297
+ export async function prepareAcceptedConversation(ctx, c, ws, options = {}) {
298
+ const capturing = pendingCaptures.get(c.id);
299
+ let captured;
300
+ if (capturing) {
301
+ captured = await capturing.catch(() => undefined);
302
+ pendingCaptures.delete(c.id);
303
+ seedPhase('prepared_snapshot');
304
+ }
305
+ // The setup intent goes in the exec that checks the repository out, before the repository appears (0.22.0+).
306
+ const intent = await options.setup;
307
+ const local = localConversations(ctx)[c.id];
308
+ if (!local?.preparation)
309
+ return false;
310
+ if (local.preparation.state === 'ready' || local.preparation.state === 'deferred')
311
+ return true;
312
+ return withTargetLock(join(ctx.store.dir, 'prepared-starts', c.id), async () => {
313
+ const current = localConversations(ctx)[c.id]?.preparation;
314
+ if (!current)
315
+ return false;
316
+ if (current.state === 'ready' || current.state === 'deferred')
317
+ return true;
318
+ try {
319
+ updatePreparation(ctx, c.id, { state: 'syncing', error: undefined });
320
+ const fresh = captured?.snapshot.revision === current.revision ? captured : undefined;
321
+ const snapshot = fresh?.snapshot ?? await readPreparedSnapshot(ctx.store.dir, local.folder, current.revision);
322
+ const cell = ws.cell(), outside = c.worktree_path !== templateCheckout(c);
323
+ // The seed's copy to the project hub starts in the same exec as the checkout, detached (no extra round trip).
324
+ const target = { root: c.worktree_path, branch: c.branch, options: { signal: ctx.signal, adopt: true,
325
+ git: fresh?.git ?? await preparedGitConfig(local.folder, snapshot.manifest, ctx.signal), ...(outside ? { dependencySource: templateCheckout(c) } : {}),
326
+ after: { script: SEED_SNAPSHOT, args: [c.worktree_path, c.branch.replace(/^shard\//, ''), HUB_PATH, 'background'] },
327
+ ...(intent ? { setupIntent: { conversation: c.id, depsHash: intent.depsHash } } : {}), workspace: ws.id, ...(options.landNow ? { landNow: true } : {}) } };
328
+ let transfer, apply;
329
+ try {
330
+ // A warm start: the probe checks the revision out in its own exec.
331
+ transfer = await transferPreparedSnapshot(cell, local.folder, current.cache, snapshot, { signal: ctx.signal, defer: options.defer ?? true, pin: c.id, apply: target });
332
+ seedPhase('prepared_transfer');
333
+ apply = transfer.applied ?? await applyPreparedSnapshot(cell, current.cache, current.revision, c.worktree_path, c.branch, { ...target.options, ...(transfer.complete ? { releasePin: c.id } : {}) });
334
+ }
335
+ catch (error) {
336
+ // No agent has this worktree yet: a VM that cannot take a prepared revision (a custom template without Python
337
+ // 3.11, say) still starts, through the copy seed, into a clean directory.
338
+ if (ctx.signal.aborted || !options.fallback || (error instanceof PreparedRefusal && error.reason === 'owned'))
339
+ throw error;
340
+ ctx.io.stderr.write(`Project transfer: ${errorText(error)}; copying the project instead.\n`);
341
+ // The copy seed owns this start from here: nothing resumes the prepared one onto its worktree.
342
+ dropPreparation(ctx, c.id, errorText(error));
343
+ await releaseLocalSnapshot(local.folder, current.revision).catch(() => undefined);
344
+ // A fresh start leaves nothing at the path; a checkout taken over in place (a template's) is set aside.
345
+ await cell.exec.run(['sh', '-c', 'if [ -e "$1" ]; then mv "$1" "$1.unfinished-$(date +%s)"; fi', 'shard-seed', c.worktree_path], { timeoutMs: 30_000, signal: ctx.signal });
346
+ return false;
347
+ }
348
+ seedPhase('prepared_apply');
349
+ updatePreparation(ctx, c.id, { state: transfer.complete ? 'ready' : 'deferred', transfer, apply, pending: transfer.pending, error: undefined, workspace: ws.id });
350
+ if (transfer.complete)
351
+ await releaseLocalSnapshot(local.folder, current.revision);
352
+ return true;
353
+ }
354
+ catch (error) {
355
+ updatePreparation(ctx, c.id, { state: 'error', error: errorText(error) });
356
+ throw error;
357
+ }
358
+ }, ctx.signal);
359
+ }
360
+ /**
361
+ * Sends a conversation's deferred large files and lands them in its worktree. Runs while its VM is awake (it never
362
+ * wakes one); the device helper retries until the start is whole.
363
+ */
364
+ export async function completeDeferredPreparation(ctx, c) {
365
+ const local = localConversations(ctx)[c.id];
366
+ if (local?.preparation?.state !== 'deferred' || conversationEnded(c))
367
+ return false;
368
+ return withTargetLock(join(ctx.store.dir, 'prepared-starts', c.id), async () => {
369
+ const current = localConversations(ctx)[c.id]?.preparation;
370
+ if (current?.state !== 'deferred')
371
+ return false;
372
+ let ws;
373
+ try {
374
+ ws = await conversationClient(ctx).workspaces.get(c.workspace_id);
375
+ }
376
+ catch (error) {
377
+ if (!(error instanceof ShardfluxApiError && error.status === 404))
378
+ throw error;
379
+ }
380
+ if (!ws || ws.data.deleted_at || (current.workspace && current.workspace !== c.workspace_id)) {
381
+ // The VM is gone (reclaimed, moved, deleted) or the conversation continues in another one (a resume restores its
382
+ // worktree there): there is no worktree left to complete.
383
+ updatePreparation(ctx, c.id, { state: 'error', error: 'The conversation\'s VM was removed before its media arrived.', pending: undefined });
384
+ await releaseLocalSnapshot(local.folder, current.revision);
385
+ return false;
386
+ }
387
+ if (!ws.ready)
388
+ return false;
389
+ const snapshot = await readPreparedSnapshot(ctx.store.dir, local.folder, current.revision);
390
+ const cell = ws.cell();
391
+ // Staged into the conversation's own repository and landed before this returns; the cache gets them after.
392
+ const transfer = await transferPreparedSnapshot(cell, local.folder, current.cache, snapshot, { signal: ctx.signal, pin: c.id,
393
+ apply: { root: c.worktree_path, branch: c.branch, options: { adopt: true } } });
394
+ // Media edited on this computer after the start never arrive as captured: the worktree goes on without them.
395
+ const drop = snapshot.manifest.files.filter(f => transfer.changed.includes(f.path)).map(f => f.oid);
396
+ const landed = await landDeferredFiles(cell, current.cache, current.revision, c.worktree_path, { signal: ctx.signal, drop });
397
+ if (landed.pending)
398
+ return false;
399
+ updatePreparation(ctx, c.id, { state: 'ready', transfer: { ...transfer, pending: current.pending ?? [] }, pending: undefined });
400
+ await releaseLocalSnapshot(local.folder, current.revision);
401
+ await pruneLocalPrepared(ctx.store.dir).catch(() => undefined);
402
+ return true;
403
+ }, ctx.signal);
404
+ }
405
+ /**
406
+ * The one-time seed of a new conversation's worktree (a conversation never watches or writes the owner's checkout):
407
+ * its prepared revision (0.22.0+), or the copy seed when the start has none or the VM cannot take one. Repeatable: a
408
+ * start resumed after a deadline continues here.
409
+ */
410
+ export async function seedConversation(ctx, c, ws, folder, movedIn, changes, setup, editor = {}) {
411
+ // Requests the copy seed starts and nothing waits for (the history backfill's start), settled before this returns.
412
+ const background = [];
413
+ const cell = ws.cell();
414
+ // A conversation that sets its project up records it before its repository appears: its guest starts the agent as
415
+ // soon as the repository appears, then with the setup's context and toolchains (0.21.1+). A prepared start writes it
416
+ // in its checkout's exec; the copy seed beside its first request.
417
+ let recorded;
418
+ const recordIntent = () => recorded ??= (async () => {
419
+ const intent = await setup;
420
+ if (intent)
421
+ await recordSetupIntent(cell, c, intent.depsHash, ctx.signal).catch((error) => {
422
+ if (!ctx.signal.aborted)
423
+ ctx.io.stderr.write(`The agent starts without the project setup context: ${error instanceof Error ? error.message : String(error)}\n`);
424
+ });
425
+ })();
426
+ if (movedIn?.seedRepository) {
427
+ void recordIntent();
428
+ await movedIn.seedRepository(ws, c);
429
+ }
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 {
435
+ await seedAgentRepository(ctx, ws, folder, { root: c.worktree_path, populate: true, changes: changes ?? localChanges(folder, ctx.signal),
436
+ background: started => background.push(started), beforeHistory: recordIntent(),
437
+ ...(editor.initialFiles ? { initialFiles: editor.initialFiles } : {}), ...(editor.onInitialFiles ? { onInitialFiles: editor.onInitialFiles } : {}) });
438
+ }
439
+ await recorded;
440
+ seedPhase('seed_done');
441
+ const preparation = localConversations(ctx)[c.id]?.preparation?.state, prepared = preparation === 'ready' || preparation === 'deferred';
442
+ if (!prepared && !movedIn?.preserveWorktrees) {
443
+ await sendTwinSeed(ctx.store.dir, cell, c, folder, ctx.signal);
444
+ seedPhase('twin_seed');
445
+ await checkoutConversationBranch(ctx, cell, c);
446
+ seedPhase('branch_checkout');
447
+ }
448
+ else if (!prepared) {
449
+ await recordSeedSnapshot(ws, c, ctx.signal);
450
+ seedPhase('seed_snapshot');
451
+ }
452
+ await Promise.all(background);
453
+ // Large media that follow the first prompt keep coming while this process runs; the device helper finishes them.
454
+ if (preparation === 'deferred')
455
+ void completeDeferredPreparation(ctx, c).catch(() => undefined);
456
+ // This computer's spool and snapshots stay bounded with no selected project to prune them (hourly at most).
457
+ void pruneLocalPrepared(ctx.store.dir, undefined, 3_600_000).catch(() => undefined);
458
+ }
459
+ export async function createConversation(ctx, folder, harness, route, name, choice = {}, movedIn, setup, ready, startup) {
184
460
  const project = projectIdentity(folder);
185
461
  seedPhase('start');
186
462
  // The launcher's phase line (SHARDFLUX_PROGRESS_FILE, 0.19.0+): a computer starts, then the seed's own marks move it on.
187
463
  startProgress(harness, ctx.io.env);
188
- const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice, movedIn);
464
+ const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice, movedIn, ready);
465
+ startup?.accepted(c);
189
466
  seedPhase('conversation_create');
467
+ // A start without a prepared revision (a moved-in session) reads this computer's changes while its VM starts.
468
+ const changes = movedIn && !movedIn.seedRepository ? localChanges(project.folder, ctx.signal) : undefined;
469
+ changes?.catch(() => undefined);
190
470
  try {
191
471
  const ws = await conversationWorkspace(ctx, c);
192
472
  seedPhase('vm_running');
193
473
  // A moved-in session's transcript is in place before the repository: its guest resumes it once the seed is done.
194
474
  await movedIn?.beforeSeed(ws, c);
195
- // This is a one-time seed. A conversation never watches or writes the owner's checkout.
196
- await seedAgentRepository(ctx, ws, project.folder, { root: c.worktree_path, populate: true });
197
- const cell = ws.cell();
198
- seedPhase('seed_done');
199
- await sendTwinSeed(ctx.store.dir, cell, c, project.folder, ctx.signal);
200
- seedPhase('twin_seed');
201
- const result = await cell.exec.run(twinCheckoutArgv(ctx.store.dir, c), { timeoutMs: 30_000, signal: ctx.signal });
202
- if (result.exitCode !== 0)
203
- throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
204
- seedPhase('branch_checkout');
205
- await recordSeedSnapshot(ws, c, ctx.signal);
206
- seedPhase('seed_snapshot');
475
+ await startup?.beforeSeed(ws, c);
476
+ await seedConversation(ctx, c, ws, project.folder, movedIn, changes, setup);
207
477
  return { conversation: c, workspace: ws };
208
478
  }
209
479
  catch (error) {
210
480
  // A failed move leaves nothing behind: its session can move again (the API counts only a live VM's move).
211
- if (route === 'codex-cloud' || movedIn) {
481
+ if (movedIn) {
212
482
  try {
213
483
  await ctx.cloud().workspaces.delete(c.workspace_id);
214
484
  }
@@ -216,9 +486,22 @@ export async function createConversation(ctx, folder, harness, route, name, choi
216
486
  throw new AggregateError([error, cleanup], `Cloud setup failed; remove owned workspace ${c.workspace_id} before retrying.`, { cause: cleanup });
217
487
  }
218
488
  }
489
+ if (route === 'codex-cloud')
490
+ throw new CodedError('project_preparation_pending', `Accepted ${c.key}; project preparation can resume. Open this conversation to continue.`, `shard agents open ${c.id}`, { conversation_id: c.id, workspace_key: c.key, workspace_id: c.workspace_id, setup_state: localConversations(ctx)[c.id]?.preparation?.state ?? 'error' });
219
491
  throw error;
220
492
  }
221
493
  }
494
+ const shellQuote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
495
+ /**
496
+ * The conversation branch, then the start state's snapshot, in one request (0.21.1+): a failed checkout fails the
497
+ * start, a failed snapshot only leaves it without one (recordSeedSnapshot). Its hub push stays detached in the VM.
498
+ */
499
+ export async function checkoutConversationBranch(ctx, cell, c) {
500
+ const checkout = `${twinCheckoutArgv(ctx.store.dir, c).map(shellQuote).join(' ')} || exit 3\nsh -c "$1" shard-seed "$2" "$3" "$4" background >/dev/null || true`;
501
+ const result = await runGitStep(cell, ['sh', '-c', checkout, 'shard-seed', SEED_SNAPSHOT, c.worktree_path, c.branch.replace(/^shard\//, ''), HUB_PATH], { timeoutMs: 120_000, maxOutputBytes: 4096, signal: ctx.signal });
502
+ if (result.exitCode !== 0)
503
+ throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
504
+ }
222
505
  export async function resumeConversation(ctx, c) {
223
506
  if (!conversationEnded(c))
224
507
  return getConversation(ctx, c.id);
@@ -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 {
@@ -63,7 +63,7 @@ export declare class DeviceGrants {
63
63
  allows(api: string, project: string, root: string, kind: string, outside: boolean): boolean;
64
64
  save(api: string, project: string, root: string, kind: string): Promise<void>;
65
65
  }
66
- export declare function deviceSummary(r: DeviceRequest): string;
66
+ export declare function deviceSummary(r: DeviceRequest, platform?: NodeJS.Platform): string;
67
67
  /** Structured local approval details; the summary remains for older menu bar and ACP clients. */
68
68
  export declare function deviceApproval(r: DeviceRequest, c: Conversation, target: {
69
69
  path: string;
@@ -14,6 +14,7 @@ import { UsageError } from "./errors.js";
14
14
  import { conversationClient } from "./conversation-commands.js";
15
15
  import { withTargetLock } from "./target-lock.js";
16
16
  import { checkConversationWorktree, rememberConversationWorktree } from "./conversation-worktree.js";
17
+ import { yourComputer } from "./this-computer.js";
17
18
  const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
18
19
  const inside = (root, target) => target === root || target.startsWith(root + sep);
19
20
  const safeId = (value) => { if (!/^[a-zA-Z0-9_-]{1,96}$/.test(value))
@@ -67,14 +68,15 @@ export class DeviceGrants {
67
68
  });
68
69
  }
69
70
  }
70
- export function deviceSummary(r) {
71
+ export function deviceSummary(r, platform = process.platform) {
72
+ const yours = yourComputer(platform);
71
73
  if (tryLocallyAction(r))
72
74
  return tryLocallyAction(r) === 'stop' ? 'Stop this conversation’s local sync and ports.' : 'Try this conversation in an isolated local worktree with two-way sync and localhost ports.';
73
75
  if (r.kind === 'exec')
74
- return `Run on your Mac: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
76
+ return `Run on ${yours}: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
75
77
  if (r.files)
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
+ return `${r.kind === 'files_pull' ? 'Share with conversation' : `Save on ${yours}`}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
79
+ return `${r.kind === 'port_forward' ? `Open conversation preview on ${yours}` : `Connect conversation to ${yours}`}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
78
80
  }
79
81
  /** Structured local approval details; the summary remains for older menu bar and ACP clients. */
80
82
  export function deviceApproval(r, c, target, folder) {