@shardflux/cli 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +27 -0
  3. package/dist/account-args.js +1 -0
  4. package/dist/agent-commands.d.ts +1 -0
  5. package/dist/agent-commands.js +15 -5
  6. package/dist/agent-config-files.d.ts +16 -2
  7. package/dist/agent-config-files.js +48 -61
  8. package/dist/agent-config-sync.d.ts +12 -1
  9. package/dist/agent-config-sync.js +228 -54
  10. package/dist/agent-repository.d.ts +39 -9
  11. package/dist/agent-repository.js +309 -92
  12. package/dist/agent-state.d.ts +3 -0
  13. package/dist/agent-state.js +13 -0
  14. package/dist/agent-target-commands.d.ts +2 -0
  15. package/dist/agent-target-commands.js +45 -15
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +14 -2
  18. package/dist/conversation-router.js +7 -11
  19. package/dist/conversation-stash.js +64 -18
  20. package/dist/conversation-terminal.d.ts +1 -1
  21. package/dist/conversation-terminal.js +9 -10
  22. package/dist/conversations.d.ts +9 -2
  23. package/dist/conversations.js +23 -6
  24. package/dist/errors.d.ts +1 -1
  25. package/dist/errors.js +1 -1
  26. package/dist/files-mirror.d.ts +1 -9
  27. package/dist/files-mirror.js +19 -3
  28. package/dist/format.js +37 -2
  29. package/dist/handlers.js +3 -1
  30. package/dist/initial-prompt.d.ts +15 -2
  31. package/dist/initial-prompt.js +29 -4
  32. package/dist/local-sessions.d.ts +60 -0
  33. package/dist/local-sessions.js +445 -0
  34. package/dist/main.js +12 -0
  35. package/dist/move.d.ts +44 -0
  36. package/dist/move.js +248 -0
  37. package/dist/moved-sessions.d.ts +33 -0
  38. package/dist/moved-sessions.js +91 -0
  39. package/dist/onboarding.d.ts +18 -3
  40. package/dist/onboarding.js +72 -46
  41. package/dist/project-sync-manifest.d.ts +31 -3
  42. package/dist/project-sync-manifest.js +192 -108
  43. package/dist/project-sync-transfer.d.ts +50 -4
  44. package/dist/project-sync-transfer.js +404 -106
  45. package/dist/project-sync.js +30 -33
  46. package/dist/seed-phases.d.ts +29 -0
  47. package/dist/seed-phases.js +83 -0
  48. package/dist/t3-mode.d.ts +7 -2
  49. package/dist/t3-mode.js +39 -5
  50. package/dist/thread-on-computer.js +4 -0
  51. package/dist/worktree-twin.d.ts +2 -6
  52. package/dist/worktree-twin.js +16 -51
  53. package/package.json +5 -5
@@ -277,10 +277,13 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
277
277
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
278
278
  const entry = (await (ws.labels.shard_router === 'project' ? targets.configureProject(ws.key, ctx.apiUrl, [target], ctx.signal) : targets.configure(ws.key, ctx.apiUrl, [target], false, ctx.signal))).find(e => e.workspace === ws.key);
279
279
  const instructions = target === 't3'
280
- ? `Add environment → SSH → ${entry.host} (${agentMachineName(ws.key)}), then open ${projectWorkspacePath({ slug: entry.project })}.`
280
+ ? '' // T3 Code: where the project is comes from its T3 Connect link (t3Line), set below.
281
281
  : target === 'claude-desktop'
282
282
  ? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
283
283
  : `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
284
+ // T3 Code: the project is linked with the person's T3 Connect sign-in first, then the thread starts in it.
285
+ const fresh = target === 't3' && !new AgentStateStore(ctx.store.dir).t3Environments(ctx.apiUrl).some(e => e.workspace === ws.key);
286
+ let phone = target === 't3' ? await linkT3Phone(ctx, ws) : undefined;
284
287
  let started;
285
288
  if (target === 't3' && thread) {
286
289
  try {
@@ -290,10 +293,15 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
290
293
  throw withT3Context(error, { workspace_key: ws.key, conversation_id: conversation ?? (ws.labels.shard_conversation || null) });
291
294
  }
292
295
  }
296
+ // The first time a project is set up for T3 Code, T3 opens on its Connections page once the project is linked: the
297
+ // project is listed there (or T3's own Sign in to T3 Connect, when T3 Code is signed out).
293
298
  if (target === 't3') {
299
+ if (fresh && phone?.link.status === 'linking')
300
+ phone = await waitT3Linked(ctx, ws, phone);
301
+ const page = fresh && phone?.link.status === 'linked' ? T3_CONNECTIONS_PAGE : undefined;
294
302
  // The thread exists once startSshThread returns: a failure to show T3 names it rather than inviting a retry.
295
303
  try {
296
- await openT3(ctx.io.env, install ?? t3Install(ctx.io.env));
304
+ await openT3(ctx.io.env, install ?? t3Install(ctx.io.env), page);
297
305
  }
298
306
  catch (error) {
299
307
  throw started ? t3OpenFailed(error, { t3_thread_id: started.t3_thread_id, conversation_id: conversation ?? (ws.labels.shard_conversation || null), workspace_key: ws.key }) : error;
@@ -304,35 +312,57 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
304
312
  new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
305
313
  if (target === 't3')
306
314
  new AgentStateStore(ctx.store.dir).saveT3Environment({ workspace: ws.key, host: entry.host, api_url: ctx.apiUrl });
307
- const phone = target === 't3' ? await linkT3Phone(ctx, ws) : undefined;
315
+ const where = target === 't3' ? phone?.line ?? `${targetLabel(entry)} is set up for T3 Code.\n` : '';
308
316
  const threadLine = started ? `Started a thread in T3 Code on ${agentMachineName(ws.key)}${started.model ? ` (${started.model}${started.effort ? `, ${started.effort}` : ''})` : ''}.\n` : '';
309
- out(ctx, `${threadLine}${instructions}\n${phone?.line ?? ''}`, { target, workspace: ws.data, host: entry.host, instructions, ...(phone ? { t3_link: phone.link } : {}),
317
+ out(ctx, target === 't3' ? `${threadLine}${where}` : `${threadLine}${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions: target === 't3' ? where.trim() : instructions, ...(phone ? { t3_link: phone.link } : {}),
310
318
  ...(target === 't3' ? t3Fields(ws, entry.project, install ?? t3Install(ctx.io.env), started, conversation) : {}),
311
319
  app_bundle_id: { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target] });
