@shardflux/cli 0.13.5 → 0.14.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 (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +40 -4
  3. package/dist/acp-catalog.d.ts +152 -0
  4. package/dist/acp-catalog.js +287 -0
  5. package/dist/acp-journal.d.ts +18 -0
  6. package/dist/acp-journal.js +232 -0
  7. package/dist/acp-rpc.d.ts +41 -0
  8. package/dist/acp-rpc.js +115 -0
  9. package/dist/acp.d.ts +27 -0
  10. package/dist/acp.js +290 -0
  11. package/dist/agent-commands.d.ts +10 -2
  12. package/dist/agent-commands.js +37 -18
  13. package/dist/agent-machine.d.ts +6 -0
  14. package/dist/agent-machine.js +93 -0
  15. package/dist/agent-repository.js +1 -1
  16. package/dist/agent-state.d.ts +3 -0
  17. package/dist/agent-state.js +33 -12
  18. package/dist/agent-target-commands.d.ts +4 -1
  19. package/dist/agent-target-commands.js +64 -22
  20. package/dist/agent-targets.d.ts +6 -4
  21. package/dist/agent-targets.js +72 -31
  22. package/dist/args.d.ts +1 -1
  23. package/dist/args.js +53 -14
  24. package/dist/command-center-actions.js +26 -3
  25. package/dist/command-center.js +20 -4
  26. package/dist/conversation-commands.d.ts +3 -0
  27. package/dist/conversation-commands.js +36 -0
  28. package/dist/conversation-local-resume.d.ts +8 -0
  29. package/dist/conversation-local-resume.js +56 -0
  30. package/dist/conversation-local.d.ts +7 -0
  31. package/dist/conversation-local.js +61 -0
  32. package/dist/conversation-router.d.ts +12 -0
  33. package/dist/conversation-router.js +89 -0
  34. package/dist/conversation-stash.d.ts +11 -0
  35. package/dist/conversation-stash.js +158 -0
  36. package/dist/conversation-terminal.d.ts +3 -0
  37. package/dist/conversation-terminal.js +74 -0
  38. package/dist/conversation-worktree.d.ts +4 -0
  39. package/dist/conversation-worktree.js +22 -0
  40. package/dist/conversations.d.ts +53 -0
  41. package/dist/conversations.js +118 -0
  42. package/dist/device-bridge.d.ts +80 -0
  43. package/dist/device-bridge.js +398 -0
  44. package/dist/device-helper.d.ts +3 -0
  45. package/dist/device-helper.js +104 -0
  46. package/dist/errors.js +5 -0
  47. package/dist/format.js +3 -2
  48. package/dist/handlers.js +3 -1
  49. package/dist/main.js +15 -3
  50. package/dist/menubar.d.ts +5 -0
  51. package/dist/menubar.js +22 -1
  52. package/dist/project-sync.d.ts +5 -1
  53. package/dist/project-sync.js +7 -6
  54. package/dist/t3-provider.d.ts +6 -0
  55. package/dist/t3-provider.js +99 -0
  56. package/dist/t3-runtime.d.ts +6 -0
  57. package/dist/t3-runtime.js +63 -0
  58. package/dist/target-lock.d.ts +3 -0
  59. package/dist/target-lock.js +143 -0
  60. package/package.json +3 -3
@@ -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,89 @@
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
+ await destination.files.mkdir('/home/user/.config/shardflux', { parents: true, mode: '0700' });
77
+ await destination.files.write(ROUTER_CONFIG, JSON.stringify({ ...config, cwd: root }) + '\n', { mode: '0600' });
78
+ return router;
79
+ }
80
+ catch (e) {
81
+ if (created)
82
+ await cloud.workspaces.delete(router.id).catch(() => undefined);
83
+ throw e;
84
+ }
85
+ finally {
86
+ await cell.files.remove(archive).catch(() => undefined);
87
+ await destination.files.remove(archive).catch(() => undefined);
88
+ }
89
+ }
@@ -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>;
@@ -0,0 +1,158 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { conversationName, listConversations, localConversations, rememberConversation } from "./conversations.js";
6
+ import { gitRunner } from "./agent-git.js";
7
+ import { shardRemote } from "./git-remote.js";
8
+ import { writePrivateFile } from "./credentials.js";
9
+ import { conversationClient } from "./conversation-commands.js";
10
+ const MAX = 256 << 20;
11
+ export function rewriteTranscript(bytes, remoteCwd, localCwd) {
12
+ const text = new TextDecoder('utf8', { fatal: true }).decode(bytes);
13
+ const rewrite = (value) => {
14
+ if (Array.isArray(value))
15
+ return value.map(rewrite);
16
+ if (!value || typeof value !== 'object')
17
+ return value;
18
+ return Object.fromEntries(Object.entries(value).map(([key, leaf]) => {
19
+ if (typeof leaf === 'string' && ['cwd', 'workdir', 'working_directory'].includes(key) && (leaf === remoteCwd || leaf.startsWith(remoteCwd + '/')))
20
+ return [key, localCwd + leaf.slice(remoteCwd.length)];
21
+ return [key, rewrite(leaf)];
22
+ }));
23
+ };
24
+ if (!text.endsWith('\n'))
25
+ throw new Error('Transcript has an incomplete final record; preserved in stash.');
26
+ return Buffer.from(text.split('\n').filter(Boolean).map(line => JSON.stringify(rewrite(JSON.parse(line)))).join('\n') + '\n');
27
+ }
28
+ export function transcriptDestination(c, folder, filename, env) {
29
+ if (!/^[a-zA-Z0-9_.-]+\.jsonl$/.test(filename))
30
+ throw new Error('Transcript filename is not a native JSONL file.');
31
+ const home = env.HOME || homedir();
32
+ if (c.harness === 'claude')
33
+ return join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects', resolve(folder).replace(/[^a-zA-Z0-9]/g, '-'), filename);
34
+ const match = /^rollout-(\d{4})-(\d{2})-(\d{2})T/.exec(filename);
35
+ if (!match)
36
+ throw new Error('Codex transcript has no native rollout date.');
37
+ return join(env.CODEX_HOME || join(home, '.codex'), 'sessions', match[1], match[2], match[3], filename);
38
+ }
39
+ function privateDirectory(path) {
40
+ let at = path;
41
+ while (!existsSync(at)) {
42
+ const parent = dirname(at);
43
+ if (parent === at)
44
+ break;
45
+ at = parent;
46
+ }
47
+ if (existsSync(at) && lstatSync(at).isSymbolicLink())
48
+ throw new Error('Transcript directory must not be a symlink.');
49
+ mkdirSync(path, { recursive: true, mode: 0o700 });
50
+ if (lstatSync(path).isSymbolicLink())
51
+ throw new Error('Transcript directory must not be a symlink.');
52
+ }
53
+ /** Keep growing copies away from native session discovery until the control plane ends the conversation. */
54
+ export async function mirrorConversation(ctx, record, source) {
55
+ const c = record.conversation;
56
+ const dir = join(ctx.store.dir, 'conversations', c.project_id, c.id), root = `/shardflux/project/transcripts/${c.id}`;
57
+ privateDirectory(dir);
58
+ const cell = source.cell({ wake: null });
59
+ try {
60
+ const manifest = await cell.files.manifest(root, { maxFileBytes: MAX, signal: ctx.signal });
61
+ if (manifest.skipped.length)
62
+ throw new Error('Project transcript copy includes files outside the mirror bound.');
63
+ let count = 0;
64
+ for (const entry of manifest.entries) {
65
+ if (entry.symlink || entry.path.includes('/') || !/^[a-zA-Z0-9_.-]+\.(jsonl|json)$/.test(entry.path))
66
+ throw new Error('Project transcript copy has an unsafe entry.');
67
+ const file = join(dir, entry.path), expected = existsSync(file) ? createHash('sha256').update(readFileSync(file)).digest('hex') : '';
68
+ if (expected !== entry.sha256) {
69
+ const bytes = await cell.files.read(`${root}/${entry.path}`, { length: Math.min(MAX + 1, entry.size + 1) });
70
+ if (bytes.length !== entry.size || createHash('sha256').update(bytes).digest('hex') !== entry.sha256)
71
+ throw new Error('Transcript changed during copying; retaining the previous stash.');
72
+ writePrivateFile(dir, file, new TextDecoder('utf8', { fatal: true }).decode(bytes));
73
+ }
74
+ if (entry.path.endsWith('.jsonl'))
75
+ count++;
76
+ }
77
+ if (count === 0)
78
+ throw new Error('Project space has no transcript bytes to mirror.');
79
+ if ((c.state === 'done' || c.state === 'archived') && !record.handed_off) {
80
+ const binding = await conversationClient(ctx).workspaces.get(c.workspace_id);
81
+ if (!binding.data.deleted_at)
82
+ return;
83
+ const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
84
+ if (!c.harness_session_id || meta.harness !== c.harness || meta.harness_session_id !== c.harness_session_id || !/^[a-zA-Z0-9_.-]+\.jsonl$/.test(meta.file))
85
+ throw new Error('Transcript handoff metadata does not match the ended conversation.');
86
+ const bytes = readFileSync(join(dir, meta.file));
87
+ if (bytes.length !== meta.bytes || createHash('sha256').update(bytes).digest('hex') !== meta.sha256)
88
+ throw new Error('Transcript handoff checkpoint differs from its durable metadata.');
89
+ await handoffConversation(ctx, { ...c, worktree_path: meta.cwd }, record.folder, dir, source);
90
+ }
91
+ }
92
+ finally {
93
+ cell.close();
94
+ }
95
+ }
96
+ export async function handoffConversation(ctx, c, folder, dir, source) {
97
+ if (c.state !== 'done' && c.state !== 'archived')
98
+ throw new Error('Only an ended conversation can enter native history.');
99
+ const files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
100
+ if (!files.length)
101
+ throw new Error('Conversation has no transcript to hand off.');
102
+ const writes = files.map(name => ({ dest: transcriptDestination(c, folder, name, ctx.io.env), bytes: rewriteTranscript(readFileSync(join(dir, name)), c.worktree_path, folder) }));
103
+ // Check every destination before the first write. Preserve local native history on any collision.
104
+ for (const { dest, bytes } of writes) {
105
+ if (existsSync(dest) && (lstatSync(dest).isSymbolicLink() || !readFileSync(dest).equals(Buffer.from(bytes))))
106
+ throw new Error(`Native transcript already exists with different bytes: ${dest}`);
107
+ }
108
+ if (!source)
109
+ throw new Error('Project hub is required before transcript handoff.');
110
+ const name = conversationName(c);
111
+ const run = gitRunner(folder, { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl }, ctx.signal);
112
+ const hub = 'hub' in source ? source.hub : shardRemote(source.key, '/shardflux/project/git/hub.git');
113
+ 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}`]);
114
+ for (const { dest, bytes } of writes) {
115
+ privateDirectory(dirname(dest));
116
+ if (!existsSync(dest))
117
+ writePrivateFile(dirname(dest), dest, new TextDecoder('utf8', { fatal: true }).decode(bytes));
118
+ }
119
+ rememberConversation(ctx, c, folder, true);
120
+ writePrivateFile(dir, join(dir, 'handoff.json'), JSON.stringify({ conversation_id: c.id, branch: c.branch, destinations: writes.map(w => w.dest), at: new Date().toISOString() }) + '\n');
121
+ }
122
+ export async function mirrorProjectConversations(ctx, mirrored = new Map()) {
123
+ const records = localConversations(ctx);
124
+ if (!Object.keys(records).length)
125
+ return;
126
+ const all = await listConversations(ctx);
127
+ for (const record of Object.values(records)) {
128
+ const c = all.find(c => c.id === record.conversation.id);
129
+ if (!c || record.handed_off || !c.harness_session_id)
130
+ continue;
131
+ const revision = JSON.stringify([c.workspace_id, c.harness_session_id, c.state, c.durable, c.last_activity_at, c.summary]);
132
+ if (mirrored.get(c.id) === revision)
133
+ continue;
134
+ rememberConversation(ctx, c, record.folder);
135
+ let source;
136
+ for (const candidate of [c, ...all.filter(other => other.project_id === c.project_id && other.id !== c.id)]) {
137
+ try {
138
+ const ws = await conversationClient(ctx).workspaces.get(candidate.workspace_id);
139
+ if (!ws.data.deleted_at && ws.ready) {
140
+ source = ws;
141
+ break;
142
+ }
143
+ }
144
+ catch { /* Another binding may be available. */ }
145
+ }
146
+ if (!source) {
147
+ const client = conversationClient(ctx);
148
+ const reader = await client.conversations.spaceReader(c.project_id, { signal: ctx.signal });
149
+ source = await client.workspaces.get(reader.workspace.id);
150
+ if (!source.ready)
151
+ await source.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
152
+ }
153
+ await mirrorConversation(ctx, { ...record, conversation: c }, source);
154
+ // An ended record may precede its VM tombstone; retry until the handoff receipt is written.
155
+ if (c.state !== 'done' && c.state !== 'archived')
156
+ mirrored.set(c.id, revision);
157
+ }
158
+ }
@@ -0,0 +1,3 @@
1
+ import type { Handler } from './commands.js';
2
+ /** Select a durable conversation, then use the harness's native terminal for all interaction. */
3
+ export declare const launchConversationProject: Handler;