@shardflux/cli 0.19.1 → 0.22.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 (115) hide show
  1. package/CHANGELOG.md +278 -1
  2. package/README.md +225 -73
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +20 -15
  6. package/dist/account-pool.js +51 -97
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-files.d.ts +19 -0
  16. package/dist/agent-config-files.js +70 -1
  17. package/dist/agent-config-return.d.ts +194 -0
  18. package/dist/agent-config-return.js +724 -0
  19. package/dist/agent-config-sync.d.ts +10 -2
  20. package/dist/agent-config-sync.js +45 -51
  21. package/dist/agent-credentials.js +7 -3
  22. package/dist/agent-repository.d.ts +16 -3
  23. package/dist/agent-repository.js +149 -74
  24. package/dist/agent-setup.d.ts +14 -3
  25. package/dist/agent-setup.js +78 -36
  26. package/dist/agent-target-commands.js +24 -13
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +104 -55
  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-forward.d.ts +2 -0
  35. package/dist/cli-proxy-forward.js +9 -2
  36. package/dist/cli-proxy-hub.d.ts +69 -25
  37. package/dist/cli-proxy-hub.js +231 -54
  38. package/dist/cli-proxy.d.ts +70 -1
  39. package/dist/cli-proxy.js +298 -32
  40. package/dist/client-settings.d.ts +39 -0
  41. package/dist/client-settings.js +91 -0
  42. package/dist/codex-cloud.d.ts +5 -0
  43. package/dist/codex-cloud.js +88 -17
  44. package/dist/command-center-actions.js +5 -1
  45. package/dist/commands.js +171 -22
  46. package/dist/conversation-commands.d.ts +5 -0
  47. package/dist/conversation-commands.js +8 -5
  48. package/dist/conversation-terminal.d.ts +10 -2
  49. package/dist/conversation-terminal.js +81 -15
  50. package/dist/conversations.d.ts +77 -1
  51. package/dist/conversations.js +257 -23
  52. package/dist/device-bridge.d.ts +1 -1
  53. package/dist/device-bridge.js +6 -4
  54. package/dist/device-helper.js +52 -4
  55. package/dist/errors.d.ts +34 -1
  56. package/dist/errors.js +56 -5
  57. package/dist/filesystem-commands.d.ts +22 -3
  58. package/dist/filesystem-commands.js +132 -16
  59. package/dist/format.d.ts +2 -1
  60. package/dist/format.js +10 -1
  61. package/dist/handlers.js +4 -1
  62. package/dist/initial-prompt.d.ts +8 -0
  63. package/dist/initial-prompt.js +43 -1
  64. package/dist/large-media.d.ts +14 -0
  65. package/dist/large-media.js +100 -0
  66. package/dist/linux-terminal.d.ts +5 -3
  67. package/dist/linux-terminal.js +6 -20
  68. package/dist/local-sessions.d.ts +26 -2
  69. package/dist/local-sessions.js +113 -24
  70. package/dist/machine-setup.d.ts +190 -0
  71. package/dist/machine-setup.js +849 -0
  72. package/dist/machine-status.d.ts +67 -0
  73. package/dist/machine-status.js +140 -0
  74. package/dist/main.js +23 -13
  75. package/dist/move-worktrees-restore.py +144 -0
  76. package/dist/move-worktrees.d.ts +63 -0
  77. package/dist/move-worktrees.js +201 -0
  78. package/dist/move.d.ts +10 -1
  79. package/dist/move.js +116 -16
  80. package/dist/onboarding.d.ts +11 -7
  81. package/dist/onboarding.js +59 -27
  82. package/dist/prepared-repository.d.ts +202 -0
  83. package/dist/prepared-repository.js +860 -0
  84. package/dist/prepared-repository.py +1306 -0
  85. package/dist/project-preparation.d.ts +48 -0
  86. package/dist/project-preparation.js +304 -0
  87. package/dist/project-sync-manifest.d.ts +4 -0
  88. package/dist/project-sync-manifest.js +5 -1
  89. package/dist/project-sync-transfer.js +1 -1
  90. package/dist/project-sync.d.ts +19 -4
  91. package/dist/project-sync.js +31 -8
  92. package/dist/project-template-setup.d.ts +33 -1
  93. package/dist/project-template-setup.js +59 -2
  94. package/dist/secret-ask.d.ts +72 -0
  95. package/dist/secret-ask.js +351 -0
  96. package/dist/seed-phases.d.ts +13 -3
  97. package/dist/seed-phases.js +21 -3
  98. package/dist/setup-cache.d.ts +7 -0
  99. package/dist/setup-cache.js +32 -0
  100. package/dist/t3-launch.js +1 -4
  101. package/dist/t3-mode.d.ts +8 -2
  102. package/dist/t3-mode.js +19 -4
  103. package/dist/t3-phone.d.ts +4 -2
  104. package/dist/t3-phone.js +12 -4
  105. package/dist/terminal-apps.d.ts +157 -0
  106. package/dist/terminal-apps.js +561 -0
  107. package/dist/terminal-onboarding.d.ts +2 -0
  108. package/dist/terminal-onboarding.js +11 -1
  109. package/dist/this-computer.d.ts +11 -0
  110. package/dist/this-computer.js +36 -0
  111. package/dist/thread-on-computer.js +4 -1
  112. package/dist/update.js +11 -3
  113. package/dist/worktree-twin.d.ts +10 -2
  114. package/dist/worktree-twin.js +27 -9
  115. package/package.json +5 -5