312
320
  return 0;
313
321
  }
322
+ /** T3 Code's Connections page (its t3code:// link), where a project linked with T3 Connect is listed. */
323
+ export const T3_CONNECTIONS_PAGE = 't3code://app/settings/connections';
314
324
  /**
315
- * The T3 app on the phone reaches this workspace through the account's T3 Connect sign-in (0.17.0+). The sign-in is
316
- * asked for once per person; linking continues by itself once it is approved.
325
+ * T3 Code and the T3 app reach this workspace through the account's T3 Connect sign-in (0.17.0+; on the desktop
326
+ * 0.19.0+). The sign-in is asked for once per person; linking continues by itself once it is approved.
317
327
  */
318
328
  async function linkT3Phone(ctx, ws) {
319
329
  try {
320
330
  const { link, signIn, needsLogin } = await t3PhoneLink(ctx, ws.id);
321
- const line = signIn
322
- ? `Phone: to use ${link.label} from the T3 app, open ${signIn.verification_uri} and confirm ${signIn.user_code} (once).\n`
323
- : needsLogin ? `Phone: to use ${link.label} from the T3 app, sign in with shard auth login, then run this again.\n`
324
- : `Phone: ${link.label} appears in the T3 app's environments.\n`;
325
- // --json carries the sign-in shard started with its session too, so the menu bar can show it.
326
- return { line, link: signIn ? { ...link, sign_in: { ...link.sign_in, ...signIn } } : needsLogin ? { ...link, needs_login: true } : link };
331
+ return { line: t3LinkLine(link, signIn, needsLogin), link: signIn ? { ...link, sign_in: { ...link.sign_in, ...signIn } } : needsLogin ? { ...link, needs_login: true } : link };
327
332
  }
328
333
  catch (error) {
329
- // An API without T3 Connect leaves the desktop route as it is.
334
+ // An API without T3 Connect leaves the project set up for T3 Code over SSH.
330
335
  if (describeError(error).code === 'not_found')
331
336
  return undefined;
332
- ctx.io.stderr.write(`Phone link: ${describeError(error).message}\n`);
337
+ ctx.io.stderr.write(`T3 Connect: ${describeError(error).message}\n`);
333
338
  return undefined;
334
339
  }
335
340
  }
