@shardflux/cli 0.18.0 → 0.19.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 (54) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +28 -1
  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 +55 -18
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +16 -4
  18. package/dist/command-center-actions.js +31 -5
  19. package/dist/conversation-commands.js +6 -1
  20. package/dist/conversation-router.js +7 -11
  21. package/dist/conversation-stash.js +64 -18
  22. package/dist/conversation-terminal.d.ts +11 -1
  23. package/dist/conversation-terminal.js +28 -7
  24. package/dist/conversations.d.ts +17 -3
  25. package/dist/conversations.js +58 -6
  26. package/dist/errors.d.ts +1 -1
  27. package/dist/errors.js +1 -1
  28. package/dist/files-mirror.d.ts +1 -9
  29. package/dist/files-mirror.js +19 -3
  30. package/dist/format.js +37 -2
  31. package/dist/handlers.js +3 -1
  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-launch.js +3 -1
  49. package/dist/t3-mode.d.ts +7 -2
  50. package/dist/t3-mode.js +39 -5
  51. package/dist/thread-on-computer.js +4 -0
  52. package/dist/worktree-twin.d.ts +2 -6
  53. package/dist/worktree-twin.js +16 -51
  54. package/package.json +5 -5
@@ -5,7 +5,7 @@ import { ensureCloudClaude } from "./claude-cloud-login.js";
5
5
  import { spawn } from 'node:child_process';
6
6
  import { Readable, Duplex } from 'node:stream';
7
7
  import { ShardfluxApiError, WorkspaceTunnels } from '@shardflux/sdk';
8
- import { conversationWorkspace, getConversation } from "./conversations.js";
8
+ import { getConversation } from "./conversations.js";
9
9
  import { out, resolveWorkspace } from "./commands.js";
10
10
  import { MODEL_CHOICES } from "./agent-models.js";
11
11
  import { AgentTargets, installedTargets, targetLabel } from "./agent-targets.js";
@@ -229,14 +229,21 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
229
229
  if (target === 't3' && /^conv_[a-z0-9]{26}$/.test(ref))
230
230
  return openT3Conversation(ctx, ref, values);
231
231
  // A conversation id opens that conversation's own session on its computer (claude.ai, the desktop apps).
232
- const conversation = /^conv_[a-z0-9]{26}$/.test(ref) ? await getConversation(ctx, ref) : undefined;
232
+ let conversation = /^conv_[a-z0-9]{26}$/.test(ref) ? await getConversation(ctx, ref) : undefined;
233
233
  // A pooled thread (0.18.0+) has no provider login on its session: it opens remotely in T3 Code.
234
234
  if (conversation && conversationPooled(conversation)) {
235
235
  if (!detected.targets.includes('t3'))
236
236
  throw pooledNeedsT3(ref);
237
237
  return openT3Conversation(ctx, ref, values);
238
238
  }
239
- let ws = conversation ? await conversationWorkspace(ctx, conversation) : await resolveWorkspace(ctx, keyOf(ref));
239
+ // A conversation whose VM was removed restores into a fresh one first (0.18.1+).
240
+ let ws;
241
+ if (conversation) {
242
+ const { reopenConversation } = await import("./conversation-terminal.js");
243
+ ({ conversation, ws } = await reopenConversation(ctx, conversation, values));
244
+ }
245
+ else
246
+ ws = await resolveWorkspace(ctx, keyOf(ref));
240
247
  if (values.reachable !== undefined)
241
248
  ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
242
249
  // T3 lists both agents and opens without Claude (it shows Claude as signed out); the Claude apps need it.
@@ -270,10 +277,13 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
270
277
  const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
271
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);
272
279
  const instructions = target === 't3'