@@ -1,4 +1,6 @@
1
1
  import { linuxTerminalSession } from "./linux-terminal.js";
2
+ import { resolve } from 'node:path';
3
+ import { attachable, openShardInTerminal, reportTerminalOpen } from "./terminal-apps.js";
2
4
  import { randomUUID } from 'node:crypto';
3
5
  import { AgentStateStore } from "./agent-state.js";
4
6
  import { projectIdentity, projectLabels } from "./project-identity.js";
@@ -7,11 +9,12 @@ import { modelChoice } from "./agent-models.js";
7
9
  import { terminal } from "./agent-commands.js";
8
10
  import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
9
11
  import { syncAgentCredentials } from "./agent-credentials.js";
10
- import { prepareProjectTemplate } from "./project-template-setup.js";
12
+ import { labelConversationSetup, prepareProjectTemplate, readConversationSetup } from "./project-template-setup.js";
11
13
  import { projectWorkspacePath, seedAgentRepository } from "./agent-repository.js";
12
14
  import { syncAgentConfig, syncAgentConfigAfterStart } from "./agent-config-sync.js";
13
15
  import { savedMachine } from "./filesystem-state.js";
14
16
  import { ensureMenubar, MenubarManager } from "./menubar.js";
17
+ import { ensureProjectGit } from "./project-git.js";
15
18
  import { UsageError } from "./errors.js";
16
19
  import { localResumeTerminal } from "./conversation-local-resume.js";
17
20
  import { agentReachability } from "./agent-reachability.js";
@@ -24,14 +27,21 @@ import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-comma
24
27
  import { openUrl } from "./browser.js";
25
28
  import { out } from "./commands.js";
26
29
  import { progress } from "./progress.js";
27
- /** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
28
- export async function conversationTerminal(ctx, ws, c, project, values, afterStart) {
30
+ import { seedPhase } from "./seed-phases.js";
31
+ /** This computer's agent config, copied after the start; with a saved machine (which carries it) just the project memory. */
32
+ const afterStartConfig = (ctx, ws, machine) => () => syncAgentConfigAfterStart(ctx, ws, machine ? { memoryOnly: true } : {});
33
+ /**
34
+ * Every terminal entry point uses the durable conversation's native harness, worktree and session. A new conversation
35
+ * in a repository without a project template for its dependencies sets its worktree up once its agent has started
36
+ * (`setup`, 0.21.1+).
37
+ */
38
+ export async function conversationTerminal(ctx, ws, c, project, values, afterStart, setup) {
29
39
  if (c.route === 'codex-cloud')
30
40
  return openCodexConversation(ctx, c);
31
41
  await restoreConversationBinding(ctx, c, ws);
32
42
  // A thread on another conversation's computer has its own PTY there, never the VM's own (0.16.0+).
33
43
  const pty = ws.labels.shard_conversation && ws.labels.shard_conversation !== c.id ? conversationName(c) : undefined;
34
- return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id, title: c.title, ...(pty ? { pty } : {}), choice: recordedChoice(c), ...(afterStart ? { afterStart } : {}) });
44
+ return terminal(ctx, ws, c.harness, conversationName(c), project, values, setup?.hash, c.worktree_path, { id: c.id, session: c.harness_session_id, title: c.title, ...(pty ? { pty } : {}), choice: recordedChoice(c), ...(afterStart ? { afterStart } : {}), ...(setup ? { setupLabelled: setup.labelled } : {}) });
35
45
  }
36
46
  /**
37
47
  * A conversation and its VM, for an app to open (Claude Desktop, claude.ai, T3 Code, the menu bar). A conversation
@@ -48,7 +58,7 @@ export async function reopenConversation(ctx, c, values) {
48
58
  progress(ctx, 'Restored');
49
59
  return { conversation: restored, ws };
50
60
  }
51
- const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
61
+ const afterStart = afterStartConfig(ctx, ws, !!(await savedMachine(ctx))?.template);
52
62
  const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
53
63
  await conversationTerminal(quiet, ws, restored, ws.labels.repo_remote || ws.labels.shard_project || ws.key, { ...values, detach: true }, afterStart);
54
64
  return { conversation: restored, ws };
@@ -56,20 +66,58 @@ export async function reopenConversation(ctx, c, values) {
56
66
  /**
57
67
  * `shard ./` (and `agents new --in auto`): new conversations open where `agents targets` says (open_in, 0.17.0+): T3
58
68
  * Code mode, Claude Desktop or claude.ai for Claude (the conversation's Remote Control session), else this terminal.
69
+ * From a shell nobody types into (a coding agent's, or none) a terminal conversation opens in a new tab of the
70
+ * person's terminal app (0.20.1+, agents targets --terminal).
59
71
  */