341
+ const t3LinkLine = (link, signIn, needsLogin) => signIn
342
+ ? `To have ${link.label} appear in T3 Code and the T3 app, open ${signIn.verification_uri} and confirm ${signIn.user_code} (once).\n`
343
+ : needsLogin ? `To have ${link.label} appear in T3 Code and the T3 app, sign in with shard auth login, then run this again.\n`
344
+ : link.status === 'linked' ? `${link.label} is linked to your T3 account.\n`
345
+ : `Linking ${link.label} to your T3 account…\n`;
346
+ /** A project linking for the first time: T3 lists it once linked, so its Connections page waits for that (at most 20 s). */
347
+ async function waitT3Linked(ctx, ws, phone) {
348
+ const until = Date.now() + 20_000;
349
+ while (Date.now() < until && !ctx.signal?.aborted) {
350
+ await delay(2000);
351
+ try {
352
+ const known = (await ctx.cloud().t3.status()).links.find(l => l.workspace_id === ws.id);
353
+ if (known?.status === 'linked') {
354
+ const link = { ...phone.link, status: 'linked' };
355
+ return { line: t3LinkLine(link), link };
356
+ }
357
+ if (known && known.status !== 'linking')
358
+ return phone;
359
+ }
360
+ catch {
361
+ return phone;
362
+ }
363
+ }
364
+ return phone;
365
+ }
336
366
  export async function queueT3Prewarm(ctx, ws, version) {
337
367
  try {
338
368
  await prewarmT3(ctx, ws, version);
@@ -348,7 +378,7 @@ function configuredTargets(targets, e, t3 = false) {
348
378
  { target: 'ssh', label: 'SSH', file: targets.sshFile, name: e.host, detail: `(included from ${tilde(targets, join(targets.home, '.ssh', 'config'))}): ssh ${e.host}` },
349
379
  ...(e.targets.includes('claude-desktop') ? [{ target: 'claude-desktop', label: 'Claude Desktop', file: targets.claudeFile, name: targetLabel(e), detail: `(sshConfigs: ${targetLabel(e)}); restart Claude Desktop, then pick it in Code's environment list` }] : []),
350
380
  ...(e.targets.includes('codex-app') ? [{ target: 'codex-app', label: 'Codex app', file: targets.codexFile, name: targetLabel(e), detail: `(remoteConnections: ${targetLabel(e)}); restart Codex to import it` }] : []),
351
- ...(t3 ? [{ target: 't3', label: 'T3 Code', file: targets.sshFile, name: agentMachineName(e.workspace), detail: `Add environment → SSH → ${e.host}; project ${projectWorkspacePath({ slug: e.project })}` }] : []),
381
+ ...(t3 ? [{ target: 't3', label: 'T3 Code', file: targets.sshFile, name: agentMachineName(e.workspace), detail: `SSH host ${e.host}; project ${projectWorkspacePath({ slug: e.project })}; in T3 Code and the T3 app through T3 Connect` }] : []),
352
382
  ];
353
383
  }
354
384
  function targetFiles(targets, entries) {
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.18.1";
1
+ export declare const CLI_VERSION = "0.19.1";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
15
  import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
16
- export const CLI_VERSION = '0.18.1';
16
+ export const CLI_VERSION = '0.19.1';
17
17
  export const GLOBAL_OPTIONS = {
18
18
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
19
19
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -1252,6 +1252,17 @@ export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud age
1252
1252
  /** Bare `shard` without a terminal, or in a coding agent's shell (0.17.1+): the setup question and both paths' steps. */
1253
1253
  export const GUIDE_COMMAND = { path: ['<guide>'], summary: 'Show how to set up Shardflux with a coding agent', positionals: [], options: {} };
1254
1254
  const AGENT_COMMANDS = [
1255
+ { path: ['move'], summary: 'Move a Claude Code or Codex session from this computer to the cloud: the same session, with its history',
1256
+ description: 'Run it in the project folder. The session continues in its own cloud VM as the same session (claude --resume, codex resume), from your project exactly as it is, uncommitted edits included; your working tree, index and branch stay as they are. A running turn finishes here first. The cloud work comes back to this folder by itself, and typing in the local copy says it now continues in the cloud. Sessions from Claude Desktop and the Codex app move the same way.',
1257
+ positionals: [], options: {
1258
+ last: { type: 'boolean', description: 'This project’s most recent session.' },
1259
+ session: { type: 'string', value: '<id>', description: 'The session to move: a Claude Code session id or Codex thread id.' },
1260
+ path: { type: 'string', value: '<path>', description: 'The project folder (default: the current folder).' },
1261
+ claude: { type: 'boolean', description: 'With --last or --list: Claude Code sessions only.' },
1262
+ codex: { type: 'boolean', description: 'With --last or --list: Codex sessions only.' },
1263
+ list: { type: 'boolean', description: 'List this project’s sessions on this computer, and where the moved ones continue.' },
1264
+ detach: { type: 'boolean', description: 'Start it in the cloud without opening it here.' },
1265
+ }, examples: ['shard move --last', 'shard move --list', 'shard move --session 3d085e35-aa66-4866-9a53-f09719d6767c --detach --json'] },
1255
1266
  { path: ['device', 'requests'], summary: 'Review local device requests waiting for approval', positionals: [], options: {} },
1256
1267
  ...['approve', 'deny'].map((action) => ({ path: ['device', action], summary: `${action} a local device request`, positionals: [{ name: 'id', required: true }], options: action === 'approve' ? { always: { type: 'boolean', description: 'Allow matching requests for this project; never for outside-folder access.' } } : {} })),
1257
1268
  { path: ['conversations', 'try'], summary: 'Try a conversation in a synced local worktree', positionals: [{ name: 'conversation', required: true }], options: { path: { type: 'string', value: '<path>', description: 'Local project checkout.' }, stop: { type: 'boolean', description: 'Stop the device helper’s local sync and ports.' } } },
@@ -1288,6 +1299,7 @@ const AGENT_COMMANDS = [
1288
1299
  } },
1289
1300
  { path: ['agents', 'fork'], summary: 'Fork an agent’s memory and disk', positionals: [{ name: 'agent', required: true }, { name: 'name', required: true }], options: WAIT },
1290
1301
  { path: ['agents', 'park'], summary: 'Suspend an agent when idle', positionals: [{ name: 'agent', required: true }], options: { 'when-idle': { type: 'string', value: '<duration>', description: 'Idle grace before suspend (default 1s).' } } },
1302
+ { path: ['agents', 'config-sync'], hidden: true, summary: 'Copy this computer’s Claude and Codex config into a conversation VM (started in the background after a start, 0.19.0+)', positionals: [{ name: 'workspace', required: true }], options: {} },
1291
1303
  { path: ['agents', 'logs'], summary: 'Tail an agent’s terminal from its output offset: a conversation id (0.18.1+) or an agent workspace key', positionals: [{ name: 'agent', required: true }], options: {
1292
1304
  app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice; a conversation uses its own).' },
1293
1305
  offset: { type: 'string', value: '<bytes>', description: 'Read from this output byte offset (default last 64 KiB).' },
@@ -1719,7 +1731,7 @@ export const EXIT_CODE_HELP = [
1719
1731
  /** The top help's sections: every command's group (first word) is in exactly one (args.test.ts checks). */
1720
1732
  export const HELP_SECTIONS = [
1721
1733
  { title: 'Account (a user session: shard signup or shard auth login)', groups: ['signup', 'verify', 'auth', 'setup', 'env', 'orgs', 'projects', 'api-keys', 'members', 'invitations', 'billing', 'audit', 'account'] },
1722
- { title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'machine', 'template', 'agents', 'accounts', 'device', 'cli-proxy', 'ssh-proxy'] },
1734
+ { title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'move', 'machine', 'template', 'agents', 'accounts', 'device', 'cli-proxy', 'ssh-proxy'] },
1723
1735
  { title: 'Workspaces for your app (a project API key: SHARDFLUX_API_KEY, else the one shard setup saved)', groups: ['login', 'whoami', 'status', 'usage', 'workspaces', 'files', 'operations', 'executions', 'secrets', 'credentials', 'volumes', 'egress'] },
1724
1736
  { title: 'Templates for your app’s workspaces (not for cloud agents; publish and archive use the session, the rest the API key)', groups: ['templates'] },
1725
1737
  { title: 'Other', groups: ['menubar', 'update', 'feedback', 'version'] },
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { ShardfluxApiError } from '@shardflux/sdk';
3
- import { workspaceRepositoryPath, projectWorkspacePath, HISTORY_BUNDLE_LIMIT } from "./agent-repository.js";
4
- import { writeSyncBlob } from "./project-sync-transfer.js";
3
+ import { workspaceRepositoryPath, projectWorkspacePath } from "./agent-repository.js";
4
+ import { copySyncBlob } from "./project-sync-transfer.js";
5
5
  import { UsageError } from "./errors.js";
6
6
  import { conversationClient } from "./conversation-commands.js";
7
7
  export const ROUTER_CONFIG = '/home/user/.config/shardflux/conversation-router.json';
@@ -58,18 +58,14 @@ export async function prepareDesktopRouter(ctx, source) {
58
58
  const archive = `/tmp/shard-router-${randomUUID()}.bundle`;
59
59
  try {
60
60
  if (created) {
61
- const packed = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), '-c', 'core.hooksPath=/dev/null', 'bundle', 'create', archive, 'HEAD'], { timeoutMs: 120_000, signal: ctx.signal });
61
+ const packed = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), '-c', 'core.hooksPath=/dev/null', 'bundle', 'create', archive, 'HEAD'], { timeoutMs: 600_000, signal: ctx.signal });
62
62
  if (packed.exitCode !== 0)
63
63
  throw new Error(`Project router seed failed: ${packed.stderr.trim()}`);
64
- const info = await cell.files.stat(archive);
65
- if (info.size > HISTORY_BUNDLE_LIMIT)
66
- throw new UsageError('Project router history exceeds the 256 MiB transfer bound.');
67
- const bytes = await cell.files.read(archive, { length: info.size + 1 });
68
- if (bytes.length !== info.size)
69
- throw new Error('Project router history changed during copying.');
70
- await writeSyncBlob(destination, archive, bytes);
64
+ // Any history size: the bundle goes VM to VM one chunk at a time, and the import may take a second per 8 MiB.
65
+ const { size } = await cell.files.stat(archive);
66
+ await copySyncBlob(cell, archive, size, destination, archive, ctx.signal);
71
67
  await destination.files.mkdir(root, { parents: true });
72
- const imported = await destination.exec.run(['sh', '-c', 'set -eu; git -C "$1" init -q; git -C "$1" -c core.hooksPath=/dev/null fetch -q "$2" HEAD:refs/heads/shardflux-router; git -C "$1" -c core.hooksPath=/dev/null checkout -q shardflux-router', 'sh', root, archive], { timeoutMs: 120_000, signal: ctx.signal });
68
+ const imported = await destination.exec.run(['sh', '-c', 'set -eu; git -C "$1" init -q; git -C "$1" -c core.hooksPath=/dev/null fetch -q "$2" HEAD:refs/heads/shardflux-router; git -C "$1" -c core.hooksPath=/dev/null checkout -q shardflux-router', 'sh', root, archive], { timeoutMs: 120_000 + Math.ceil(size / (8 << 20)) * 1000, signal: ctx.signal });
73
69
  if (imported.exitCode !== 0)
74
70
  throw new Error(`Project router repository could not be prepared: ${imported.stderr.trim()}`);
75
71
  }
