@shardflux/cli 0.13.6 → 0.14.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 (64) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +44 -4
  3. package/dist/acp-catalog.d.ts +7 -0
  4. package/dist/acp-catalog.js +40 -0
  5. package/dist/acp-journal.d.ts +18 -0
  6. package/dist/acp-journal.js +232 -0
  7. package/dist/acp-models.d.ts +36 -0
  8. package/dist/acp-models.js +161 -0
  9. package/dist/acp-rpc.d.ts +41 -0
  10. package/dist/acp-rpc.js +115 -0
  11. package/dist/acp.d.ts +29 -0
  12. package/dist/acp.js +464 -0
  13. package/dist/agent-commands.d.ts +10 -2
  14. package/dist/agent-commands.js +37 -18
  15. package/dist/agent-machine.d.ts +6 -0
  16. package/dist/agent-machine.js +93 -0
  17. package/dist/agent-repository.d.ts +2 -0
  18. package/dist/agent-repository.js +42 -31
  19. package/dist/agent-state.d.ts +3 -0
  20. package/dist/agent-state.js +33 -12
  21. package/dist/agent-target-commands.d.ts +4 -1
  22. package/dist/agent-target-commands.js +110 -40
  23. package/dist/agent-targets.d.ts +6 -4
  24. package/dist/agent-targets.js +72 -31
  25. package/dist/args.d.ts +1 -1
  26. package/dist/args.js +53 -14
  27. package/dist/command-center-actions.js +26 -3
  28. package/dist/command-center.js +20 -4
  29. package/dist/conversation-commands.d.ts +3 -0
  30. package/dist/conversation-commands.js +36 -0
  31. package/dist/conversation-local-resume.d.ts +8 -0
  32. package/dist/conversation-local-resume.js +56 -0
  33. package/dist/conversation-local.d.ts +7 -0
  34. package/dist/conversation-local.js +61 -0
  35. package/dist/conversation-router.d.ts +12 -0
  36. package/dist/conversation-router.js +92 -0
  37. package/dist/conversation-stash.d.ts +11 -0
  38. package/dist/conversation-stash.js +158 -0
  39. package/dist/conversation-terminal.d.ts +3 -0
  40. package/dist/conversation-terminal.js +74 -0
  41. package/dist/conversation-worktree.d.ts +4 -0
  42. package/dist/conversation-worktree.js +22 -0
  43. package/dist/conversations.d.ts +53 -0
  44. package/dist/conversations.js +119 -0
  45. package/dist/device-bridge.d.ts +80 -0
  46. package/dist/device-bridge.js +409 -0
  47. package/dist/device-helper.d.ts +3 -0
  48. package/dist/device-helper.js +104 -0
  49. package/dist/errors.js +5 -0
  50. package/dist/format.js +3 -2
  51. package/dist/git-remote-bin.js +3 -1
  52. package/dist/git-remote.d.ts +3 -1
  53. package/dist/git-remote.js +2 -1
  54. package/dist/handlers.js +3 -1
  55. package/dist/main.js +15 -3
  56. package/dist/project-sync.d.ts +5 -1
  57. package/dist/project-sync.js +7 -6
  58. package/dist/t3-provider.d.ts +6 -0
  59. package/dist/t3-provider.js +99 -0
  60. package/dist/t3-runtime.d.ts +6 -0
  61. package/dist/t3-runtime.js +63 -0
  62. package/dist/target-lock.d.ts +3 -0
  63. package/dist/target-lock.js +143 -0
  64. package/package.json +3 -3
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.13.6';
15
+ export const CLI_VERSION = '0.14.1';
16
16
  export const GLOBAL_OPTIONS = {
17
17
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
18
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -139,10 +139,29 @@ const SECRET_PERMISSIONS = {
139
139
  'allow-project': { type: 'string', multiple: true, value: '<project-id>', description: 'Organization secrets: projects whose workspaces may use it (repeatable). Default: none until granted.' },
140
140
  'all-projects': { type: 'boolean', description: 'Organization secrets: every project of the organization may use it.' },
141
141
  };
142
+ // Cloud agents: a VM is the coding template, then the machine, then the project template.
143
+ const LAYER_HELP = {
144
+ machine: 'Your machine is what every cloud agent starts with: tools, credentials, dotfiles, browser logins, git identity, Claude/Codex settings and skills. "shard machine" opens its VM; change it there (or in any agent’s VM), then run "shard machine save". It is saved from an agent’s VM, so it always keeps Claude Code, Codex and the agent runtime.',
145
+ template: 'The project template is this repository’s layer: toolchains, dependencies, services and project secrets. Setup after "shard ./" saves it; "shard template save" saves the current agent VM’s project changes. It is saved from an agent’s VM; "shard templates" builds templates for your own app’s workspaces instead.',
146
+ };
142
147
  const KEY_COMMANDS = [
148
+ { path: ['conversations', 'list'], summary: 'List project conversations', positionals: [], options: {
149
+ project: { type: 'string', value: '<id>', description: 'Project ID for a user session.' }, state: { type: 'string', value: '<state>', description: 'Conversation state.' },
150
+ parent: { type: 'string', value: '<id>', description: 'Children of this conversation.' }, limit: { type: 'string', value: '<count>', description: 'Page size.' }, cursor: { type: 'string', value: '<cursor>', description: 'Next page cursor.' }
151
+ } },
152
+ { path: ['conversations', 'resume'], summary: 'Resume a saved conversation in a fresh VM', positionals: [{ name: 'id', required: true }], options: {} },
153
+ { path: ['conversations', 'reader'], summary: 'Open a read-only project space reader', positionals: [{ name: 'project_id', required: true }], options: {} },
154
+ { path: ['conversations', 'get'], summary: 'Show a conversation and its current VM', positionals: [{ name: 'id', required: true }], options: {} },
155
+ { path: ['conversations', 'send'], summary: 'Send the next user turn', positionals: [{ name: 'id', required: true }, { name: 'text', required: true, variadic: true, words: true }], options: {} },
156
+ { path: ['conversations', 'wait'], summary: 'Wait for a result or question', positionals: [{ name: 'id', required: true }], options: {
157
+ until: { type: 'string', value: '<states>', description: 'Comma-separated conversation states.' }, timeout: { type: 'string', value: '<seconds>', description: 'Wait for 0..900 seconds (default 900).' }
158
+ } },
159
+ { path: ['conversations', 'land'], summary: 'Record the landing action performed by the agent', positionals: [{ name: 'id', required: true }, { name: 'action', required: true }], options: { into: { type: 'string', value: '<ref>', description: 'Destination branch or PR URL.' } } },
160
+ { path: ['conversations', 'move'], summary: 'Move at the end of the turn', positionals: [{ name: 'id', required: true }, { name: 'to', required: true }], options: {} },
143
161
  ...['machine', 'template'].flatMap((level) => [
144
- { path: [level], summary: level === 'machine' ? 'Open your machine agent, or show its saved filesystem with --json' : 'Show this project’s template filesystem', positionals: [], options: {} },
145
- { path: [level, 'save'], summary: `Save the ${level} filesystem as a new private version`, positionals: [], options: {
162
+ { path: [level], summary: level === 'machine' ? 'Open your machine agent, or show its saved filesystem with --json' : 'Show this project’s template filesystem', positionals: [], options: {},
163
+ description: LAYER_HELP[level] },
164
+ { path: [level, 'save'], summary: `Save the ${level} filesystem as a new private version`, description: LAYER_HELP[level], positionals: [], options: {
146
165
  wait: { type: 'boolean', description: 'Return when the new version is ready (or failed), printing progress on stderr.' },
147
166
  timeout: { type: 'string', value: '<duration>', description: 'With --wait: give up waiting after this long (default 10m). The save continues server side.' },
148
167
  } },
@@ -958,7 +977,7 @@ const KEY_COMMANDS = [
958
977
  {
959
978
  path: ['templates', 'init'],
960
979
  summary: 'Write a template.yaml that starts from a base template',
961
- description: 'The file is the recipe v2 document: languages, packages, files (local "from" paths are uploaded by "shard templates build"), build steps, and the settings a workspace gets when it opens (env, inputs, start commands, services, defaults). Edit it, then build it.',
980
+ description: 'For your own app’s workspaces (SDK/API). Cloud agents do not use it: their machine and project templates are saved from an agent’s VM with "shard machine save" and "shard template save", and a repository bound to another template is refused (template_not_agent_capable). The file is the recipe v2 document: languages, packages, files (local "from" paths are uploaded by "shard templates build"), build steps, and the settings a workspace gets when it opens (env, inputs, start commands, services, defaults). Edit it, then build it.',
962
981
  positionals: [],
963
982
  options: {
964
983
  base: { type: 'string', value: '<slug[@version]>', description: 'Base template (required), e.g. ubuntu-24.04; without @version, the version open resolves to now.' },
@@ -970,7 +989,7 @@ const KEY_COMMANDS = [
970
989
  {
971
990
  path: ['templates', 'build'],
972
991
  summary: 'Build a template from template.yaml: upload its local files and folders, then build it',
973
- description: 'Folders named by "from" are packed as a reproducible tar and uploaded with files unless the organization already has the same bytes. The version stays unpublished (test it with "shard templates test") unless --publish. Progress goes to stderr.',
992
+ description: 'For your own app’s workspaces (SDK/API). Cloud agents do not use it: their machine and project templates are saved from an agent’s VM with "shard machine save" and "shard template save", and a repository bound to another template is refused (template_not_agent_capable). Folders named by "from" are packed as a reproducible tar and uploaded with files unless the organization already has the same bytes. The version stays unpublished (test it with "shard templates test") unless --publish. Progress goes to stderr.',
974
993
  positionals: [{ name: 'file', required: true }],
975
994
  options: {
976
995
  slug: { type: 'string', value: '<slug>', description: 'Organization template to build into (default: the name of the directory holding the file). Created by the first build.' },
@@ -1080,7 +1099,7 @@ const KEY_COMMANDS = [
1080
1099
  {
1081
1100
  path: ['templates', 'draft', 'open'],
1082
1101
  summary: 'Open the draft of an organization template (a layered workspace to edit live)',
1083
- description: 'One live draft per template. Edit it with "shard ws exec|files ... <draft key>", capture states, open test instances, then publish it as the next version.',
1102
+ description: 'For your own app’s workspaces (SDK/API). Cloud agents do not use it: their machine and project templates are saved from an agent’s VM with "shard machine save" and "shard template save", and a repository bound to another template is refused (template_not_agent_capable). One live draft per template. Edit it with "shard ws exec|files ... <draft key>", capture states, open test instances, then publish it as the next version.',
1084
1103
  positionals: [SLUG],
1085
1104
  options: {
1086
1105
  base: { type: 'string', value: '<slug@version>', description: 'Version to start from (default: the template’s latest published version; required for a new template).' },
@@ -1152,14 +1171,15 @@ const AGENT_OPTIONS = {
1152
1171
  approvals: { type: 'boolean', description: 'Keep the agent’s permission prompts.' },
1153
1172
  };
1154
1173
  export const PROJECT_COMMAND = {
1155
- path: ['<path>'], summary: 'Open the project’s cloud agent command center, or start its agent directly with claude|codex',
1156
- description: 'Naming the app starts (or reattaches) that agent at once; with --json it starts detached and prints the session. Detach with Ctrl-] then d; /exit ends the agent session.',
1157
- positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: AGENT_OPTIONS,
1174
+ path: ['<path>'], summary: 'Start a conversation in its own cloud VM',
1175
+ description: 'Opens Claude Code or Codex directly. --resume continues a project conversation. With --json it starts detached and prints the session. Detach with Ctrl-] then d.',
1176
+ positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: { ...AGENT_OPTIONS, new: { type: 'boolean', description: 'Start a new conversation.' }, resume: { type: 'boolean', description: 'Continue a conversation from this project.' } },
1158
1177
  };
1159
1178
  export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud agent command center', positionals: [], options: {} };
1160
1179
  const AGENT_COMMANDS = [
1180
+ { 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.' } } },
1161
1181
  { path: ['agents', 'targets'], summary: 'Detect apps or configure managed desktop connections', positionals: [{ name: 'agent' }], options: { remove: { type: 'boolean', description: 'Remove this agent’s managed entries, or all entries for this API URL.' } } },
1162
- ...['claude-desktop', 'codex-app', 'web'].map(target => ({ path: ['agents', 'open', target], summary: `Open the agent in ${target}`, positionals: [{ name: 'agent' }], options: { reachable: AGENT_OPTIONS.reachable } })),
1182
+ ...['claude-desktop', 'codex-app', 'web', 't3'].map(target => ({ path: ['agents', 'open', target], summary: `Open the agent in ${target}`, positionals: [{ name: 'agent' }], options: { reachable: AGENT_OPTIONS.reachable } })),
1163
1183
  { path: ['ssh-proxy'], summary: 'Forward SSH standard IO to a managed workspace', positionals: [{ name: 'host', required: true }], options: {} },
1164
1184
  ...['worktree', 'merge', 'keep', 'discard', 'restore'].map((action) => ({
1165
1185
  path: ['agents', action], summary: { worktree: 'Open agent work in a local worktree', merge: 'Merge agent work into the current branch and clean up', keep: 'Keep agent work as a branch and clean up', discard: 'Delete an agent and its local work', restore: 'Restore archived work as a local branch within 30 days' }[action],
@@ -1169,12 +1189,13 @@ const AGENT_COMMANDS = [
1169
1189
  ...(action === 'keep' ? { push: { type: 'boolean', description: 'Push the kept branch to origin and open a PR when gh is installed.' } } : {}),
1170
1190
  },
1171
1191
  })),
1172
- { path: ['agents', 'open'], summary: 'Attach an agent in the terminal', description: 'Detach with Ctrl-] then d; /exit ends the agent session.', positionals: [{ name: 'agent', required: true }], options: { ...AGENT_OPTIONS, app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' }, in: { type: 'string', value: '<terminal>', description: 'Where to open the session (default terminal).' } } },
1192
+ { path: ['agents', 'open'], summary: 'Attach an agent in the terminal', description: 'Detach with Ctrl-] then d; /exit ends the agent session.', positionals: [{ name: 'agent', required: true }], options: { ...AGENT_OPTIONS, app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' }, in: { type: 'string', value: '<terminal|claude-desktop|codex-app|web|t3>', description: 'Where to open the session (default terminal).' } } },
1173
1193
  { path: ['agents', 'new'], summary: 'Start a session or a named agent', positionals: [{ name: 'agent' }], options: {
1174
1194
  ...AGENT_OPTIONS,
1175
1195
  app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' },
1176
1196
  path: { type: 'string', value: '<path>', description: 'Local project path for first launch.' },
1177
1197
  name: { type: 'string', value: '<name>', description: 'New agent name; copies the project main workspace with --like.' },
1198
+ in: { type: 'string', value: '<terminal|claude-desktop|codex-app|t3>', description: 'Where to open the new agent (default terminal).' },
1178
1199
  like: { type: 'string', value: '<id|key>', description: 'Copy the workspace configuration onto a fresh disk.' },
1179
1200
  } },
1180
1201
  { path: ['agents', 'fork'], summary: 'Fork an agent’s memory and disk', positionals: [{ name: 'agent', required: true }, { name: 'name', required: true }], options: WAIT },
@@ -1193,6 +1214,7 @@ const MENUBAR_COMMANDS = ['start', 'stop', 'status'].map(action => ({
1193
1214
  export const COMMANDS = [...ACCOUNT_COMMANDS.filter((c) => c.path[0] !== 'templates'), ...AGENT_COMMANDS, ...AGENT_CREDENTIAL_COMMANDS, ...MENUBAR_COMMANDS, ...KEY_COMMANDS, ...ACCOUNT_COMMANDS.filter((c) => c.path[0] === 'templates')];
1194
1215
  const GROUPS = {
1195
1216
  menubar: 'macOS menu bar app',
1217
+ conversations: 'Project conversations, branches and messages',
1196
1218
  agents: 'Cloud agent sessions and copied credentials',
1197
1219
  auth: 'Create an account and sign in (the session is saved in the credentials file); passwords, email, two-factor authentication and sessions',
1198
1220
  'auth password': 'Change or reset your password',
@@ -1218,6 +1240,8 @@ const GROUPS = {
1218
1240
  templates: 'Build templates from template.yaml, test and export versions, browse templates (file tree, diff), develop them in a draft, and publish or archive versions',
1219
1241
  };
1220
1242
  const ALIASES = {
1243
+ c: 'conversations',
1244
+ conversation: 'conversations',
1221
1245
  ws: 'workspaces',
1222
1246
  workspace: 'workspaces',
1223
1247
  ops: 'operations',
@@ -1361,6 +1385,8 @@ function toParseArgsOptions(opts) {
1361
1385
  * returns help text for --help, bare groups and non-TTY bare `shard`.
1362
1386
  */
1363
1387
  export function parseCommandLine(argv, options = {}) {
1388
+ if (argv.length === 1 && (argv[0] === 'acp' || argv[0] === 'device-helper'))
1389
+ return { kind: 'run', command: { path: [argv[0]], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1364
1390
  const terminator = argv.indexOf('--');
1365
1391
  const head = terminator < 0 ? argv : argv.slice(0, terminator);
1366
1392
  const leading = [];
@@ -1551,8 +1577,9 @@ export const EXIT_CODE_HELP = [
1551
1577
  /** The top help's sections: every command's group (first word) is in exactly one (args.test.ts checks). */
1552
1578
  export const HELP_SECTIONS = [
1553
1579
  { 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'] },
1554
- { title: 'Workspaces (a project API key: SHARDFLUX_API_KEY, else the one shard setup saved)', groups: ['login', 'whoami', 'status', 'usage', 'machine', 'template', 'agents', 'ssh-proxy', 'workspaces', 'files', 'operations', 'executions', 'secrets', 'credentials', 'volumes', 'egress'] },
1555
- { title: 'Templates (publish and archive use the session; the rest the API key)', groups: ['templates'] },
1580
+ { title: 'Cloud agents (shard ./ starts one; a project API key)', groups: ['conversations', 'machine', 'template', 'agents', 'ssh-proxy'] },
1581
+ { 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'] },
1582
+ { title: 'Templates for your app’s workspaces (not for cloud agents; publish and archive use the session, the rest the API key)', groups: ['templates'] },
1556
1583
  { title: 'Other', groups: ['menubar', 'update', 'feedback', 'version'] },
1557
1584
  ];
1558
1585
  export function topHelp() {
@@ -1575,7 +1602,19 @@ export function topHelp() {
1575
1602
  ` shard auth verify-email '<link from the email>'`,
1576
1603
  ` printf '%s\\n' "$PASSWORD" | shard auth login --email you@example.com`,
1577
1604
  ' shard setup # organization, project and an API key, saved',
1578
- ' shard ws open demo --template python-node-browser',
1605
+ ' shard ./ # a cloud agent for this folder',
1606
+ ' shard ws open demo --template python-node-browser # or a workspace for your app (SDK/API)',
1607
+ '',
1608
+ 'Cloud agents (Claude Code or Codex in its own VM, with your setup):',
1609
+ ' shard ./ # a conversation on this folder’s repository: Claude Code in its own VM (shard ./ codex: Codex; --resume: a saved one)',
1610
+ ' shard machine save # what every agent starts with: tools, logins, dotfiles, Claude/Codex config',
1611
+ ' shard template save # this project: toolchains, dependencies, services, project secrets',
1612
+ ' A VM is the coding template, then your machine, then the project template. Setup after shard ./ saves the',
1613
+ ' project template; machine and project templates are always saved from an agent’s VM, so they keep Claude Code,',
1614
+ ' Codex and the agent runtime. "shard templates" is for your own app’s workspaces, not for agents: a repository',
1615
+ ' bound to such a template is refused (template_not_agent_capable).',
1616
+ ' In the VM, Claude Code and Codex have Shardflux computer use next to their own tools: the shardflux-computer MCP',
1617
+ ' server’s computer and computer_batch drive the VM’s desktop (start programs with DISPLAY=:1 setsid -f).',
1579
1618
  ...sections,
1580
1619
  '',
1581
1620
  'Aliases: ws = workspaces, ops = operations, org = orgs, keys = api-keys; "shard workspaces files|operations|executions ..." also works.',
@@ -8,6 +8,9 @@ import { cellConnector, PtyOutputCursor } from "./agent-terminal.js";
8
8
  import { rememberGitAgent } from "./agent-git-commands.js";
9
9
  import { rememberLaunch } from "./agent-live.js";
10
10
  import { agentName as gitAgentName } from "./agent-git.js";
11
+ import { randomUUID } from 'node:crypto';
12
+ import { prepareAgentMachine } from "./agent-machine.js";
13
+ import { OperationFailedError } from '@shardflux/sdk';
11
14
  /** L9 can register target-specific handlers after detecting the installed applications. */
12
15
  export async function availableTargets(ctx) {
13
16
  const { HANDLERS } = await import("./handlers.js");
@@ -19,7 +22,7 @@ export async function availableTargets(ctx) {
19
22
  if (code !== 0)
20
23
  throw new Error('open target detection failed');
21
24
  const result = JSON.parse(output);
22
- return ['terminal', ...result.targets.filter(target => ['claude-desktop', 'codex-app', 'web'].includes(target) && HANDLERS[`agents open ${target}`])];
25
+ return ['terminal', ...result.targets.filter(target => ['claude-desktop', 'codex-app', 'web', 't3'].includes(target) && HANDLERS[`agents open ${target}`])];
23
26
  }
24
27
  export async function agentAction(ctx, action, values, args) {
25
28
  const { HANDLERS } = await import("./handlers.js");
@@ -42,19 +45,39 @@ export const CENTER_HANDLERS = {
42
45
  const source = await resolveWorkspace(ctx, ref);
43
46
  const key = `${source.labels.shard_project || source.key.split('/')[0]}/${name}`;
44
47
  const labels = { ...source.labels, shard_project: source.labels.shard_project || source.key.split('/')[0], agent: name,
45
- agent_pty_name: source.labels.agent_pty_name || agentName(source.key, source.labels) };
48
+ agent_pty_name: source.labels.agent_pty_name || agentName(source.key, source.labels), t3_environment_id: randomUUID() };
46
49
  const caps = workspaceCaps(values);
47
50
  const started = Date.now();
48
51
  const result = await ctx.cloud().workspaces.fork(source.id, { key, labels, ...(caps ? { caps } : {}) }, {
49
52
  agentLabel: ctx.agentLabel, wait: values.wait === false ? false : { timeoutMs: waitTimeout(values), signal: ctx.signal },
53
+ }).catch(async (error) => {
54
+ // Older cells can copy an external agent workspace and then fail their
55
+ // PTY-only repair. Keep the failure, but never leave the copy's T3 identity shared.
56
+ const forkError = error instanceof OperationFailedError ? error.operation.error : undefined;
57
+ const details = forkError?.details;
58
+ if (error instanceof OperationFailedError && error.operation.kind === 'fork' && error.errorCode === 'agent_fork_failed' && details && typeof details === 'object' && !Array.isArray(details) && 'workspace_state' in details && details.workspace_state === 'running' && error.operation.workspace_id) {
59
+ const child = await ctx.cloud().workspaces.get(error.operation.workspace_id);
60
+ if (child.key !== key || child.data.forked_from_workspace_id !== source.id || child.labels?.t3_environment_id !== labels.t3_environment_id)
61
+ throw new Error(`Fork ${key} identity could not be reconciled: copied workspace does not match this fork`, { cause: error });
62
+ await prepareAgentMachine(ctx, child);
63
+ await ctx.cloud().workspaces.setRetention(child.id, { delete_after_idle_days: 7 });
64
+ if (ctx.json && forkError)
65
+ forkError.details = { ...details, workspace_identity_reconciled: true };
66
+ else
67
+ ctx.io.stderr.write(`Copied ${key} has its own T3 identity; fork operation ${error.operationId} still failed.\n`);
68
+ }
69
+ throw error;
50
70
  });
51
71
  const agent = result.operation.result?.agent;
52
72
  const receipt = agent && typeof agent === 'object' ? agent : undefined;
53
- if (result.operation.state === 'succeeded' && (!receipt ||
73
+ const workspaceOnly = result.operation.result?.agent_fork_mode === 'workspace' && !agent;
74
+ if (result.operation.state === 'succeeded' && !workspaceOnly && (!receipt ||
54
75
  ['id', 'workspace_key', 'session_id'].some(field => typeof receipt[field] !== 'string' || !receipt[field]) ||
55
76
  receipt.workspace_key !== key || !['working', 'waiting'].includes(String(receipt.state)))) {
56
77
  throw new Error(`Fork ${key} has no independent agent session receipt`);
57
78
  }
79
+ if (result.operation.state === 'succeeded')
80
+ await prepareAgentMachine(ctx, result.workspace);
58
81
  await ctx.cloud().workspaces.setRetention(result.workspace.id, { delete_after_idle_days: 7 });
59
82
  if (agent)
60
83
  rememberLaunch(ctx, result.workspace.id, started);
@@ -2,6 +2,8 @@ import { json } from "./format.js";
2
2
  import { UsageError } from "./errors.js";
3
3
  import { projectIdentity } from "./project-identity.js";
4
4
  import { AgentStateStore } from "./agent-state.js";
5
+ import { installedTargets } from "./agent-targets.js";
6
+ import { randomUUID } from 'node:crypto';
5
7
  import { agentAction, availableTargets } from "./command-center-actions.js";
6
8
  import { centerRows, loadCenter } from "./command-center-model.js";
7
9
  import { AnsiRenderer, renderCenter } from "./command-center-renderer.js";
@@ -259,11 +261,16 @@ export async function showCenter(ctx, scope, options = {}) {
259
261
  menu('Start here', [app, app === 'claude' ? 'codex' : 'claude'].map(app => ({ label: `Start ${app === 'claude' ? 'Claude' : 'Codex'} here`, run: () => dispatch('new', { path: scope.folder, app }, [], true) })));
260
262
  return;
261
263
  }
262
- if (key === 'enter')
263
- await dispatch('open', { app, project, in: 'terminal' }, [row.key], true);
264
+ if (key === 'enter') {
265
+ const target = new AgentStateStore(ctx.store.dir).target(project, ['terminal', ...installedTargets(ctx.io.env).targets]);
266
+ await dispatch('open', { app, project, in: target }, [row.key], true);
267
+ }
264
268
  else if (key === 'o') {
265
269
  const targets = await availableTargets(ctx);
266
- menu('Open in…', targets.map(target => ({ label: target, run: () => dispatch('open', { app, project, in: target }, [row.key], true) })));
270
+ menu('Open in…', targets.map(target => ({ label: target, run: async () => {
271
+ new AgentStateStore(ctx.store.dir).saveTarget(project, target);
272
+ await dispatch('open', { app, project, in: target }, [row.key], true);
273
+ } })));
267
274
  }
268
275
  else if (key === 'n') {
269
276
  menu('New session', ['claude', 'codex'].map(app => ({ label: `${app === 'claude' ? 'Claude' : 'Codex'} (reattach if running)`, run: () => dispatch('new', { app, project }, [row.key], true) })));
@@ -310,8 +317,12 @@ export async function showCenter(ctx, scope, options = {}) {
310
317
  export const commandCenter = async (ctx, values, [path, app]) => {
311
318
  // `shard <path> claude|codex` names the agent: start (or reattach) it without the picker; --json starts it detached.
312
319
  if (path && (app === 'claude' || app === 'codex'))
313
- return agentAction(ctx, 'new', { ...values, path, app }, []);
320
+ return agentAction(ctx, 'new', { ...values, path, app, ...(values.new === true ? { name: `agent-${randomUUID().slice(0, 8)}` } : {}) }, []);
314
321
  const scope = path ? projectIdentity(path) : undefined;
322
+ if (scope && values.new === true) {
323
+ const target = new AgentStateStore(ctx.store.dir).target(scope.remote ?? scope.folder, ['terminal', ...installedTargets(ctx.io.env).targets]);
324
+ return agentAction(ctx, 'new', { ...values, path: scope.folder, name: `agent-${randomUUID().slice(0, 8)}`, in: target === 'web' ? 'terminal' : target }, []);
325
+ }
315
326
  if (ctx.json) {
316
327
  const data = await loadCenter(ctx);
317
328
  const rows = centerRows(data, scope);
@@ -319,5 +330,10 @@ export const commandCenter = async (ctx, values, [path, app]) => {
319
330
  ctx.io.stdout.write(json({ projects, workspaces: data.workspaces.filter(w => rows.some(r => r.workspace?.id === w.id)), agents: data.agents.filter(a => rows.some(r => r.agent?.id === a.id)) }));
320
331
  return 0;
321
332
  }
333
+ if (scope) {
334
+ const target = new AgentStateStore(ctx.store.dir).target(scope.remote ?? scope.folder, ['terminal', ...installedTargets(ctx.io.env).targets]);
335
+ if (target === 't3')
336
+ return agentAction(ctx, 'new', { ...values, path: scope.folder, in: target }, []);
337
+ }
322
338
  return showCenter(ctx, scope, { approvals: values.approvals === true, ...(app === 'claude' || app === 'codex' ? { app } : {}) });
323
339
  };
@@ -0,0 +1,3 @@
1
+ import type { Ctx, Handler } from './commands.js';
2
+ export declare function conversationClient(ctx: Ctx): import("@shardflux/sdk").Shardflux | import("@shardflux/sdk").ShardfluxAccount;
3
+ export declare const CONVERSATION_HANDLERS: Record<string, Handler>;
@@ -0,0 +1,36 @@
1
+ import { out, str } from "./commands.js";
2
+ import { UsageError } from "./errors.js";
3
+ export function conversationClient(ctx) {
4
+ const key = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || ctx.store.profile(ctx.apiUrl).api_key !== undefined;
5
+ const session = Boolean(ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim()) || ctx.store.profile(ctx.apiUrl).session !== undefined;
6
+ return key || !session ? ctx.cloud() : ctx.account();
7
+ }
8
+ export const CONVERSATION_HANDLERS = {
9
+ async 'conversations resume'(ctx, _values, [id]) { const r = await conversationClient(ctx).conversations.resume(id, { signal: ctx.signal }); out(ctx, `${r.conversation.title}: resumed.\n`, r); return 0; },
10
+ async 'conversations reader'(ctx, _values, [id]) { const r = await conversationClient(ctx).conversations.spaceReader(id, { signal: ctx.signal }); out(ctx, `${r.workspace.workspace_key}\n`, r); return 0; },
11
+ async 'conversations list'(ctx, values) {
12
+ const state = str(values.state);
13
+ const page = await conversationClient(ctx).conversations.list({ projectId: str(values.project), state, parentId: str(values.parent), cursor: str(values.cursor), limit: values.limit ? Number(values.limit) : undefined });
14
+ out(ctx, page.data.map(c => `${c.id}\t${c.title}\t${c.state}\t${c.branch}`).join('\n') + '\n', page);
15
+ return 0;
16
+ },
17
+ async 'conversations get'(ctx, _values, [id]) { const c = await conversationClient(ctx).conversations.get(id); out(ctx, `${c.title}\n${c.state}\n${c.branch}\n`, c); return 0; },
18
+ 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; },
19
+ async 'conversations wait'(ctx, values, [id]) {
20
+ const seconds = values.timeout === undefined ? 900 : Number(values.timeout);
21
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > 900)
22
+ throw new UsageError('--timeout must be 0..900 seconds.');
23
+ const until = str(values.until)?.split(',');
24
+ const r = await conversationClient(ctx).conversations.wait(id, { until, timeoutSeconds: seconds, signal: ctx.signal });
25
+ out(ctx, 'timeout' in r ? 'Still working.\n' : `${r.conversation.title}: ${r.event.kind}\n${r.event.question ?? r.event.result?.summary ?? ''}\n`, r);
26
+ return 0;
27
+ },
28
+ async 'conversations land'(ctx, values, [id, action]) {
29
+ if (!['merge', 'pr', 'keep', 'discard'].includes(action))
30
+ throw new UsageError('Choose merge, pr, keep or discard.');
31
+ const r = await conversationClient(ctx).conversations.land(id, action, { into: str(values.into), signal: ctx.signal });
32
+ out(ctx, `${r.title}: ${r.landed.how}\n`, r);
33
+ return 0;
34
+ },
35
+ async 'conversations move'(ctx, _values, [id, to]) { const r = await conversationClient(ctx).conversations.move(id, to, { signal: ctx.signal }); out(ctx, `Move scheduled for the end of the turn.\n`, r); return 0; },
36
+ };
@@ -0,0 +1,8 @@
1
+ import type { Ctx } from './commands.js';
2
+ import type { Conversation } from './conversations.js';
3
+ /** Native local continuation uses a worktree. It never checks out the saved branch in the user's main tree. */
4
+ export declare function prepareLocalResume(ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'signal'>, c: Conversation, folder: string): Promise<{
5
+ folder: string;
6
+ argv: string[];
7
+ }>;
8
+ export declare function localResumeTerminal(ctx: Ctx, c: Conversation, folder: string): Promise<number>;
@@ -0,0 +1,56 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { conversationName } from "./conversations.js";
5
+ import { gitRunner } from "./agent-git.js";
6
+ import { transcriptDestination, rewriteTranscript } from "./conversation-stash.js";
7
+ import { writePrivateFile } from "./credentials.js";
8
+ import { UsageError } from "./errors.js";
9
+ import { checkConversationWorktree, rememberConversationWorktree } from "./conversation-worktree.js";
10
+ import { withTargetLock } from "./target-lock.js";
11
+ /** Native local continuation uses a worktree. It never checks out the saved branch in the user's main tree. */
12
+ export async function prepareLocalResume(ctx, c, folder) {
13
+ return withTargetLock(join(ctx.store.dir, 'conversation-worktree-lock'), async () => {
14
+ if (!c.harness_session_id || !['done', 'archived'].includes(c.state))
15
+ throw new UsageError('This conversation has no ended native session.');
16
+ const run = gitRunner(folder, { ...process.env, ...ctx.io.env }, ctx.signal), name = conversationName(c);
17
+ const root = join(folder, '.shard', 'worktrees', name);
18
+ // This ref was fetched before the handoff receipt. Missing work is an error, never an empty branch.
19
+ await run(['rev-parse', '--verify', `refs/remotes/shardflux/${name}^{commit}`]);
20
+ if (!existsSync(root)) {
21
+ mkdirSync(dirname(root), { recursive: true });
22
+ const exists = await run(['show-ref', '--verify', `refs/heads/${c.branch}`]).then(() => true, () => false);
23
+ await run(['-c', 'core.hooksPath=/dev/null', 'worktree', 'add', ...(exists ? [] : ['-b', c.branch]), root, exists ? c.branch : `refs/remotes/shardflux/${name}`]);
24
+ rememberConversationWorktree(ctx, c, folder, root);
25
+ }
26
+ else
27
+ checkConversationWorktree(ctx, c, folder, root);
28
+ const work = gitRunner(root, { ...process.env, ...ctx.io.env }, ctx.signal);
29
+ if (await work(['symbolic-ref', '--short', 'HEAD']) !== c.branch)
30
+ throw new UsageError('The local worktree belongs to another branch.');
31
+ if (c.harness === 'claude') {
32
+ const filename = `${c.harness_session_id}.jsonl`;
33
+ const source = transcriptDestination(c, folder, filename, ctx.io.env), dest = transcriptDestination(c, root, filename, ctx.io.env);
34
+ if (!existsSync(source))
35
+ throw new UsageError('Native transcript handoff is missing.');
36
+ if (!existsSync(dest))
37
+ writePrivateFile(dirname(dest), dest, Buffer.from(rewriteTranscript(readFileSync(source), folder, root)).toString('utf8'));
38
+ return { folder: root, argv: ['claude', '--resume', c.harness_session_id] };
39
+ }
40
+ return { folder: root, argv: ['codex', 'resume', c.harness_session_id, '--all', '-C', root] };
41
+ }, ctx.signal);
42
+ }
43
+ export async function localResumeTerminal(ctx, c, folder) {
44
+ if (!ctx.io.stdin.isTTY || ctx.json)
45
+ throw new UsageError('Native local resume requires a terminal.');
46
+ const launch = await prepareLocalResume(ctx, c, folder);
47
+ const child = spawn(launch.argv[0], launch.argv.slice(1), { cwd: launch.folder, env: { ...process.env, ...ctx.io.env }, stdio: 'inherit' });
48
+ return new Promise((resolve, reject) => {
49
+ const abort = () => child.kill('SIGTERM');
50
+ ctx.signal.addEventListener('abort', abort, { once: true });
51
+ child.once('error', e => { ctx.signal.removeEventListener('abort', abort); reject(e); });
52
+ child.once('exit', code => { ctx.signal.removeEventListener('abort', abort); resolve(code ?? 130); });
53
+ if (ctx.signal.aborted)
54
+ abort();
55
+ });
56
+ }
@@ -0,0 +1,7 @@
1
+ import type { Ctx, Handler } from './commands.js';
2
+ import type { Conversation } from './conversations.js';
3
+ export declare function tryConversationLocally(ctx: Ctx, c: Conversation, folder: string): Promise<{
4
+ folder: string;
5
+ stop(): Promise<void>;
6
+ }>;
7
+ export declare const CONVERSATION_HANDLERS: Record<string, Handler>;
@@ -0,0 +1,61 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { getConversation, resolveConversation, conversationWorkspace, localConversations } from "./conversations.js";
3
+ import { ensureConversationWorktree, devicePort } from "./device-bridge.js";
4
+ import { startProjectSync } from "./project-sync.js";
5
+ import { projectIdentity } from "./project-identity.js";
6
+ import { UsageError } from "./errors.js";
7
+ import { out } from "./commands.js";
8
+ import { join } from 'node:path';
9
+ import { writePrivateFile } from "./credentials.js";
10
+ export async function tryConversationLocally(ctx, c, folder) {
11
+ const ws = await conversationWorkspace(ctx, c), worktree = await ensureConversationWorktree(ctx, c, folder, ws);
12
+ const project = { ...projectIdentity(folder), folder: worktree };
13
+ const sync = await startProjectSync(ctx, ws, project, { root: c.worktree_path, agent: c.key.split('/').at(-1), seed: false });
14
+ const ports = [];
15
+ try {
16
+ for (const port of c.ports ?? [])
17
+ ports.push(await devicePort(ws, ws.cell(), 'port_forward', { remote_port: port, local_port: port }, ctx.signal));
18
+ return { folder: worktree, async stop() { try {
19
+ await sync.stop();
20
+ }
21
+ finally {
22
+ await Promise.all(ports.map(p => p.stop()));
23
+ ws.cell().close();
24
+ } } };
25
+ }
26
+ catch (e) {
27
+ await sync.stop();
28
+ await Promise.all(ports.map(p => p.stop()));
29
+ throw e;
30
+ }
31
+ }
32
+ export const CONVERSATION_HANDLERS = {
33
+ async 'conversations try'(ctx, values, [ref]) {
34
+ if (!ref)
35
+ throw new UsageError('Choose a conversation.');
36
+ const c = await resolveConversation(ctx, ref), record = localConversations(ctx)[c.id];
37
+ if (values.stop === true) {
38
+ const dir = join(ctx.store.dir, 'device-stops');
39
+ writePrivateFile(dir, join(dir, `${c.id}.json`), JSON.stringify({ api_url: ctx.apiUrl }) + '\n');
40
+ out(ctx, `Stopping local sync for ${c.key}.\n`, { conversation_id: c.id, stopping: true });
41
+ return 0;
42
+ }
43
+ const folder = typeof values.path === 'string' ? values.path : record?.folder;
44
+ if (!folder)
45
+ throw new UsageError('Use --path to name the local project checkout.');
46
+ const handle = await tryConversationLocally(ctx, c, folder);
47
+ out(ctx, `Trying ${c.key} in ${handle.folder}. Press Ctrl-C to stop sync.\n`, { conversation: c, folder: handle.folder });
48
+ try {
49
+ while (!ctx.signal.aborted) {
50
+ const next = await getConversation(ctx, c.id);
51
+ if (next.state === 'done' || next.state === 'archived' || next.workspace_id !== c.workspace_id)
52
+ break;
53
+ await delay(1000, undefined, { signal: ctx.signal }).catch(() => undefined);
54
+ }
55
+ }
56
+ finally {
57
+ await handle.stop();
58
+ }
59
+ return 0;
60
+ },
61
+ };
@@ -0,0 +1,12 @@
1
+ import type { Workspace } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ export declare const ROUTER_CONFIG = "/home/user/.config/shardflux/conversation-router.json";
4
+ export declare function routerConfiguration(ctx: Pick<Ctx, 'apiUrl'>, ws: Workspace, commit: string, cwd?: string): {
5
+ api_url: string;
6
+ project_id: string;
7
+ repo_remote: string;
8
+ cwd: string;
9
+ base_commit: string;
10
+ };
11
+ /** CVE reads this config on every request; old guests keep their existing SSH path. */
12
+ export declare function prepareDesktopRouter(ctx: Ctx, source: Workspace): Promise<Workspace>;
@@ -0,0 +1,92 @@
1
+ import { randomUUID } from 'node:crypto';
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";
5
+ import { UsageError } from "./errors.js";
6
+ import { conversationClient } from "./conversation-commands.js";
7
+ export const ROUTER_CONFIG = '/home/user/.config/shardflux/conversation-router.json';
8
+ export function routerConfiguration(ctx, ws, commit, cwd = workspaceRepositoryPath(ws)) {
9
+ const remote = ws.labels.repo_remote || ws.labels.shard_repo_remote;
10
+ if (!ws.data.project_id || !remote || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(commit) || !cwd.startsWith('/home/user/'))
11
+ throw new UsageError('Project routing requires a project, repository and its actual commit.');
12
+ return { api_url: ctx.apiUrl, project_id: ws.data.project_id, repo_remote: remote, cwd, base_commit: commit };
13
+ }
14
+ /** CVE reads this config on every request; old guests keep their existing SSH path. */
15
+ export async function prepareDesktopRouter(ctx, source) {
16
+ const cloud = conversationClient(ctx);
17
+ try {
18
+ await cloud.conversations.list({ projectId: source.data.project_id, limit: 1 });
19
+ }
20
+ catch (error) {
21
+ if (error instanceof ShardfluxApiError && (error.status === 404 || error.status === 503 && error.details?.reason === 'conversations_unavailable'))
22
+ return source;
23
+ throw error;
24
+ }
25
+ const cell = source.cell();
26
+ const probe = await cell.exec.run(['sf-agent', 'app-router-info'], { timeoutMs: 10_000, maxOutputBytes: 8192, signal: ctx.signal });
27
+ if (probe.exitCode !== 0)
28
+ return source;
29
+ let info;
30
+ try {
31
+ info = JSON.parse(probe.stdout);
32
+ }
33
+ catch {
34
+ return source;
35
+ }
36
+ if (info.version !== 1)
37
+ return source;
38
+ const mounted = await cell.exec.run(['test', '-d', '/shardflux/project/git/hub.git'], { timeoutMs: 10_000, signal: ctx.signal });
39
+ if (mounted.exitCode !== 0)
40
+ return source;
41
+ const head = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), 'rev-parse', 'HEAD'], { timeoutMs: 10_000, signal: ctx.signal });
42
+ if (head.exitCode !== 0)
43
+ throw new UsageError('The project repository has no commit to route from.');
44
+ const commit = head.stdout.trim(), config = routerConfiguration(ctx, source, commit);
45
+ const project = source.key.split('/')[0], key = `${project}/router`;
46
+ let router = await cloud.workspaces.findByKey(key, { includeDeleted: false, signal: ctx.signal });
47
+ if (router && (router.key !== key || router.data.project_id !== config.project_id || router.labels.shard_router !== 'project' || router.labels.repo_remote !== config.repo_remote))
48
+ throw new UsageError('The project router key belongs to another workspace.');
49
+ const created = !router;
50
+ const labels = { ...source.labels, shard_project: project, shard_router: 'project', agent: 'router', shard_agent: 'router', repo_remote: config.repo_remote, repo_path: projectWorkspacePath({ slug: project }) };
51
+ delete labels.shard_conversation;
52
+ delete labels.shard_branch;
53
+ if (!router)
54
+ router = await cloud.workspaces.open({ key, like: source.id, labels, wait: { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal } });
55
+ if (!router.ready)
56
+ await router.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
57
+ const destination = router.cell(), root = workspaceRepositoryPath(router);
58
+ const archive = `/tmp/shard-router-${randomUUID()}.bundle`;
59
+ try {
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 });
62
+ if (packed.exitCode !== 0)
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);
71
+ 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 });
73
+ if (imported.exitCode !== 0)
74
+ throw new Error(`Project router repository could not be prepared: ${imported.stderr.trim()}`);
75
+ }
76
+ const seed = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), '-c', 'core.hooksPath=/dev/null', 'push', '/shardflux/project/git/hub.git', `${commit}:refs/shard/router/seeds/${commit}`], { timeoutMs: 120_000, signal: ctx.signal });
77
+ if (seed.exitCode !== 0)
78
+ throw new Error(`Project router hub seed failed: ${seed.stderr.trim()}`);
79
+ await destination.files.mkdir('/home/user/.config/shardflux', { parents: true, mode: '0700' });
80
+ await destination.files.write(ROUTER_CONFIG, JSON.stringify({ ...config, cwd: root }) + '\n', { mode: '0600' });
81
+ return router;
82
+ }
83
+ catch (e) {
84
+ if (created)
85
+ await cloud.workspaces.delete(router.id).catch(() => undefined);
86
+ throw e;
87
+ }
88
+ finally {
89
+ await cell.files.remove(archive).catch(() => undefined);
90
+ await destination.files.remove(archive).catch(() => undefined);
91
+ }
92
+ }
@@ -0,0 +1,11 @@
1
+ import type { Workspace } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ import type { Conversation, LocalConversation } from './conversations.js';
4
+ export declare function rewriteTranscript(bytes: Uint8Array, remoteCwd: string, localCwd: string): Uint8Array;
5
+ export declare function transcriptDestination(c: Conversation, folder: string, filename: string, env: Record<string, string | undefined>): string;
6
+ /** Keep growing copies away from native session discovery until the control plane ends the conversation. */
7
+ export declare function mirrorConversation(ctx: Ctx, record: LocalConversation, source: Workspace): Promise<void>;
8
+ export declare function handoffConversation(ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'signal'>, c: Conversation, folder: string, dir: string, source?: Pick<Workspace, 'key'> | {
9
+ hub: string;
10
+ }): Promise<void>;
11
+ export declare function mirrorProjectConversations(ctx: Ctx, mirrored?: Map<string, string>): Promise<void>;