60
72
  export const launchProjectCommand = async (ctx, values, [path = './', explicit]) => {
61
73
  const named = explicit ?? (values.claude === true ? 'claude' : values.codex === true ? 'codex' : undefined);
74
+ const typing = attachable(ctx.io), here = typing || ctx.json;
62
75
  if (values.resume !== true && values.review !== true) {
63
76
  const project = projectIdentity(path);
64
77
  const app = named === 'claude' || named === 'codex' ? named : new AgentStateStore(ctx.store.dir).app(project.remote ?? project.folder);
65
- const where = process.platform === 'linux' && ctx.io.stdin.isTTY ? 'terminal' : openInChoices(ctx, project.slug)[app];
78
+ const where = process.platform === 'linux' && typing ? 'terminal' : openInChoices(ctx, project.slug)[app];
66
79
  if (where === 't3')
67
80
  return launchT3(ctx, values, path, app);
68
81
  if (values.on === undefined && app === 'claude' && (where === 'claude-desktop' || where === 'web'))
69
82
  return startConversation(ctx, values, [path, ...(explicit ? [explicit] : [])], where);
83
+ if (!here && where === 'terminal')
84
+ return projectInTerminalApp(ctx, values, path, named);
70
85
  }
86
+ else if (!here)
87
+ return projectInTerminalApp(ctx, values, path, named);
71
88
  return launchConversationProject(ctx, values, [path, ...(explicit ? [explicit] : [])]);
72
89
  };
90
+ /** Flags `shard ./` hands to the terminal tab that runs it; files become absolute paths (the tab starts in the project). */
91
+ const NOT_FORWARDED = new Set(['json', 'api-url', 'agent-label', 'wake', 'wake-timeout', 'help', 'version', 'timing', 'reachable', 'claude', 'codex']);
92
+ export function forwardedFlags(values) {
93
+ const flags = [];
94
+ for (const [key, value] of Object.entries(values)) {
95
+ if (NOT_FORWARDED.has(key) || value === undefined)
96
+ continue;
97
+ for (const v of Array.isArray(value) ? value : [value]) {
98
+ if (v === true)
99
+ flags.push(`--${key}`);
100
+ else if (v === false)
101
+ flags.push(`--no-${key}`);
102
+ else if (typeof v === 'string') {
103
+ if ((key === 'prompt-file' || key === 'image') && v === '-')
104
+ throw new UsageError(`--${key} - reads standard input, which a new terminal tab cannot: pass a file, or start it detached with --json.`);
105
+ flags.push(`--${key}`, key === 'prompt-file' || key === 'image' ? resolve(v) : v);
106
+ }
107
+ }
108
+ }
109
+ return flags;
110
+ }
111
+ /** `shard ./` with its flags in a new tab of the person's terminal app, in the project folder: the tab owns the start,
112
+ * any sign-in and the attach, as if the person typed it there. */
113
+ async function projectInTerminalApp(ctx, values, path, app) {
114
+ const result = await openShardInTerminal(ctx, ['./', ...(app ? [app] : []), ...forwardedFlags(values)], { cwd: resolve(path) });
115
+ if (!result.opened) {
116
+ throw new UsageError(`shard ./ opens Claude Code or Codex in a terminal, and none opened here${result.reason ? `: ${result.reason.replace(/\.$/, '')}` : ''}.`, 'ask your user to run shard ./ in their own terminal, or start the conversation detached: shard ./ --json --prompt "<task>" (shard ./ codex --json ... for Codex)');
117
+ }
118
+ reportTerminalOpen(ctx, result);
119
+ return 0;
120
+ }
73
121
  /** Select a durable conversation, then use the harness's native terminal for all interaction. */
74
122
  export const launchConversationProject = (ctx, values, args) => startConversation(ctx, values, args);
75
123
  async function startConversation(ctx, values, [path = './', explicit], openIn) {
@@ -112,17 +160,31 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
112
160
  }
113
161
  if (app === 'codex' && !linuxTerminalSession(ctx, values))
114
162
  return launchCodexCloud(ctx, values, path, images);
115
- await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
116
- const signedIn = app === 'claude' ? await cloudClaudeLogin(ctx) : true;
117
- if (!signedIn)
118
- requireClaudeTerminal(ctx);
163
+ seedPhase('cli_start');
164
+ // Agent credentials and the Claude sign-in are checked beside the project's local work (its Git state and snapshot),
165
+ // and both pass before a VM is requested (0.21.1+).
166
+ const signIn = (async () => {
167
+ await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
168
+ const signed = app === 'claude' ? await cloudClaudeLogin(ctx) : true;
169
+ if (!signed)
170
+ requireClaudeTerminal(ctx);
171
+ seedPhase('claude_login');
172
+ return signed;
173
+ })();
174
+ signIn.catch(() => undefined);
175
+ // Whether this conversation sets the project up: hashed from the files it starts from (in a Git repository, as its VM
176
+ // reads them), and asked while its VM starts.
177
+ await ensureProjectGit(ctx, project.folder);
178
+ const setup = readConversationSetup(ctx, project);
179
+ seedPhase('project_git');
119
180
  let created;