273
- ? `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.
274
281
  : target === 'claude-desktop'
275
282
  ? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
276
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;
277
287
  let started;
278
288
  if (target === 't3' && thread) {
279
289
  try {
@@ -283,10 +293,15 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
283
293
  throw withT3Context(error, { workspace_key: ws.key, conversation_id: conversation ?? (ws.labels.shard_conversation || null) });
284
294
  }
285
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).
286
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;
287
302
  // The thread exists once startSshThread returns: a failure to show T3 names it rather than inviting a retry.
288
303
  try {
289
- await openT3(ctx.io.env, install ?? t3Install(ctx.io.env));
304
+ await openT3(ctx.io.env, install ?? t3Install(ctx.io.env), page);
290
305
  }
291
306
  catch (error) {
292
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;
@@ -297,35 +312,57 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
297
312
  new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
298
313
  if (target === 't3')
299
314
  new AgentStateStore(ctx.store.dir).saveT3Environment({ workspace: ws.key, host: entry.host, api_url: ctx.apiUrl });
300
- 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` : '';
301
316
  const threadLine = started ? `Started a thread in T3 Code on ${agentMachineName(ws.key)}${started.model ? ` (${started.model}${started.effort ? `, ${started.effort}` : ''})` : ''}.\n` : '';
302
- 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 } : {}),
303
318
  ...(target === 't3' ? t3Fields(ws, entry.project, install ?? t3Install(ctx.io.env), started, conversation) : {}),
304
319
  app_bundle_id: { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target] });
305
320
  return 0;
306
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';
307
324
  /**
308
- * The T3 app on the phone reaches this workspace through the account's T3 Connect sign-in (0.17.0+). The sign-in is
309
- * 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.
310
327
  */
311
328
  async function linkT3Phone(ctx, ws) {
312
329
  try {
313
330
  const { link, signIn, needsLogin } = await t3PhoneLink(ctx, ws.id);
314
- const line = signIn
315
- ? `Phone: to use ${link.label} from the T3 app, open ${signIn.verification_uri} and confirm ${signIn.user_code} (once).\n`
316
- : needsLogin ? `Phone: to use ${link.label} from the T3 app, sign in with shard auth login, then run this again.\n`
317
- : `Phone: ${link.label} appears in the T3 app's environments.\n`;
318
- // --json carries the sign-in shard started with its session too, so the menu bar can show it.
319
- 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 };
320
332
  }
321
333
  catch (error) {
322
- // 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.
323
335
  if (describeError(error).code === 'not_found')
324
336
  return undefined;
325
- ctx.io.stderr.write(`Phone link: ${describeError(error).message}\n`);
337
+ ctx.io.stderr.write(`T3 Connect: ${describeError(error).message}\n`);
326
338
  return undefined;
327
339
  }