@@ -1,13 +1,16 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
3
- import { dirname, join, resolve } from 'node:path';
3
+ import { open, rename, rm } from 'node:fs/promises';
4
+ import { basename, dirname, join, resolve } from 'node:path';
4
5
  import { homedir } from 'node:os';
5
6
  import { conversationName, listConversations, localConversations, rememberConversation } from "./conversations.js";
6
7
  import { gitRunner } from "./agent-git.js";
7
8
  import { shardRemote } from "./git-remote.js";
8
9
  import { writePrivateFile } from "./credentials.js";
9
10
  import { conversationClient } from "./conversation-commands.js";
10
- const MAX = 64 << 20; // Guest FileManifest uses the project-sync 64 MiB per-file bound.
11
+ import { movedSession, movedUnchanged } from "./moved-sessions.js";
12
+ import { claudeProjectName } from "./local-sessions.js";
13
+ import { remoteManifest, SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
11
14
  export function rewriteTranscript(bytes, remoteCwd, localCwd) {
12
15
  const text = new TextDecoder('utf8', { fatal: true }).decode(bytes);
13
16
  const rewrite = (value) => {
@@ -30,7 +33,7 @@ export function transcriptDestination(c, folder, filename, env) {
30
33
  throw new Error('Transcript filename is not a native JSONL file.');
31
34
  const home = env.HOME || homedir();
32
35
  if (c.harness === 'claude')
33
- return join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects', resolve(folder).replace(/[^a-zA-Z0-9]/g, '-'), filename);
36
+ return join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects', claudeProjectName(resolve(folder)), filename);
34
37
  const match = /^rollout-(\d{4})-(\d{2})-(\d{2})T/.exec(filename);
35
38
  if (!match)
36
39
  throw new Error('Codex transcript has no native rollout date.');
@@ -61,6 +64,43 @@ function readStashFile(file) {
61
64
  closeSync(fd);
62
65
  }
63
66
  }
67
+ /**
68
+ * Copies one transcript into the stash in SYNC_CHUNK_BYTES reads (any size, never whole in memory): checked as UTF-8
69
+ * and against the manifest hash on the way, written private (0600) next to its destination and renamed into place.
70
+ */
71
+ async function copyTranscript(cell, path, entry, dir, file, signal) {
72
+ const tmp = join(dir, `.${entry.path}.${randomUUID()}.tmp`);
73
+ try {
74
+ const fd = await open(tmp, 'wx', 0o600);
75
+ try {
76
+ const hash = createHash('sha256'), utf8 = new TextDecoder('utf8', { fatal: true });
77
+ for (let offset = 0; offset < entry.size;) {
78
+ signal.throwIfAborted();
79
+ const want = Math.min(SYNC_CHUNK_BYTES, entry.size - offset);
80
+ // A read below the gateway's per-read cap returns what was asked unless the file shrank.
81
+ const chunk = await cell.files.read(path, { offset, length: want });
82
+ if (chunk.length !== want)
83
+ throw new Error('Transcript changed during copying; retaining the previous stash.');
84
+ hash.update(chunk);
85
+ utf8.decode(chunk, { stream: true });
86
+ for (let at = 0; at < chunk.length;)
87
+ at += (await fd.write(chunk, at, chunk.length - at, offset + at)).bytesWritten;
88
+ offset += chunk.length;
89
+ }
90
+ utf8.decode();
91
+ if (hash.digest('hex') !== entry.sha256)
92
+ throw new Error('Transcript changed during copying; retaining the previous stash.');
93
+ await fd.sync();
94
+ }
95
+ finally {
96
+ await fd.close();
97
+ }
98
+ await rename(tmp, file);
99
+ }
100
+ finally {
101
+ await rm(tmp, { force: true });
102
+ }
103
+ }
64
104
  /** Keep growing copies away from native session discovery until the control plane ends the conversation. */
65
105
  export async function mirrorConversation(ctx, record, source, skipped = new Set()) {
66
106
  const c = record.conversation;
@@ -85,7 +125,8 @@ export async function mirrorConversation(ctx, record, source, skipped = new Set(
85
125
  ctx.signal.addEventListener('abort', close, { once: true });
86
126
  try {
87
127
  ctx.signal.throwIfAborted();
88
- const manifest = await cell.files.manifest(root, { maxFileBytes: MAX, signal: ctx.signal });
128
+ // Transcripts of any size: no per-file bound (an older guest's size skips are hashed in the VM).
129
+ const manifest = await remoteManifest(cell, root, undefined, ctx.signal);
89
130
  for (const entry of manifest.skipped)
90
131
  skip(entry.path, entry.reason);
91
132
  let count = 0;
@@ -98,8 +139,8 @@ export async function mirrorConversation(ctx, record, source, skipped = new Set(
98
139
  skip(entry.path, 'not a safe top-level JSONL or JSON file');
99
140
  continue;
100
141
  }
101
- if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > MAX) {
102
- skip(entry.path, 'outside the 64 MiB mirror bound');
142
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
143
+ skip(entry.path, 'invalid size');
103
144
  continue;
104
145
  }
105
146
  try {
@@ -109,12 +150,8 @@ export async function mirrorConversation(ctx, record, source, skipped = new Set(
109
150
  continue;
110
151
  }
111
152
  const expected = existing ? createHash('sha256').update(readStashFile(file)).digest('hex') : '';
112
- if (expected !== entry.sha256) {
113
- const bytes = await cell.files.read(`${root}/${entry.path}`, { length: entry.size + 1 });
114
- if (bytes.length !== entry.size || createHash('sha256').update(bytes).digest('hex') !== entry.sha256)
115
- throw new Error('Transcript changed during copying; retaining the previous stash.');
116
- writePrivateFile(dir, file, new TextDecoder('utf8', { fatal: true }).decode(bytes));
117
- }
153
+ if (expected !== entry.sha256)
154
+ await copyTranscript(cell, `${root}/${entry.path}`, entry, dir, file, ctx.signal);
118
155
  }
119
156
  catch (e) {
120
157
  throw new Error(`Entry ${JSON.stringify(entry.path)}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
@@ -148,10 +185,16 @@ export async function handoffConversation(ctx, c, folder, dir, source) {
148
185
  const files = readdirSync(dir).filter(f => !f.includes('..') && /^[a-zA-Z0-9_.-]+\.jsonl$/.test(f) && lstatSync(join(dir, f)).isFile());
149
186
  if (!files.length)
150
187
  throw new Error('Conversation has no transcript to hand off.');
151
- const writes = files.map(name => ({ dest: transcriptDestination(c, folder, name, ctx.io.env), bytes: rewriteTranscript(readStashFile(join(dir, name)), c.worktree_path, folder) }));
188
+ // A session moved in from this computer (`shard move`) comes back to the file it left; it replaces that local copy only
189
+ // while the copy holds exactly what moved (nobody continued it here meanwhile).
190
+ const moved = c.harness_session_id ? movedSession(ctx.store.dir, ctx.apiUrl, c.harness, c.harness_session_id) : undefined;
191
+ const writes = files.map(name => {
192
+ const dest = moved && name === basename(moved.file) ? moved.file : transcriptDestination(c, folder, name, ctx.io.env);
193
+ return { dest, bytes: rewriteTranscript(readStashFile(join(dir, name)), c.worktree_path, folder), replace: !!moved && dest === moved.file && movedUnchanged(moved) };
194
+ });
152
195
  // Check every destination before the first write. Preserve local native history on any collision.
153
- for (const { dest, bytes } of writes) {
154
- if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || !readFileSync(dest).equals(Buffer.from(bytes))))
196
+ for (const { dest, bytes, replace } of writes) {
197
+ if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || (!replace && !readFileSync(dest).equals(Buffer.from(bytes)))))
155
198
  throw new Error(`Native transcript already exists with different bytes: ${dest}`);
156
199
  }
157
200
  if (!source)
@@ -160,9 +203,12 @@ export async function handoffConversation(ctx, c, folder, dir, source) {
160
203
  const run = gitRunner(folder, { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl }, ctx.signal);
161
204
  const hub = 'hub' in source ? source.hub : shardRemote(source.key, '/shardflux/project/git/hub.git');
162
205
  await run(['-c', 'core.hooksPath=/dev/null', 'fetch', '--no-tags', hub, `+${c.state === 'archived' ? `refs/shard/archive/${name}` : `refs/heads/${c.branch}`}:refs/remotes/shardflux/${name}`]);
163
- for (const { dest, bytes } of writes) {
206
+ for (const { dest, bytes, replace } of writes) {
164
207
  privateDirectory(dirname(dest));
165
- if (!existsSync(dest))
208
+ // The local copy it replaces stays in the stash.
209
+ if (replace && existsSync(dest) && !readFileSync(dest).equals(Buffer.from(bytes)))
210
+ writePrivateFile(dir, join(dir, `local-${basename(dest)}`), readStashFile(dest).toString('utf8'));
211
+ if (!existsSync(dest) || replace)
166
212
  writePrivateFile(dirname(dest), dest, new TextDecoder('utf8', { fatal: true }).decode(bytes));
167
213
  }
168
214
  rememberConversation(ctx, c, folder, true);
@@ -3,7 +3,7 @@ import type { Ctx, Handler } from './commands.js';
3
3
  import type { Values } from './args.js';
4
4
  import type { Conversation } from './conversations.js';
5
5
  /** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
6
- export declare function conversationTerminal(ctx: Ctx, ws: Workspace, c: Conversation, project: string, values: Values): Promise<number>;
6
+ export declare function conversationTerminal(ctx: Ctx, ws: Workspace, c: Conversation, project: string, values: Values, afterStart?: () => Promise<void> | undefined): Promise<number>;
7
7
  /**
8
8
  * A conversation and its VM, for an app to open (Claude Desktop, claude.ai, T3 Code, the menu bar). A conversation
9
9
  * whose VM was removed after it finished restores first (0.18.1+): a fresh VM from its saved branch and transcript, the
@@ -9,7 +9,7 @@ import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from
9
9
  import { syncAgentCredentials } from "./agent-credentials.js";
10
10
  import { prepareProjectTemplate } from "./project-template-setup.js";
11
11
  import { projectWorkspacePath, seedAgentRepository } from "./agent-repository.js";
12
- import { syncAgentConfig } from "./agent-config-sync.js";
12
+ import { syncAgentConfig, syncAgentConfigAfterStart } from "./agent-config-sync.js";
13
13
  import { savedMachine } from "./filesystem-state.js";
14
14
  import { ensureMenubar, MenubarManager } from "./menubar.js";
15
15
  import { UsageError } from "./errors.js";
@@ -25,13 +25,13 @@ import { openUrl } from "./browser.js";
25
25
  import { out } from "./commands.js";
26
26
  import { progress } from "./progress.js";
27
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) {
28
+ export async function conversationTerminal(ctx, ws, c, project, values, afterStart) {
29
29
  if (c.route === 'codex-cloud')
30
30
  return openCodexConversation(ctx, c);
31
31
  await restoreConversationBinding(ctx, c, ws);
32
32
  // A thread on another conversation's computer has its own PTY there, never the VM's own (0.16.0+).
33
33
  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) });
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 } : {}) });
35
35
  }
36
36
  /**
37
37
  * A conversation and its VM, for an app to open (Claude Desktop, claude.ai, T3 Code, the menu bar). A conversation
@@ -48,10 +48,9 @@ export async function reopenConversation(ctx, c, values) {
48
48
  progress(ctx, 'Restored');
49
49
  return { conversation: restored, ws };
50
50
  }
51
- if (!(await savedMachine(ctx))?.template)
52
- await syncAgentConfig(ctx, ws);
51
+ const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
53
52
  const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
54
- await conversationTerminal(quiet, ws, restored, ws.labels.repo_remote || ws.labels.shard_project || ws.key, { ...values, detach: true });
53
+ await conversationTerminal(quiet, ws, restored, ws.labels.repo_remote || ws.labels.shard_project || ws.key, { ...values, detach: true }, afterStart);
55
54
  return { conversation: restored, ws };
56
55
  }
57
56
  /**
@@ -148,13 +147,13 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
148
147
  await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
149
148
  if (!signedIn)
150
149
  await loginClaudeInWorkspace(ctx, ws);
151
- if (!(await savedMachine(ctx))?.template)
152
- await syncAgentConfig(ctx, ws);
150
+ // 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);
153
152
  await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
154
153
  if (openIn) {
155
154
  // The agent starts detached in its VM; the conversation then opens in Claude Desktop or claude.ai (Remote Control).
156
155
  const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
157
- await conversationTerminal(quiet, ws, c, project.remote ?? project.folder, { ...values, detach: true });
156
+ await conversationTerminal(quiet, ws, c, project.remote ?? project.folder, { ...values, detach: true }, afterStart);
158
157
  if (openIn === 'claude-desktop')
159
158
  return openClaudeDesktopSession(ctx, ws, c);
160
159
  const url = await remoteControlUrl(ctx, ws, c);
@@ -163,5 +162,5 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
163
162
  out(ctx, `${url}\n`, { target: 'web', workspace: ws.data, conversation_id: c.id, url });
164
163
  return 0;
165
164
  }
166
- return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values);
165
+ return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values, afterStart);
167
166
  }
@@ -87,8 +87,15 @@ export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promis
87
87
  */
88
88
  export declare function waitThreadReady(ctx: Ctx, c: Conversation, pollMs?: number): Promise<Conversation>;
89
89
  export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws: Workspace): Promise<void>;
90
- export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, allocated?: (workspace: WorkspaceView) => void, idempotencyKey?: string, choice?: ModelChoice): Promise<Conversation>;
91
- export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, choice?: ModelChoice): Promise<{
90
+ /** A native session moved in from this computer (`shard move`, 0.19.0+): the conversation starts by resuming it. */
91
+ export interface MovedInSession {
92
+ session_id: string;
93
+ title?: string;
94
+ }
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>;
96
+ export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, choice?: ModelChoice, movedIn?: MovedInSession & {
97
+ beforeSeed(ws: Workspace, c: Conversation): Promise<void>;
98
+ }): Promise<{
92
99
  conversation: Conversation;
93
100
  workspace: Workspace;
94
101
  }>;
@@ -4,12 +4,13 @@ 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 { seedAgentRepository } from "./agent-repository.js";
7
+ import { preflightProjectSeed, seedAgentRepository } from "./agent-repository.js";
8
8
  import { UsageError } from "./errors.js";
9
9
  import { conversationClient } from "./conversation-commands.js";
10
10
  import { recordSeedSnapshot } from "./files-mirror.js";
11
11
  import { rememberTwinSeed, sendTwinSeed, twinCheckoutArgv, twinSeed } from "./worktree-twin.js";
12
12
  import { progress } from "./progress.js";
13
+ import { seedPhase, startProgress } from "./seed-phases.js";
13
14
  /**
14
15
  * Whether the conversation has ended: its VM was reclaimed, or it was archived or discarded. A finished turn is `done`
15
16
  * while its VM still takes the next turn; on Codex Cloud, `done` ends the conversation.
@@ -156,9 +157,11 @@ export async function restoreConversationBinding(ctx, c, ws) {
156
157
  if (c.restore.kind === 'resume')
157
158
  progress(ctx, 'Restored');
158
159
  }
159
- export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}) {
160
+ export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}, movedIn) {
160
161
  const project = projectIdentity(folder);
161
162
  await ensureProjectGit(ctx, project.folder);
163
+ // A start refuses before a VM is allocated, never after (0.19.0+).
164
+ await preflightProjectSeed(project.folder, ctx.signal);
162
165
  // Both the conversation API and its legacy workspace fallback need a usable local HEAD.
163
166
  await conversationClient(ctx).conversations.list({ limit: 1 });
164
167
  const git = gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal);
@@ -166,32 +169,46 @@ export async function createConversationRecord(ctx, folder, harness, route, name
166
169
  const branch = await git(['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => undefined);
167
170
  // Uncommitted edits ride along as the twin snapshot the conversation starts from (worktree-twin.ts).
168
171
  const seed = await twinSeed(ctx.store.dir, project.folder, head, ctx.store.profile(ctx.apiUrl).project_id).catch(() => ({ commit: head, snapshot: false }));
172
+ seedPhase('local_snapshot');
169
173
  const commit = seed.commit;
170
174
  const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
171
- base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
175
+ base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id,
176
+ ...(movedIn ? { harnessSessionId: movedIn.session_id, ...(movedIn.title ? { title: movedIn.title.slice(0, 200) } : {}) } : {}) }, { idempotencyKey, signal: ctx.signal });
172
177
  const c = validateConversation({ ...response.conversation, restore: response.restore });
173
178
  allocated?.(response.workspace);
174
179
  rememberTwinSeed(ctx.store.dir, c.id, project.folder, seed, branch);
175
180
  rememberConversation(ctx, c, project.folder, false, branch);
176
181
  return c;
177
182
  }
178
- export async function createConversation(ctx, folder, harness, route, name, choice = {}) {
183
+ export async function createConversation(ctx, folder, harness, route, name, choice = {}, movedIn) {
179
184
  const project = projectIdentity(folder);
180
- const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice);
185
+ seedPhase('start');
186
+ // The launcher's phase line (SHARDFLUX_PROGRESS_FILE, 0.19.0+): a computer starts, then the seed's own marks move it on.
187
+ startProgress(harness, ctx.io.env);
188
+ const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice, movedIn);
189
+ seedPhase('conversation_create');
181
190
  try {
182
191
  const ws = await conversationWorkspace(ctx, c);
192
+ seedPhase('vm_running');
193
+ // A moved-in session's transcript is in place before the repository: its guest resumes it once the seed is done.
194
+ await movedIn?.beforeSeed(ws, c);
183
195
  // This is a one-time seed. A conversation never watches or writes the owner's checkout.
184
196
  await seedAgentRepository(ctx, ws, project.folder, { root: c.worktree_path, populate: true });
185
197
  const cell = ws.cell();
198
+ seedPhase('seed_done');
186
199
  await sendTwinSeed(ctx.store.dir, cell, c, project.folder, ctx.signal);
200
+ seedPhase('twin_seed');
187
201
  const result = await cell.exec.run(twinCheckoutArgv(ctx.store.dir, c), { timeoutMs: 30_000, signal: ctx.signal });
188
202
  if (result.exitCode !== 0)
189
203
  throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
204
+ seedPhase('branch_checkout');
190
205
  await recordSeedSnapshot(ws, c, ctx.signal);
206
+ seedPhase('seed_snapshot');
191
207
  return { conversation: c, workspace: ws };
192
208
  }
193
209
  catch (error) {
194
- if (route === 'codex-cloud') {
210
+ // 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) {
195
212
  try {
196
213
  await ctx.cloud().workspaces.delete(c.workspace_id);
197
214
  }
package/dist/errors.d.ts CHANGED
@@ -37,7 +37,7 @@ export declare class GitError extends HintedError {
37
37
  }
38
38
  /**
39
39
  * A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
40
- * e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
40
+ * e.g. `t3_not_installed` (0.17.0+) when T3 Code is missing.
41
41
  */
42
42
  export declare class CodedError extends HintedError {
43
43
  name: string;
package/dist/errors.js CHANGED
@@ -54,7 +54,7 @@ export class GitError extends HintedError {
54
54
  }
55
55
  /**
56
56
  * A refusal `shard` detects itself, with a stable code, its facts in `details` and the next step as `hint` (exit 1),
57
- * e.g. `repository_too_large` (0.13.3+) for Git history over the initial sync limit.
57
+ * e.g. `t3_not_installed` (0.17.0+) when T3 Code is missing.
58
58
  */
59
59
  export class CodedError extends HintedError {
60
60
  name = 'CodedError';
@@ -146,15 +146,7 @@ export declare class FilesMirror {
146
146
  already: boolean;
147
147
  }>;
148
148
  }
149
- /**
150
- * The seed snapshot (sh; $1 repository, $2 conversation name, $3 hub): HEAD plus the working tree the conversation was
151
- * seeded with, committed in the conversation's VM with a temporary index (never on this computer), kept per worktree
152
- * as refs/worktree/shard/seed and in the hub as refs/shard/seed/<name>. Mirrors leave out what it already contains,
153
- * so the user's own uncommitted edits never show as the conversation's change. Threads on one computer are worktrees
154
- * of one repository (0.16.0+), so the seed is per worktree; the shared refs/shard/seed of earlier seeds is read, and
155
- * still written, only in the repository's main worktree.
156
- */
157
- export declare const SEED_SNAPSHOT = "set -eu\ncd \"$1\"\nmain=0; [ \"$(git rev-parse --absolute-git-dir)\" = \"$(git rev-parse --path-format=absolute --git-common-dir)\" ] && main=1\nif git rev-parse -q --verify refs/worktree/shard/seed >/dev/null; then seed=$(git rev-parse refs/worktree/shard/seed)\nelif [ \"$main\" = 1 ] && git rev-parse -q --verify refs/shard/seed >/dev/null; then seed=$(git rev-parse refs/shard/seed); else\nidx=$(mktemp); trap 'rm -f \"$idx\"' EXIT\ncp \"$(git rev-parse --path-format=absolute --git-path index)\" \"$idx\" 2>/dev/null || { rm -f \"$idx\"; GIT_INDEX_FILE=\"$idx\" git read-tree HEAD; }\nGIT_INDEX_FILE=\"$idx\" git add -A\ntree=$(GIT_INDEX_FILE=\"$idx\" git write-tree)\nif [ \"$tree\" = \"$(git rev-parse 'HEAD^{tree}')\" ]; then seed=$(git rev-parse HEAD); else\nseed=$(GIT_AUTHOR_NAME=Shardflux GIT_AUTHOR_EMAIL=seed@shardflux.dev GIT_COMMITTER_NAME=Shardflux GIT_COMMITTER_EMAIL=seed@shardflux.dev git commit-tree \"$tree\" -p HEAD -m \"Shardflux: the working tree this conversation started from\"); fi\ngit update-ref refs/worktree/shard/seed \"$seed\"\nif [ \"$main\" = 1 ]; then git update-ref refs/shard/seed \"$seed\"; fi\nfi\nif [ -d \"$3\" ]; then git -c core.hooksPath=/dev/null push -q \"$3\" \"+$seed:refs/shard/seed/$2\" >/dev/null 2>&1 || true; fi\nprintf '%s\n' \"$seed\"\n";
149
+ export declare const SEED_SNAPSHOT = "set -eu\ncd \"$1\"\nmain=0; [ \"$(git rev-parse --absolute-git-dir)\" = \"$(git rev-parse --path-format=absolute --git-common-dir)\" ] && main=1\nif git rev-parse -q --verify refs/worktree/shard/seed >/dev/null; then seed=$(git rev-parse refs/worktree/shard/seed)\nelif [ \"$main\" = 1 ] && git rev-parse -q --verify refs/shard/seed >/dev/null; then seed=$(git rev-parse refs/shard/seed); else\nidx=$(mktemp); trap 'rm -f \"$idx\"' EXIT\ncp \"$(git rev-parse --path-format=absolute --git-path index)\" \"$idx\" 2>/dev/null || { rm -f \"$idx\"; GIT_INDEX_FILE=\"$idx\" git read-tree HEAD; }\nGIT_INDEX_FILE=\"$idx\" git add -A\ntree=$(GIT_INDEX_FILE=\"$idx\" git write-tree)\nif [ \"$tree\" = \"$(git rev-parse 'HEAD^{tree}')\" ]; then seed=$(git rev-parse HEAD); else\nseed=$(GIT_AUTHOR_NAME=Shardflux GIT_AUTHOR_EMAIL=seed@shardflux.dev GIT_COMMITTER_NAME=Shardflux GIT_COMMITTER_EMAIL=seed@shardflux.dev git commit-tree \"$tree\" -p HEAD -m \"Shardflux: the working tree this conversation started from\"); fi\ngit update-ref refs/worktree/shard/seed \"$seed\"\nif [ \"$main\" = 1 ]; then git update-ref refs/shard/seed \"$seed\"; fi\nfi\npush='set -u\nseed=$1; name=$2; hub=$3; cd \"$4\"\npush=1; if [ \"$(git config --bool remote.origin.promisor 2>/dev/null || true)\" = true ]; then push=0\nfor c in \"$seed\" \"$seed^1\"; do o=$(git rev-parse -q --verify \"$c^{commit}\" 2>/dev/null) && git --git-dir=\"$hub\" cat-file -e \"$o^{commit}\" 2>/dev/null && push=1 && break; done; fi\nif [ -d \"$hub\" ] && [ \"$push\" = 1 ]; then git -c core.hooksPath=/dev/null push -q \"$hub\" \"+$seed:refs/shard/seed/$name\" >/dev/null 2>&1 || true; fi\n'\nif [ \"${4:-}\" = background ]; then\n if command -v setsid >/dev/null 2>&1; then (setsid sh -c \"$push\" shard-seed-push \"$seed\" \"$2\" \"$3\" \"$PWD\" >/dev/null 2>&1 </dev/null &)\n else (sh -c \"$push\" shard-seed-push \"$seed\" \"$2\" \"$3\" \"$PWD\" >/dev/null 2>&1 </dev/null &); fi\nelse sh -c \"$push\" shard-seed-push \"$seed\" \"$2\" \"$3\" \"$PWD\"; fi\nprintf '%s\n' \"$seed\"\n";
158
150
  /** Best effort: without a snapshot the folder also lists the start state's own edits. */
159
151
  export declare function recordSeedSnapshot(ws: Pick<Workspace, 'cell'>, c: Pick<Conversation, 'worktree_path' | 'branch'>, signal?: AbortSignal): Promise<string | null>;
160
152
  /** A ready VM of the project (never woken), else the project's space reader, woken when needed. */