120
181
  try {
121
- created = await createConversation(ctx, project.folder, app, 'terminal', undefined, choice);
182
+ created = await createConversation(ctx, project.folder, app, 'terminal', undefined, choice, undefined, setup, signIn);
122
183
  }
123
184
  catch (e) {
124
185
  if (!conversationsUnavailable(e))
125
186
  throw e;
187
+ const signedIn = await signIn;
126
188
  // Optional migration: preserve the native terminal on the old control plane, without reviving main sync.
127
189
  const name = `c-${randomUUID().slice(0, 12)}`;
128
190
  const prepared = await prepareProjectTemplate(ctx, project, undefined);
@@ -140,6 +202,9 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
140
202
  return terminal(ctx, ws, app, name, project.remote ?? project.folder, values, prepared.setupHash);
141
203
  }
142
204
  const { conversation: c, workspace: ws } = created;
205
+ const signedIn = await signIn;
206
+ const setupStart = labelConversationSetup(ctx, ws, c.worktree_path, await setup);
207
+ seedPhase('setup_label');
143
208
  if (values.computer === true)
144
209
  await ws.setComputerUse(true);
145
210
  await uploadInitialImages(ws.cell(), images, ctx.signal);
@@ -148,19 +213,20 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
148
213
  if (!signedIn)
149
214
  await loginClaudeInWorkspace(ctx, ws);
150
215
  // Without a saved machine the laptop's Claude and Codex config is copied in, after the agent has its first prompt.
151
- const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
216
+ const afterStart = afterStartConfig(ctx, ws, !!(await savedMachine(ctx))?.template);
152
217
  await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
218
+ seedPhase('menubar');
153
219
  if (openIn) {
154
220
  // The agent starts detached in its VM; the conversation then opens in Claude Desktop or claude.ai (Remote Control).
155
221
  const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
156
- await conversationTerminal(quiet, ws, c, project.remote ?? project.folder, { ...values, detach: true }, afterStart);
222
+ await conversationTerminal(quiet, ws, c, project.remote ?? project.folder, { ...values, detach: true }, afterStart, setupStart);
157
223
  if (openIn === 'claude-desktop')
158
224
  return openClaudeDesktopSession(ctx, ws, c);
159
225
  const url = await remoteControlUrl(ctx, ws, c);
160
- if (!ctx.json && !openUrl(url, ctx.io.env))
226
+ if (!ctx.json && !(await openUrl(url, ctx.io.env)))
161
227
  throw new Error(`could not open ${url}`);
162
228
  out(ctx, `${url}\n`, { target: 'web', workspace: ws.data, conversation_id: c.id, url });
163
229
  return 0;
164
230
  }
165
- return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values, afterStart);
231
+ return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values, afterStart, setupStart);
166
232
  }
@@ -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;
@@ -49,6 +51,10 @@ export interface Conversation {
49
51
  } | null;
50
52
  /** Why it needs you, while `state` is needs_you; null otherwise (0.18.0+, APIs that return it). */
51
53
  question?: string | null;
54
+ /** The user who started it (0.20.0+, APIs that record it); null when unknown. */
55
+ started_by?: {
56
+ user_id: string;
57
+ } | null;
52
58
  }