328
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
+ }
329
366
  export async function queueT3Prewarm(ctx, ws, version) {
330
367
  try {
331
368
  await prewarmT3(ctx, ws, version);
@@ -341,7 +378,7 @@ function configuredTargets(targets, e, t3 = false) {
341
378
  { target: 'ssh', label: 'SSH', file: targets.sshFile, name: e.host, detail: `(included from ${tilde(targets, join(targets.home, '.ssh', 'config'))}): ssh ${e.host}` },
342
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` }] : []),
343
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` }] : []),
344
- ...(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` }] : []),
345
382
  ];
346
383
  }
347
384
  function targetFiles(targets, entries) {
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.18.0";
1
+ export declare const CLI_VERSION = "0.19.0";
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.0';
16
+ export const CLI_VERSION = '0.19.0';
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,8 +1299,9 @@ 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).' } } },
1291
- { path: ['agents', 'logs'], summary: 'Tail an agent PTY from its output offset', positionals: [{ name: 'agent', required: true }], options: {
1292
- app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' },
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: {} },
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: {
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).' },
1294
1306
  follow: { type: 'boolean', description: 'Follow output until interrupted.' },
1295
1307
  } },
@@ -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'] },
@@ -11,9 +11,11 @@ import { agentName as gitAgentName } from "./agent-git.js";
11
11
  import { randomUUID } from 'node:crypto';
12
12
  import { prepareAgentMachine } from "./agent-machine.js";
13
13
  import { OperationFailedError } from '@shardflux/sdk';
14
+ import { conversationPtyId, getConversation } from "./conversations.js";
14
15
  import { launchConversationProject, launchProjectCommand } from "./conversation-terminal.js";
15
16
  import { startThreadOn } from "./thread-on-computer.js";
16
17
  import { launchT3 } from "./t3-launch.js";
18
+ const CONVERSATION_ID = /^conv_[a-z0-9]{26}$/;
17
19
  /** L9 can register target-specific handlers after detecting the installed applications. */
18
20
  export async function availableTargets(ctx) {
19
21
  const { HANDLERS } = await import("./handlers.js");
@@ -124,14 +126,38 @@ export const CENTER_HANDLERS = {
124
126
  },
125
127
  async 'agents logs'(ctx, values, [ref]) {
126
128
  if (!ref)
127
- throw new UsageError('agents logs requires an agent workspace key');
129
+ throw new UsageError('agents logs requires a conversation id or an agent workspace key');
128
130
  if (values.app !== undefined && values.app !== 'claude' && values.app !== 'codex')
129
131
  throw new UsageError('--app must be claude or codex');
130
- const ws = await resolveWorkspace(ctx, ref);
132
+ // A conversation (0.18.1+): its id, or its VM's key. Its PTY is the guest's record, never derived from the VM key.
133
+ let conversation;
134
+ let ws;
135
+ if (CONVERSATION_ID.test(ref)) {
136
+ conversation = await getConversation(ctx, ref);
137
+ ws = await ctx.cloud().workspaces.get(conversation.workspace_id);
138
+ }
139
+ else {
140
+ ws = await resolveWorkspace(ctx, ref);
141
+ if (ws.labels.shard_conversation && CONVERSATION_ID.test(ws.labels.shard_conversation))
142
+ conversation = await getConversation(ctx, ws.labels.shard_conversation);
143
+ }
144
+ if (conversation?.route === 'codex-cloud')
145
+ throw new UsageError(`Codex Cloud runs this conversation; follow it with shard agents open ${conversation.id}.`);
146
+ if (conversation?.route === 'acp')
147
+ throw new UsageError('This conversation runs through its ACP client, which shows its output; it has no terminal to follow.');
148
+ if (conversation && values.app !== undefined && values.app !== conversation.harness)
149
+ throw new UsageError(`This conversation runs ${conversation.harness}; omit --app.`);
131
150
  const project = ws.labels.repo_remote || ws.labels.shard_project || ws.key;
132
- const app = str(values.app) ?? new AgentStateStore(ctx.store.dir).app(project, ws.labels.app === 'codex' ? 'codex' : 'claude');
133
- const id = `shard-${ws.labels.agent_pty_name || agentName(ws.key, ws.labels)}-${app}`;
151
+ const app = conversation?.harness ?? str(values.app) ?? new AgentStateStore(ctx.store.dir).app(project, ws.labels.app === 'codex' ? 'codex' : 'claude');
134
152
  const cell = ws.cell({ agentLabel: ctx.agentLabel, ...(ctx.wake ? {} : { wake: null }), transitionTimeoutMs: ctx.wakeTimeoutMs });
153
+ let id;
154
+ try {
155
+ id = conversation ? await conversationPtyId(cell, conversation, ctx.signal) : `shard-${ws.labels.agent_pty_name || agentName(ws.key, ws.labels)}-${app}`;
156
+ }
157
+ catch (error) {
158
+ cell.close();
159
+ throw error;
160
+ }
135
161
  const offsetText = str(values.offset);
136
162
  if (offsetText !== undefined && (!/^\d+$/.test(offsetText) || !Number.isSafeInteger(Number(offsetText))))
137
163
  throw new UsageError('--offset must be a nonnegative safe integer');
@@ -175,7 +201,7 @@ export const CENTER_HANDLERS = {
175
201
  }
176
202
  const remainder = decoder.decode();
177
203
  if (ctx.json)
178
- ctx.io.stdout.write(json({ pty_id: id, output: output + remainder, nextOffset: cursor.offset, session, exited }));
204
+ ctx.io.stdout.write(json({ pty_id: id, ...(conversation ? { conversation: { id: conversation.id } } : {}), output: output + remainder, nextOffset: cursor.offset, session, exited }));
179
205
  else
180
206
  ctx.io.stdout.write(remainder);
181
207
  return 0;
@@ -22,7 +22,12 @@ export const CONVERSATION_HANDLERS = {
22
22
  return 0;
23
23
  },
24
24
  async 'conversations get'(ctx, _values, [id]) { const c = await conversationClient(ctx).conversations.get(id); const q = questionLine(c, 200); out(ctx, `${c.title}\n${c.state}\n${c.branch}\n${q ? `${q}\n` : ''}`, c); return 0; },
25
- async 'conversations send'(ctx, _values, [id, text]) { const r = await conversationClient(ctx).conversations.send(id, text, { signal: ctx.signal }); out(ctx, `Message sent (${r.seq}).\n`, r); return 0; },
25
+ // A finished conversation whose VM was removed restores into a fresh one, then takes the message (APIs that do; 0.18.1+).
26
+ async 'conversations send'(ctx, _values, [id, text]) {
27
+ const r = await conversationClient(ctx).conversations.send(id, text, { signal: ctx.signal });
28
+ out(ctx, `${r.restored ? 'Restored\n' : ''}Message sent (${r.seq}).\n`, r);
29
+ return 0;
30
+ },
26
31
  async 'conversations wait'(ctx, values, [id]) {
27
32
  const seconds = values.timeout === undefined ? 900 : Number(values.timeout);
28
33
  if (!Number.isInteger(seconds) || seconds < 0 || seconds > 900)
@@ -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,17 @@ 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
+ /**
8
+ * A conversation and its VM, for an app to open (Claude Desktop, claude.ai, T3 Code, the menu bar). A conversation
9
+ * whose VM was removed after it finished restores first (0.18.1+): a fresh VM from its saved branch and transcript, the
10
+ * same conversation and its session resumed. A terminal conversation's session starts detached, as a new one does, so
11
+ * the app reaches it at once; the guest starts the others.
12
+ */
13
+ export declare function reopenConversation(ctx: Ctx, c: Conversation, values: Values): Promise<{
14
+ conversation: Conversation;
15
+ ws: Workspace;
16
+ }>;
7
17
  /**
8
18
  * `shard ./` (and `agents new --in auto`): new conversations open where `agents targets` says (open_in, 0.17.0+): T3
9
19
  * Code mode, Claude Desktop or claude.ai for Claude (the conversation's Remote Control session), else this terminal.
@@ -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";
@@ -23,14 +23,35 @@ import { launchT3 } from "./t3-launch.js";
23
23
  import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-commands.js";
24
24
  import { openUrl } from "./browser.js";
25
25
  import { out } from "./commands.js";
26
+ import { progress } from "./progress.js";
26
27
  /** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
27
- export async function conversationTerminal(ctx, ws, c, project, values) {
28
+ export async function conversationTerminal(ctx, ws, c, project, values, afterStart) {
28
29
  if (c.route === 'codex-cloud')
29
30
  return openCodexConversation(ctx, c);
30
31
  await restoreConversationBinding(ctx, c, ws);
31
32
  // A thread on another conversation's computer has its own PTY there, never the VM's own (0.16.0+).
32
33
  const pty = ws.labels.shard_conversation && ws.labels.shard_conversation !== c.id ? conversationName(c) : undefined;
33
- 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
+ }
36
+ /**
37
+ * A conversation and its VM, for an app to open (Claude Desktop, claude.ai, T3 Code, the menu bar). A conversation
38
+ * whose VM was removed after it finished restores first (0.18.1+): a fresh VM from its saved branch and transcript, the
39
+ * same conversation and its session resumed. A terminal conversation's session starts detached, as a new one does, so
40
+ * the app reaches it at once; the guest starts the others.
41
+ */
42
+ export async function reopenConversation(ctx, c, values) {
43
+ if (!c.ended_at || c.route === 'codex-cloud')
44
+ return { conversation: c, ws: await conversationWorkspace(ctx, c) };
45
+ const restored = await resumeConversation(ctx, c);
46
+ const ws = await conversationWorkspace(ctx, restored);
47
+ if (restored.route !== 'terminal') {
48
+ progress(ctx, 'Restored');
49
+ return { conversation: restored, ws };
50
+ }
51
+ const afterStart = (await savedMachine(ctx))?.template ? undefined : () => syncAgentConfigAfterStart(ctx, ws);
52
+ const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
53
+ await conversationTerminal(quiet, ws, restored, ws.labels.repo_remote || ws.labels.shard_project || ws.key, { ...values, detach: true }, afterStart);
54
+ return { conversation: restored, ws };
34
55
  }
35
56
  /**
36
57
  * `shard ./` (and `agents new --in auto`): new conversations open where `agents targets` says (open_in, 0.17.0+): T3
@@ -126,13 +147,13 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
126
147
  await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
127
148
  if (!signedIn)
128
149
  await loginClaudeInWorkspace(ctx, ws);
129
- if (!(await savedMachine(ctx))?.template)
130
- 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);
131
152
  await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
132
153
  if (openIn) {
133
154
  // The agent starts detached in its VM; the conversation then opens in Claude Desktop or claude.ai (Remote Control).
134
155
  const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
135
- 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);
136
157
  if (openIn === 'claude-desktop')
137
158
  return openClaudeDesktopSession(ctx, ws, c);
138
159
  const url = await remoteControlUrl(ctx, ws, c);
@@ -141,5 +162,5 @@ async function startConversation(ctx, values, [path = './', explicit], openIn) {
141
162
  out(ctx, `${url}\n`, { target: 'web', workspace: ws.data, conversation_id: c.id, url });
142
163
  return 0;
143
164
  }
144
- return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values);
165
+ return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values, afterStart);
145
166
  }
@@ -1,4 +1,4 @@
1
- import type { Workspace, WorkspaceView } from '@shardflux/sdk';
1
+ import type { CellClient, Workspace, WorkspaceView } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
3
  import type { ModelChoice } from './agent-models.js';
4
4
  export type Harness = 'claude' | 'codex';
@@ -65,6 +65,13 @@ export interface LocalConversation {
65
65
  */
66
66
  export declare const conversationEnded: (c: Pick<Conversation, "state" | "ended_at" | "route" | "landed">) => boolean;
67
67
  export declare const conversationName: (c: Conversation) => string;
68
+ /**
69
+ * The managed PTY a conversation's harness runs in (0.18.1+). The guest records it per conversation
70
+ * (sf-agent's conversation runtime record), which also keeps the PTY a fork inherited from its parent; without a
71
+ * record it is the one the guest names after the conversation, `shard-<name>-<harness>`. Never the VM key's name: a
72
+ * conversation's VM is `<project>/conv-<name>`.
73
+ */
74
+ export declare function conversationPtyId(cell: Pick<CellClient, 'exec'>, c: Conversation, signal?: AbortSignal): Promise<string>;
68
75
  export declare function validateConversation(c: Conversation): Conversation;
69
76
  export declare function localConversations(ctx: Pick<Ctx, 'store' | 'apiUrl'>): Record<string, LocalConversation>;
70
77
  export declare function rememberConversation(ctx: Pick<Ctx, 'store' | 'apiUrl'>, c: Conversation, folder: string, handedOff?: boolean, baseBranch?: string): void;
@@ -80,8 +87,15 @@ export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promis
80
87
  */
81
88
  export declare function waitThreadReady(ctx: Ctx, c: Conversation, pollMs?: number): Promise<Conversation>;
82
89
  export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws: Workspace): Promise<void>;
83
- 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>;
84
- 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<{
85
99
  conversation: Conversation;
86
100
  workspace: Workspace;
87
101
  }>;