53
59
  export interface LocalConversation {
54
60
  api_url: string;
@@ -58,6 +64,33 @@ export interface LocalConversation {
58
64
  handed_off?: boolean;
59
65
  /** The branch the conversation started from (0.16.0+), for APIs that do not return base_branch yet. */
60
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;
61
94
  }
62
95
  /**
63
96
  * Whether the conversation has ended: its VM was reclaimed, or it was archived or discarded. A finished turn is `done`
@@ -91,14 +124,57 @@ export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws
91
124
  export interface MovedInSession {
92
125
  session_id: string;
93
126
  title?: string;
127
+ preserveWorktrees?: boolean;
94
128
  }
95
- 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>;
129
+ /**
130
+ * `ready`: checks the caller runs beside this local work (a sign-in check, 0.21.1+); the VM is requested only after
131
+ * they pass. The conversation API's availability is read meanwhile too.
132
+ */
133
+ 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>;
134
+ /**
135
+ * Brings the conversation's accepted revision into its VM (0.22.0+): only what the project cache lacks is sent, and
136
+ * with `defer` large incompressible files follow the first prompt. Resumable: a later call (the device helper, or
137
+ * `shard agents open`) continues the same revision, never a newer checkout. False when the start has no revision.
138
+ */
139
+ export declare function prepareAcceptedConversation(ctx: Ctx, c: Conversation, ws: Workspace, options?: {
140
+ defer?: boolean;
141
+ fallback?: boolean;
142
+ setup?: Promise<{
143
+ depsHash: string;
144
+ } | undefined>;
145
+ }): Promise<boolean>;
146
+ /**
147
+ * Sends a conversation's deferred large files and lands them in its worktree. Runs while its VM is awake (it never
148
+ * wakes one); the device helper retries until the start is whole.
149
+ */
150
+ export declare function completeDeferredPreparation(ctx: Ctx, c: Conversation): Promise<boolean>;
151
+ /**
152
+ * The one-time seed of a new conversation's worktree (a conversation never watches or writes the owner's checkout):
153
+ * its prepared revision (0.22.0+), or the copy seed when the start has none or the VM cannot take one. Repeatable: a
154
+ * start resumed after a deadline continues here.
155
+ */
156
+ export declare function seedConversation(ctx: Ctx, c: Conversation, ws: Workspace, folder: string, movedIn?: MovedInSession & {
157
+ seedRepository?(ws: Workspace, c: Conversation): Promise<void>;
158
+ }, changes?: Promise<LocalChanges>, setup?: Promise<{
159
+ depsHash: string;
160
+ } | undefined>): Promise<void>;
96
161
  export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, choice?: ModelChoice, movedIn?: MovedInSession & {
97
162
  beforeSeed(ws: Workspace, c: Conversation): Promise<void>;
163
+ seedRepository?(ws: Workspace, c: Conversation): Promise<void>;
164
+ }, setup?: Promise<{
165
+ depsHash: string;
166
+ } | undefined>, ready?: Promise<unknown>, startup?: {
167
+ accepted(c: Conversation): void;
168
+ beforeSeed(ws: Workspace, c: Conversation): Promise<void>;
98
169
  }): Promise<{
99
170
  conversation: Conversation;
100
171
  workspace: Workspace;
101
172
  }>;
173
+ /**
174
+ * The conversation branch, then the start state's snapshot, in one request (0.21.1+): a failed checkout fails the
175
+ * start, a failed snapshot only leaves it without one (recordSeedSnapshot). Its hub push stays detached in the VM.
176
+ */
177
+ export declare function checkoutConversationBranch(ctx: Pick<Ctx, 'store' | 'signal'>, cell: Pick<CellClient, 'exec'>, c: Conversation): Promise<void>;
102
178
  export declare function resumeConversation(ctx: Ctx, c: Conversation): Promise<Conversation>;
103
179
  /** The model and effort a conversation's harness starts with, as its record keeps them. */
104
180
  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
  }
@@ -157,58 +163,273 @@ export async function restoreConversationBinding(ctx, c, ws) {
157
163
  if (c.restore.kind === 'resume')
158
164
  progress(ctx, 'Restored');
159
165
  }
160
- export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}, movedIn) {
166
+ /**
167
+ * `ready`: checks the caller runs beside this local work (a sign-in check, 0.21.1+); the VM is requested only after
168
+ * they pass. The conversation API's availability is read meanwhile too.
169
+ */
170
+ export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}, movedIn, ready) {
161
171
  const project = projectIdentity(folder);
162
172
  await ensureProjectGit(ctx, project.folder);
163
173
  // A start refuses before a VM is allocated, never after (0.19.0+).
164
174
  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 });
175
+ // Both the conversation API and its legacy workspace fallback need a usable local HEAD. The API's answer (a legacy
176
+ // control plane refuses) is awaited before the create, after the local snapshot it runs beside.
177
+ const probe = conversationClient(ctx).conversations.list({ limit: 1 }).then(() => undefined, (error) => ({ error }));
167
178
  const git = gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal);
168
179
  const head = await git(['rev-parse', 'HEAD']);
169
180
  const branch = await git(['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => undefined);
170
181
  // 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 }));
182
+ const seed = movedIn?.preserveWorktrees ? { commit: head, snapshot: false }
183
+ : await twinSeed(ctx.store.dir, project.folder, head, ctx.store.profile(ctx.apiUrl).project_id).catch(() => ({ commit: head, snapshot: false }));
172
184
  seedPhase('local_snapshot');
185
+ const refused = await probe;
186
+ if (refused)
187
+ throw refused.error;
188
+ await ready;
189
+ seedPhase('api_probe');
173
190
  const commit = seed.commit;
191
+ // The prepared revision of that state (0.22.0+): its media and what Git does not hold (.env files, filter output),
192
+ // 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) => {
195
+ if (ctx.signal.aborted)
196
+ throw error;
197
+ ctx.io.stderr.write(`Project snapshot: ${error instanceof Error ? error.message : String(error)}; copying the project instead.\n`);
198
+ return undefined;
199
+ });
200
+ capture.catch(() => undefined);
201
+ // The title marker this computer chose (`shard settings --title-prefix`, 0.20.1+); none sent means the platform's.
202
+ const titlePrefix = storedTitlePrefix(ctx.store.dir);
174
203
  const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
175
204
  base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id,
205
+ ...(titlePrefix !== undefined ? { titlePrefix } : {}),
176
206
  ...(movedIn ? { harnessSessionId: movedIn.session_id, ...(movedIn.title ? { title: movedIn.title.slice(0, 200) } : {}) } : {}) }, { idempotencyKey, signal: ctx.signal });
177
207
  const c = validateConversation({ ...response.conversation, restore: response.restore });
178
208
  allocated?.(response.workspace);
179
209
  rememberTwinSeed(ctx.store.dir, c.id, project.folder, seed, branch);
180
210
  rememberConversation(ctx, c, project.folder, false, branch);
211
+ // The seed awaits the capture (prepareAcceptedConversation), so it runs on while the VM boots, with the Git settings
212
+ // the VM repository copies from this one.
213
+ pendingCaptures.set(c.id, capture.then(async (prepared) => {
214
+ if (!prepared)
215
+ return undefined;
216
+ new AgentStateStore(ctx.store.dir).update(doc => {
217
+ const record = doc.conversations[c.id];
218
+ if (record)
219
+ record.preparation = { revision: prepared.revision, cache: preparedCachePath(project.remote ?? `file://${project.folder}`), state: 'accepted' };
220
+ });
221
+ return { snapshot: prepared, git: await preparedGitConfig(project.folder, prepared.manifest, ctx.signal) };
222
+ }));
223
+ acceptedProgress(c.id, c.key);
181
224
  return c;
182
225
  }
183
- export async function createConversation(ctx, folder, harness, route, name, choice = {}, movedIn) {
226
+ /** Captures still running for conversations this process created (createConversationRecord). */
227
+ const pendingCaptures = new Map();
228
+ /** A start the copy seed took over: its prepared revision is no longer this conversation's (recorded as the reason). */
229
+ function dropPreparation(ctx, id, reason) {
230
+ new AgentStateStore(ctx.store.dir).update(doc => {
231
+ const record = doc.conversations[id];
232
+ if (record?.preparation) {
233
+ delete record.preparation;
234
+ record.preparation_fallback = reason;
235
+ }
236
+ });
237
+ }
238
+ function updatePreparation(ctx, id, patch) {
239
+ new AgentStateStore(ctx.store.dir).update(doc => {
240
+ const record = doc.conversations[id];
241
+ if (record?.preparation)
242
+ record.preparation = { ...record.preparation, ...patch };
243
+ });
244
+ }
245
+ const errorText = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 2000);
246
+ /** The template checkout a conversation outside it copies dependency folders from (a Codex Cloud worktree). */
247
+ const templateCheckout = (c) => `/home/user/${c.key.split('/')[0]}`;
248
+ /**
249
+ * Brings the conversation's accepted revision into its VM (0.22.0+): only what the project cache lacks is sent, and
250
+ * with `defer` large incompressible files follow the first prompt. Resumable: a later call (the device helper, or
251
+ * `shard agents open`) continues the same revision, never a newer checkout. False when the start has no revision.
252
+ */
253
+ export async function prepareAcceptedConversation(ctx, c, ws, options = {}) {
254
+ const capturing = pendingCaptures.get(c.id);
255
+ let captured;
256
+ if (capturing) {
257
+ captured = await capturing.catch(() => undefined);
258
+ pendingCaptures.delete(c.id);
259
+ seedPhase('prepared_snapshot');
260
+ }
261
+ // The setup intent goes in the exec that checks the repository out, before the repository appears (0.22.0+).
262
+ const intent = await options.setup;
263
+ const local = localConversations(ctx)[c.id];
264
+ if (!local?.preparation)
265
+ return false;
266
+ if (local.preparation.state === 'ready' || local.preparation.state === 'deferred')
267
+ return true;
268
+ return withTargetLock(join(ctx.store.dir, 'prepared-starts', c.id), async () => {
269
+ const current = localConversations(ctx)[c.id]?.preparation;
270
+ if (!current)
271
+ return false;
272
+ if (current.state === 'ready' || current.state === 'deferred')
273
+ return true;
274
+ try {
275
+ updatePreparation(ctx, c.id, { state: 'syncing', error: undefined });
276
+ const fresh = captured?.snapshot.revision === current.revision ? captured : undefined;
277
+ const snapshot = fresh?.snapshot ?? await readPreparedSnapshot(ctx.store.dir, local.folder, current.revision);
278
+ const cell = ws.cell(), outside = c.worktree_path !== templateCheckout(c);
279
+ // The seed's copy to the project hub starts in the same exec as the checkout, detached (no extra round trip).
280
+ const target = { root: c.worktree_path, branch: c.branch, options: { signal: ctx.signal, adopt: true,
281
+ git: fresh?.git ?? await preparedGitConfig(local.folder, snapshot.manifest, ctx.signal), ...(outside ? { dependencySource: templateCheckout(c) } : {}),
282
+ 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 } };
284
+ let transfer, apply;
285
+ try {
286
+ // A warm start: the probe checks the revision out in its own exec.
287
+ transfer = await transferPreparedSnapshot(cell, local.folder, current.cache, snapshot, { signal: ctx.signal, defer: options.defer ?? true, pin: c.id, apply: target });
288
+ seedPhase('prepared_transfer');
289
+ apply = transfer.applied ?? await applyPreparedSnapshot(cell, current.cache, current.revision, c.worktree_path, c.branch, { ...target.options, ...(transfer.complete ? { releasePin: c.id } : {}) });
290
+ }
291
+ catch (error) {
292
+ // No agent has this worktree yet: a VM that cannot take a prepared revision (a custom template without Python
293
+ // 3.11, say) still starts, through the copy seed, into a clean directory.
294
+ if (ctx.signal.aborted || !options.fallback || (error instanceof PreparedRefusal && error.reason === 'owned'))
295
+ throw error;
296
+ ctx.io.stderr.write(`Project transfer: ${errorText(error)}; copying the project instead.\n`);
297
+ // The copy seed owns this start from here: nothing resumes the prepared one onto its worktree.
298
+ dropPreparation(ctx, c.id, errorText(error));
299
+ await releaseLocalSnapshot(local.folder, current.revision).catch(() => undefined);
300
+ // A fresh start leaves nothing at the path; a checkout taken over in place (a template's) is set aside.
301
+ 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 });
302
+ return false;
303
+ }
304
+ seedPhase('prepared_apply');
305
+ updatePreparation(ctx, c.id, { state: transfer.complete ? 'ready' : 'deferred', transfer, apply, pending: transfer.pending, error: undefined, workspace: ws.id });
306
+ if (transfer.complete)
307
+ await releaseLocalSnapshot(local.folder, current.revision);
308
+ return true;
309
+ }
310
+ catch (error) {
311
+ updatePreparation(ctx, c.id, { state: 'error', error: errorText(error) });
312
+ throw error;
313
+ }
314
+ }, ctx.signal);
315
+ }
316
+ /**
317
+ * Sends a conversation's deferred large files and lands them in its worktree. Runs while its VM is awake (it never
318
+ * wakes one); the device helper retries until the start is whole.
319
+ */
320
+ export async function completeDeferredPreparation(ctx, c) {
321
+ const local = localConversations(ctx)[c.id];
322
+ if (local?.preparation?.state !== 'deferred' || conversationEnded(c))
323
+ return false;
324
+ return withTargetLock(join(ctx.store.dir, 'prepared-starts', c.id), async () => {
325
+ const current = localConversations(ctx)[c.id]?.preparation;
326
+ if (current?.state !== 'deferred')
327
+ return false;
328
+ let ws;
329
+ try {
330
+ ws = await conversationClient(ctx).workspaces.get(c.workspace_id);
331
+ }
332
+ catch (error) {
333
+ if (!(error instanceof ShardfluxApiError && error.status === 404))
334
+ throw error;
335
+ }
336
+ if (!ws || ws.data.deleted_at || (current.workspace && current.workspace !== c.workspace_id)) {
337
+ // The VM is gone (reclaimed, moved, deleted) or the conversation continues in another one (a resume restores its
338
+ // worktree there): there is no worktree left to complete.
339
+ updatePreparation(ctx, c.id, { state: 'error', error: 'The conversation\'s VM was removed before its media arrived.', pending: undefined });
340
+ await releaseLocalSnapshot(local.folder, current.revision);
341
+ return false;
342
+ }
343
+ if (!ws.ready)
344
+ return false;
345
+ const snapshot = await readPreparedSnapshot(ctx.store.dir, local.folder, current.revision);
346
+ const cell = ws.cell();
347
+ // Staged into the conversation's own repository and landed before this returns; the cache gets them after.
348
+ const transfer = await transferPreparedSnapshot(cell, local.folder, current.cache, snapshot, { signal: ctx.signal, pin: c.id,
349
+ apply: { root: c.worktree_path, branch: c.branch, options: { adopt: true } } });
350
+ // Media edited on this computer after the start never arrive as captured: the worktree goes on without them.
351
+ const drop = snapshot.manifest.files.filter(f => transfer.changed.includes(f.path)).map(f => f.oid);
352
+ const landed = await landDeferredFiles(cell, current.cache, current.revision, c.worktree_path, { signal: ctx.signal, drop });
353
+ if (landed.pending)
354
+ return false;
355
+ updatePreparation(ctx, c.id, { state: 'ready', transfer: { ...transfer, pending: current.pending ?? [] }, pending: undefined });
356
+ await releaseLocalSnapshot(local.folder, current.revision);
357
+ await pruneLocalPrepared(ctx.store.dir).catch(() => undefined);
358
+ return true;
359
+ }, ctx.signal);
360
+ }
361
+ /**
362
+ * The one-time seed of a new conversation's worktree (a conversation never watches or writes the owner's checkout):
363
+ * its prepared revision (0.22.0+), or the copy seed when the start has none or the VM cannot take one. Repeatable: a
364
+ * start resumed after a deadline continues here.
365
+ */
366
+ export async function seedConversation(ctx, c, ws, folder, movedIn, changes, setup) {
367
+ // Requests the copy seed starts and nothing waits for (the history backfill's start), settled before this returns.
368
+ const background = [];
369
+ const cell = ws.cell();
370
+ // A conversation that sets its project up records it before its repository appears: its guest starts the agent as
371
+ // soon as the repository appears, then with the setup's context and toolchains (0.21.1+). A prepared start writes it
372
+ // in its checkout's exec; the copy seed beside its first request.
373
+ let recorded;
374
+ const recordIntent = () => recorded ??= (async () => {
375
+ const intent = await setup;
376
+ if (intent)
377
+ await recordSetupIntent(cell, c, intent.depsHash, ctx.signal).catch((error) => {
378
+ if (!ctx.signal.aborted)
379
+ ctx.io.stderr.write(`The agent starts without the project setup context: ${error instanceof Error ? error.message : String(error)}\n`);
380
+ });
381
+ })();
382
+ if (movedIn?.seedRepository) {
383
+ void recordIntent();
384
+ await movedIn.seedRepository(ws, c);
385
+ }
386
+ else if (!await prepareAcceptedConversation(ctx, c, ws, { fallback: true, ...(setup ? { setup } : {}) })) {
387
+ 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() });
389
+ }
390
+ await recorded;
391
+ seedPhase('seed_done');
392
+ const preparation = localConversations(ctx)[c.id]?.preparation?.state, prepared = preparation === 'ready' || preparation === 'deferred';
393
+ if (!prepared && !movedIn?.preserveWorktrees) {
394
+ await sendTwinSeed(ctx.store.dir, cell, c, folder, ctx.signal);
395
+ seedPhase('twin_seed');
396
+ await checkoutConversationBranch(ctx, cell, c);
397
+ seedPhase('branch_checkout');
398
+ }
399
+ else if (!prepared) {
400
+ await recordSeedSnapshot(ws, c, ctx.signal);
401
+ seedPhase('seed_snapshot');
402
+ }
403
+ await Promise.all(background);
404
+ // Large media that follow the first prompt keep coming while this process runs; the device helper finishes them.
405
+ if (preparation === 'deferred')
406
+ void completeDeferredPreparation(ctx, c).catch(() => undefined);
407
+ // This computer's spool and snapshots stay bounded with no selected project to prune them (hourly at most).
408
+ void pruneLocalPrepared(ctx.store.dir, undefined, 3_600_000).catch(() => undefined);
409
+ }
410
+ export async function createConversation(ctx, folder, harness, route, name, choice = {}, movedIn, setup, ready, startup) {
184
411
  const project = projectIdentity(folder);
185
412
  seedPhase('start');
186
413
  // The launcher's phase line (SHARDFLUX_PROGRESS_FILE, 0.19.0+): a computer starts, then the seed's own marks move it on.
187
414
  startProgress(harness, ctx.io.env);
188
- const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice, movedIn);
415
+ const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice, movedIn, ready);
416
+ startup?.accepted(c);
189
417
  seedPhase('conversation_create');
418
+ // A start without a prepared revision (a moved-in session) reads this computer's changes while its VM starts.
419
+ const changes = movedIn && !movedIn.seedRepository ? localChanges(project.folder, ctx.signal) : undefined;
420
+ changes?.catch(() => undefined);
190
421
  try {
191
422
  const ws = await conversationWorkspace(ctx, c);
192
423
  seedPhase('vm_running');
193
424
  // A moved-in session's transcript is in place before the repository: its guest resumes it once the seed is done.
194
425
  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');
426
+ await startup?.beforeSeed(ws, c);
427
+ await seedConversation(ctx, c, ws, project.folder, movedIn, changes, setup);
207
428
  return { conversation: c, workspace: ws };
208
429
  }
209
430
  catch (error) {
210
431
  // 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) {
432
+ if (movedIn) {
212
433
  try {
213
434
  await ctx.cloud().workspaces.delete(c.workspace_id);
214
435
  }
@@ -216,9 +437,22 @@ export async function createConversation(ctx, folder, harness, route, name, choi
216
437
  throw new AggregateError([error, cleanup], `Cloud setup failed; remove owned workspace ${c.workspace_id} before retrying.`, { cause: cleanup });
217
438
  }
218
439
  }
440
+ if (route === 'codex-cloud')
441
+ 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
442
  throw error;
220
443
  }
221
444
  }
445
+ const shellQuote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
446
+ /**
447
+ * The conversation branch, then the start state's snapshot, in one request (0.21.1+): a failed checkout fails the
448
+ * start, a failed snapshot only leaves it without one (recordSeedSnapshot). Its hub push stays detached in the VM.
449
+ */
450
+ export async function checkoutConversationBranch(ctx, cell, c) {
451
+ const checkout = `${twinCheckoutArgv(ctx.store.dir, c).map(shellQuote).join(' ')} || exit 3\nsh -c "$1" shard-seed "$2" "$3" "$4" background >/dev/null || true`;
452
+ 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 });
453
+ if (result.exitCode !== 0)
454
+ throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
455
+ }
222
456
  export async function resumeConversation(ctx, c) {
223
457
  if (!conversationEnded(c))
224
458
  return getConversation(ctx, c.id);