@shardflux/cli 0.15.1 → 0.16.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 (47) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +50 -3
  3. package/dist/acp-models.js +5 -1
  4. package/dist/agent-commands.d.ts +8 -2
  5. package/dist/agent-commands.js +23 -11
  6. package/dist/agent-target-commands.d.ts +7 -2
  7. package/dist/agent-target-commands.js +12 -7
  8. package/dist/agent-targets.d.ts +3 -0
  9. package/dist/agent-targets.js +4 -1
  10. package/dist/agent-terminal.d.ts +8 -0
  11. package/dist/agent-terminal.js +35 -2
  12. package/dist/args.d.ts +1 -1
  13. package/dist/args.js +29 -3
  14. package/dist/codex-cloud.d.ts +2 -0
  15. package/dist/codex-cloud.js +2 -0
  16. package/dist/command-center-actions.js +9 -0
  17. package/dist/commands.d.ts +23 -1
  18. package/dist/commands.js +54 -14
  19. package/dist/conversation-commands.js +6 -0
  20. package/dist/conversation-terminal.js +10 -1
  21. package/dist/conversations.d.ts +13 -1
  22. package/dist/conversations.js +41 -5
  23. package/dist/device-helper.js +9 -2
  24. package/dist/errors.d.ts +5 -0
  25. package/dist/errors.js +11 -1
  26. package/dist/files-mirror.d.ts +164 -0
  27. package/dist/files-mirror.js +761 -0
  28. package/dist/handlers.js +3 -1
  29. package/dist/main.js +39 -14
  30. package/dist/menubar.d.ts +57 -0
  31. package/dist/menubar.js +264 -45
  32. package/dist/registry.d.ts +8 -0
  33. package/dist/registry.js +18 -0
  34. package/dist/thread-on-computer.d.ts +35 -0
  35. package/dist/thread-on-computer.js +200 -0
  36. package/dist/update-check.d.ts +9 -1
  37. package/dist/update-check.js +55 -17
  38. package/dist/update.d.ts +2 -0
  39. package/dist/update.js +26 -8
  40. package/dist/wait-status.d.ts +22 -0
  41. package/dist/wait-status.js +59 -0
  42. package/dist/worktree-twin-engine.d.ts +95 -0
  43. package/dist/worktree-twin-engine.js +402 -0
  44. package/dist/worktree-twin-land.py +331 -0
  45. package/dist/worktree-twin.d.ts +296 -0
  46. package/dist/worktree-twin.js +718 -0
  47. 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.15.1';
15
+ export const CLI_VERSION = '0.16.0';
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).' },
@@ -57,7 +57,7 @@ const IF_REVISION = {
57
57
  };
58
58
  const WAIT = {
59
59
  wait: { type: 'boolean', description: 'Wait until the operation finishes.' },
60
- timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (e.g. 90s, 5m; default 5m). The operation continues server side (a start waiting for capacity until its deadline, 15 minutes after it began; then it fails with capacity_unavailable).' },
60
+ timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (e.g. 90s, 5m; default 5m, and a start queued for capacity is waited for until its deadline, its place in the queue shown on stderr). The operation continues server side (a start waiting for capacity until its deadline, 15 minutes after it began; then it fails with capacity_unavailable).' },
61
61
  ...TIMING,
62
62
  };
63
63
  const REF = { name: 'id|key', required: true };
@@ -158,6 +158,11 @@ const KEY_COMMANDS = [
158
158
  } },
159
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
160
  { path: ['conversations', 'move'], summary: 'Move at the end of the turn', positionals: [{ name: 'id', required: true }, { name: 'to', required: true }], options: {} },
161
+ { path: ['conversations', 'landing'], summary: 'Show or set how finished conversations reach this checkout', positionals: [{ name: 'mode' }, { name: 'backup' }], options: {
162
+ path: { type: 'string', value: '<path>', description: 'Local project checkout (default the current directory).' }
163
+ },
164
+ description: 'While the macOS app runs, a conversation whose turn ends with committed work lands in the local branch it started from: the branch moves to its commits and the changed files are written, keeping your uncommitted edits (auto, the default). pr opens a pull request instead; off leaves conversations in the cloud. Every file a landing rewrites is backed up first: backups lists them, restore <backup> puts the files back.',
165
+ examples: ['shard conversations landing', 'shard conversations landing pr', 'shard conversations landing backups', 'shard conversations landing restore 2026-10-09T12-00-00-000Z-fix-login-3fa2c1'] },
161
166
  ...['machine', 'template'].flatMap((level) => [
162
167
  { 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
168
  description: LAYER_HELP[level] },
@@ -851,6 +856,14 @@ const KEY_COMMANDS = [
851
856
  'shard files patch acme/agent-7 /home/user/app.py --old "DEBUG = True" --new "DEBUG = False" --if-revision "$(shard ws get acme/agent-7 --json | jq .tree_revision)"',
852
857
  ],
853
858
  },
859
+ {
860
+ path: ['files', 'pull'],
861
+ summary: 'Download a large file a conversation committed into ~/Shardflux',
862
+ description: 'Each conversation’s committed changes appear in ~/Shardflux/<repository>/<base branch>/<conversation>/ while the macOS app runs. Files above the size limit are listed there as <file>.placeholder.txt; this downloads one in its place. <conversation> is its name, id or folder name.',
863
+ positionals: [{ name: 'conversation', required: true }, { name: 'path', required: true }],
864
+ options: {},
865
+ examples: ['shard files pull hype-video out/hype.mp4'],
866
+ },
854
867
  {
855
868
  path: ['operations', 'list'],
856
869
  summary: 'List a workspace’s operations, newest first',
@@ -1175,11 +1188,17 @@ const AGENT_OPTIONS = {
1175
1188
  reachable: { type: 'string', value: '<duration>', hidden: true, description: 'Deprecated; ignored. Claude conversations stay reachable without a time limit.' },
1176
1189
  approvals: { type: 'boolean', description: 'Keep the agent’s permission prompts.' },
1177
1190
  };
1191
+ /** A new thread on an existing conversation's computer (0.16.0+). */
1192
+ const ON_OPTIONS = {
1193
+ on: { type: 'string', value: '<conversation|computer>', description: 'Start the thread on the computer of an existing conversation, sharing its packages, caches, services, ports and files, on its own branch: a conversation id, key or name, or its workspace key or id (shard agents computers lists them) (0.16.0+).' },
1194
+ review: { type: 'boolean', description: 'With --on: a read-only Claude reviewer of that conversation’s worktree instead of a thread with its own branch (0.16.0+).' },
1195
+ };
1178
1196
  export const PROJECT_COMMAND = {
1179
1197
  path: ['<path>'], summary: 'Start a conversation in its own cloud VM',
1180
1198
  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.',
1181
1199
  positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: {
1182
1200
  ...AGENT_OPTIONS,
1201
+ ...ON_OPTIONS,
1183
1202
  computer: COMPUTER_OPTION,
1184
1203
  new: { type: 'boolean', description: 'Start a new conversation.' },
1185
1204
  resume: { type: 'boolean', description: 'Continue a conversation from this project.' },
@@ -1208,8 +1227,12 @@ const AGENT_COMMANDS = [
1208
1227
  })),
1209
1228
  ...['status', 'enable', 'disable'].map(action => ({ path: ['agents', 'codex', action], summary: `${action} the account's billed Codex Cloud connector`, positionals: [], options: {} })),
1210
1229
  { path: ['agents', 'open'], summary: 'Open a conversation (Codex Cloud or the native Claude session)', description: 'Codex opens its existing Cloud thread without waking its VM. Claude attaches its native 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|auto|claude-desktop|codex-app|web|t3>', description: 'Where to open it. auto (0.15.1+): the saved target, else a Claude conversation in Claude Desktop when it is installed, else in this terminal; web opens claude.ai.' } } },
1211
- { path: ['agents', 'new'], summary: 'Start a conversation in a fresh VM; Codex opens a genuine Cloud thread', positionals: [{ name: 'agent' }], options: {
1230
+ { path: ['agents', 'computers'], summary: 'List the project’s computers a new thread can start on with --on, and the threads on each (0.16.0+)', positionals: [], options: {
1231
+ path: { type: 'string', value: '<path>', description: 'Local project path (default the current directory).' },
1232
+ } },
1233
+ { path: ['agents', 'new'], summary: 'Start a conversation in a fresh VM, or with --on on an existing conversation’s computer; Codex opens a genuine Cloud thread', positionals: [{ name: 'agent' }], options: {
1212
1234
  ...AGENT_OPTIONS,
1235
+ ...ON_OPTIONS,
1213
1236
  computer: COMPUTER_OPTION,
1214
1237
  app: { type: 'string', value: '<claude|codex>', description: 'Agent app (default remembered choice).' },
1215
1238
  path: { type: 'string', value: '<path>', description: 'Local project path for first launch.' },
@@ -1407,6 +1430,9 @@ export function parseCommandLine(argv, options = {}) {
1407
1430
  // `acp --login` is the ACP terminal sign-in an editor runs (ACP appends the method's args to `shard acp`).
1408
1431
  if (argv.length === 2 && argv[0] === 'acp' && argv[1] === '--login')
1409
1432
  return { kind: 'run', command: { path: ['acp'], summary: '', positionals: [], options: {} }, values: { login: true }, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1433
+ // `menubar refresh` (0.15.2+, not in help): the background step each command may start (menubar.ts).
1434
+ if (argv.length === 2 && argv[0] === 'menubar' && argv[1] === 'refresh')
1435
+ return { kind: 'run', command: { path: ['menubar', 'refresh'], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1410
1436
  if (argv.length === 1 && (argv[0] === 'acp' || argv[0] === 'device-helper'))
1411
1437
  return { kind: 'run', command: { path: [argv[0]], summary: '', positionals: [], options: {} }, values: {}, positionals: [], global: { apiUrl: undefined, json: false, agentLabel: undefined, wake: undefined, wakeTimeout: undefined } };
1412
1438
  const terminator = argv.indexOf('--');
@@ -6,6 +6,8 @@ import type { InitialImage } from './initial-prompt.js';
6
6
  /** Only provider-returned task ids enter the registered Codex deep-link route. */
7
7
  export declare function openCodexThread(task: CodexCloudTask, env: Ctx['io']['env']): Promise<void>;
8
8
  export declare function submitCloudInitialPrompt(ctx: Ctx, c: Conversation, prompt: string, workspaceKey: string): Promise<void>;
9
+ /** Prints the Cloud thread (and its link) and opens it in the Codex app; --json prints it instead. */
10
+ export declare function printCloudThread(ctx: Ctx, c: Conversation, task: CodexCloudTask, submitted?: boolean): Promise<void>;
9
11
  /** How long the account's Codex remote device may take to come up before it counts as stuck. */
10
12
  export declare const CODEX_STARTING_LIMIT_MS: number;
11
13
  export type CodexConnectorView = {
@@ -32,6 +32,8 @@ export async function submitCloudInitialPrompt(ctx, c, prompt, workspaceKey) {
32
32
  throw new CodedError('initial_prompt_failed', `Created ${workspaceKey}, but the initial prompt could not be confirmed. Open that conversation before starting another.`, undefined, { workspace_key: workspaceKey, conversation_id: c.id, submitted: null });
33
33
  }
34
34
  }
35
+ /** Prints the Cloud thread (and its link) and opens it in the Codex app; --json prints it instead. */
36
+ export async function printCloudThread(ctx, c, task, submitted = false) { return output(ctx, c, task, submitted); }
35
37
  async function output(ctx, c, task, submitted = false) {
36
38
  const url = threadLink(task);
37
39
  if (ctx.json) {
@@ -12,6 +12,7 @@ import { randomUUID } from 'node:crypto';
12
12
  import { prepareAgentMachine } from "./agent-machine.js";
13
13
  import { OperationFailedError } from '@shardflux/sdk';
14
14
  import { launchConversationProject } from "./conversation-terminal.js";
15
+ import { startThreadOn } from "./thread-on-computer.js";
15
16
  /** L9 can register target-specific handlers after detecting the installed applications. */
16
17
  export async function availableTargets(ctx) {
17
18
  const { HANDLERS } = await import("./handlers.js");
@@ -34,6 +35,14 @@ export async function agentAction(ctx, action, values, args) {
34
35
  }
35
36
  export const CENTER_HANDLERS = {
36
37
  async 'agents new'(ctx, values, [ref]) {
38
+ // A thread on an existing conversation's computer (0.16.0+).
39
+ if (values.on !== undefined) {
40
+ if (ref || values.like !== undefined)
41
+ throw new UsageError('--on starts a thread on that conversation’s computer; omit the agent argument and --like.');
42
+ return startThreadOn(ctx, values, str(values.path) ?? './');
43
+ }
44
+ if (values.review === true)
45
+ throw new UsageError('--review needs --on <conversation>.');
37
46
  if ((values.prompt !== undefined || values['prompt-file'] !== undefined || values.image !== undefined) && (ref || values.name !== undefined || values.like !== undefined || values.in !== undefined)) {
38
47
  throw new UsageError('An initial prompt needs a new conversation: use agents new --path <folder> --app claude|codex.');
39
48
  }
@@ -31,6 +31,11 @@ export interface Ctx {
31
31
  wake: boolean;
32
32
  /** Bound on lifecycle waits per cell call, wakes included (--wake-timeout / SHARDFLUX_WAKE_TIMEOUT_MS). */
33
33
  wakeTimeoutMs: number;
34
+ /**
35
+ * The wake bound was given (--wake-timeout or SHARDFLUX_WAKE_TIMEOUT_MS; 0.16.0+): it is kept as given. Otherwise a
36
+ * resume queued for capacity is waited for until its deadline, its place shown on stderr.
37
+ */
38
+ wakeTimeoutExplicit?: boolean;
34
39
  signal: AbortSignal;
35
40
  /** --timing: print the SDK's timing of the command's open, wait or wake (stderr; a `timing` field with --json). */
36
41
  timing: boolean;
@@ -66,7 +71,7 @@ export interface Ctx {
66
71
  distribution: Distribution;
67
72
  /** The Node.js binary and script of this process; null when run in-process (tests, embedders). */
68
73
  runtime: import('./update.js').Runtime | null;
69
- /** GET /v1/client-versions (unauthenticated): the latest version of every client. */
74
+ /** Every client's versions: GET /v1/client-versions (minimum_supported, hold) with `latest` from npm (0.15.2+). */
70
75
  clientVersions(): Promise<unknown>;
71
76
  /** This machine's name (default API key name of `shard setup`). */
72
77
  hostname(): string;
@@ -127,4 +132,21 @@ export declare function ttlSeconds(values: Values, b: {
127
132
  max: number;
128
133
  range: string;
129
134
  }): number | undefined;
135
+ type ThreadRow = import('@shardflux/sdk').Agent & {
136
+ conversation_id?: string | null;
137
+ conversation_key?: string;
138
+ };
139
+ /**
140
+ * Each session's own conversation: the one whose harness session it is, else the computer's only live conversation.
141
+ * Rows that cannot be told apart keep no conversation rather than another thread's.
142
+ */
143
+ export declare function withConversations(rows: import('@shardflux/sdk').Agent[], conversations: {
144
+ id: string;
145
+ key: string;
146
+ workspace_id: string;
147
+ harness_session_id: string | null;
148
+ state: string;
149
+ ended_at?: string | null;
150
+ }[] | null): ThreadRow[];
130
151
  export declare const KEY_HANDLERS: Record<string, Handler>;
152
+ export {};
package/dist/commands.js CHANGED
@@ -12,7 +12,7 @@ import { agentConfigLikeSecrets, syncAgentConfig } from "./agent-config-sync.js"
12
12
  import { randomUUID } from 'node:crypto';
13
13
  import { access, readFile, writeFile } from 'node:fs/promises';
14
14
  import { basename, dirname, resolve as resolvePath } from 'node:path';
15
- import { EXECUTION_ID, ExecStartError, FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH, NotSupportedForModeError, SDK_VERSION, ShardfluxApiError, ShardfluxProtocolError, newExecutionId, exitCodePosix } from '@shardflux/sdk';
15
+ import { EXECUTION_ID, ExecStartError, capacityOf, FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH, NotSupportedForModeError, SDK_VERSION, ShardfluxApiError, ShardfluxProtocolError, newExecutionId, exitCodePosix } from '@shardflux/sdk';
16
16
  import { parse as parseYaml } from 'yaml';
17
17
  import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
18
18
  import { ExecSessionRunningError, ExecutionFailedError, ExecutionPendingError, InterruptedError, NotFoundError, UsageError } from "./errors.js";
@@ -32,7 +32,15 @@ export const str = (v) => (typeof v === 'string' ? v : undefined);
32
32
  * The workspace's cell client as this invocation configures it: the CLI's agent label, wake on use unless turned off,
33
33
  * and the wake timeout (an OperationTimeoutError past it exits 5).
34
34
  */
35
- const cellOf = (ctx, ws) => ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs, ...(ctx.wake ? {} : { wake: null }) });
35
+ const cellOf = (ctx, ws) => ws.cell({
36
+ agentLabel: ctx.agentLabel,
37
+ transitionTimeoutMs: ctx.wakeTimeoutMs,
38
+ ...(!ctx.wake
39
+ ? { wake: null }
40
+ : ctx.wakeTimeoutExplicit
41
+ ? { wake: (timeoutMs, signal) => ws.wake({ timeoutMs, ...(signal ? { signal } : {}), agentLabel: ctx.agentLabel, untilCapacityDeadline: false }) }
42
+ : {}),
43
+ });
36
44
  export const strs = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : []);
37
45
  const MAX_SECRET_VALUE_BYTES = 65_536;
38
46
  /** `files patch --content-file`: a patch request is at most 7 MiB; larger files go through `files write`. */
@@ -357,10 +365,17 @@ async function writeNew(path, text, force) {
357
365
  throw new UsageError(`${path} exists; pass --force to overwrite it`);
358
366
  await writeFile(path, text);
359
367
  }
368
+ /**
369
+ * An explicit `--timeout` is kept as given (0.16.0+): without one, a wait whose start is queued for capacity keeps waiting
370
+ * until the start's deadline (the SDK's `untilCapacityDeadline`), printing where it stands on stderr.
371
+ */
372
+ function capacityWait(values) {
373
+ return values !== undefined && str(values.timeout) !== undefined ? { untilCapacityDeadline: false } : {};
374
+ }
360
375
  /** Waits with the SDK's backoff; Ctrl-C reports the operation that keeps running. */
361
- async function waitOperation(ctx, operationId, timeoutMs) {
376
+ async function waitOperation(ctx, operationId, timeoutMs, values) {
362
377
  try {
363
- return await ctx.cloud().workspaces.waitForOperation(operationId, { timeoutMs, signal: ctx.signal });
378
+ return await ctx.cloud().workspaces.waitForOperation(operationId, { timeoutMs, signal: ctx.signal, ...capacityWait(values) });
364
379
  }
365
380
  catch (err) {
366
381
  if (ctx.signal.aborted)
@@ -782,7 +797,28 @@ function reportExecution(ctx, ref, r, opts) {
782
797
  }
783
798
  function lifecycleText(verb, w, op) {
784
799
  const finished = op.state === 'succeeded' || op.state === 'failed' || op.state === 'canceled';
785
- return `${verb} ${w.key !== undefined ? `${w.key} (${w.id})` : w.id}: operation ${op.id} ${op.kind} ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''}\n${finished ? durableText(op) : `wait with: shard operations wait ${op.id}\n`}`;
800
+ // A start queued for capacity (0.16.0+): its place in the queue.
801
+ const position = capacityOf(op)?.queuePosition;
802
+ const why = [op.state_reason, position ? `position ${position} in the queue` : null].filter(Boolean).join('; ');
803
+ return `${verb} ${w.key !== undefined ? `${w.key} (${w.id})` : w.id}: operation ${op.id} ${op.kind} ${op.state}${why ? ` (${why})` : ''}\n${finished ? durableText(op) : `wait with: shard operations wait ${op.id}\n`}`;
804
+ }
805
+ /**
806
+ * Each session's own conversation: the one whose harness session it is, else the computer's only live conversation.
807
+ * Rows that cannot be told apart keep no conversation rather than another thread's.
808
+ */
809
+ export function withConversations(rows, conversations) {
810
+ if (!conversations)
811
+ return rows;
812
+ const live = conversations.filter(c => c.state !== 'done' && c.state !== 'archived' && !c.ended_at);
813
+ return rows.map(a => {
814
+ const session = a.id.slice(a.workspace_id.length + 1);
815
+ const bound = live.filter(c => c.workspace_id === a.workspace_id);
816
+ // An API that names the session's conversation (0.16.0+) decides; older ones are matched here by the same rule.
817
+ const named = a.conversation_id;
818
+ const c = (named ? conversations.find(c => c.id === named) : undefined)
819
+ ?? conversations.find(c => c.workspace_id === a.workspace_id && c.harness_session_id === session) ?? (bound.length === 1 ? bound[0] : undefined);
820
+ return c ? { ...a, conversation_id: c.id, conversation_key: c.key } : a;
821
+ });
786
822
  }
787
823
  /** The project-key commands; handlers.ts adds the account plane's. */
788
824
  function agentGrace(text) {
@@ -807,6 +843,9 @@ export const KEY_HANDLERS = {
807
843
  ...(str(values.limit) ? { limit: parsePositiveInt(str(values.limit), 'limit', 200) } : {}),
808
844
  };
809
845
  const rows = [];
846
+ // Which conversation each session belongs to (0.16.0+): several threads can share one computer. Read alongside
847
+ // the sessions; an API without conversations leaves the rows as they are.
848
+ const threads = import("./conversations.js").then(m => m.listConversations(ctx)).catch(() => null);
810
849
  let cursor = str(values.cursor);
811
850
  let next;
812
851
  do {
@@ -820,7 +859,8 @@ export const KEY_HANDLERS = {
820
859
  rows.splice(0, rows.length, ...await withLiveAgents(ctx, rows, { ...(state ? { state } : {}), labels }));
821
860
  // LAST MESSAGE (0.13.4+): the session's latest message, from an API that reports it.
822
861
  const message = (a) => !a.last_message ? '-' : a.last_message.length > 60 ? `${a.last_message.slice(0, 59)}…` : a.last_message;
823
- out(ctx, rows.length ? table(['WORKSPACE', 'APP', 'STATE', 'TITLE', 'LAST MESSAGE'], rows.map(a => [a.workspace_key, a.app, a.state, a.title, message(a)])) : 'No agent sessions.\n', { data: rows, next_cursor: next });
862
+ const enriched = withConversations(rows, await threads);
863
+ out(ctx, rows.length ? table(['CONVERSATION', 'COMPUTER', 'APP', 'STATE', 'TITLE', 'LAST MESSAGE'], enriched.map(a => [a.conversation_key?.split('/').at(-1) ?? '-', a.workspace_key, a.app, a.state, a.title, message(a)])) : 'No agent sessions.\n', { data: enriched, next_cursor: next });
824
864
  if (!ctx.json && next)
825
865
  ctx.io.stderr.write(`more: shard agents list --cursor ${next}\n`);
826
866
  return 0;
@@ -1090,7 +1130,7 @@ export const KEY_HANDLERS = {
1090
1130
  ...(mode ? { mode } : {}),
1091
1131
  ...(computerUse !== undefined ? { computerUse } : {}),
1092
1132
  agentLabel: ctx.agentLabel,
1093
- wait: values.wait === false ? false : { timeoutMs, signal: ctx.signal },
1133
+ wait: values.wait === false ? false : { timeoutMs, signal: ctx.signal, ...capacityWait(values) },
1094
1134
  }));
1095
1135
  const op = ws.activeOperation;
1096
1136
  if (ws.ready && ws.mode === 'processful' && ws.labels.shard_project)
@@ -1653,7 +1693,7 @@ export const KEY_HANDLERS = {
1653
1693
  });
1654
1694
  const op = ws.activeOperation;
1655
1695
  if (values.wait !== false && !ws.ready && op) {
1656
- await waitOperation(ctx, op.id, timeoutMs);
1696
+ await waitOperation(ctx, op.id, timeoutMs, values);
1657
1697
  ws = await ctx.cloud().workspaces.get(ws.id);
1658
1698
  }
1659
1699
  const human = ws.ready
@@ -1849,7 +1889,7 @@ export const KEY_HANDLERS = {
1849
1889
  let ws = opened.workspace;
1850
1890
  const op = opened.operation;
1851
1891
  if (values.wait !== false && !ws.ready && op) {
1852
- await waitOperation(ctx, op.id, timeoutMs);
1892
+ await waitOperation(ctx, op.id, timeoutMs, values);
1853
1893
  ws = await cloud.workspaces.get(ws.id);
1854
1894
  }
1855
1895
  const human = ws.ready
@@ -1875,7 +1915,7 @@ export const KEY_HANDLERS = {
1875
1915
  const label = str(values.label);
1876
1916
  let op = await draft.captureState(label === undefined ? {} : { label });
1877
1917
  if (values.wait === true)
1878
- op = await waitOperation(ctx, op.id, waitTimeout(values));
1918
+ op = await waitOperation(ctx, op.id, waitTimeout(values), values);
1879
1919
  const checkpoint = typeof op.result?.checkpoint_id === 'string' ? `state ${op.result.checkpoint_id}\n` : '';
1880
1920
  out(ctx, `Capturing a state of the ${slug} draft: operation ${op.id} ${op.kind} ${op.state}\n${checkpoint}${op.state === 'succeeded' ? '' : `wait with: shard operations wait ${op.id}\n`}`, op);
1881
1921
  return 0;
@@ -1902,7 +1942,7 @@ export const KEY_HANDLERS = {
1902
1942
  let ws = await draft.openTestInstance({ ...(state ? { stateId: state } : {}), ...(instanceKey ? { key: instanceKey } : {}), ...(c ? { caps: c } : {}), agentLabel: ctx.agentLabel, wait: false });
1903
1943
  const op = ws.activeOperation;
1904
1944
  if (values.wait !== false && !ws.ready && op) {
1905
- await waitOperation(ctx, op.id, timeoutMs);
1945
+ await waitOperation(ctx, op.id, timeoutMs, values);
1906
1946
  ws = await ctx.cloud().workspaces.get(ws.id);
1907
1947
  }
1908
1948
  const human = ws.ready
@@ -1931,7 +1971,7 @@ export const KEY_HANDLERS = {
1931
1971
  throw new UsageError('refusing to discard without --yes (the draft is deleted and its live test instances end)');
1932
1972
  let op = await ctx.cloud().templates.draft(slug).discard();
1933
1973
  if (values.wait === true)
1934
- op = await waitOperation(ctx, op.id, waitTimeout(values));
1974
+ op = await waitOperation(ctx, op.id, waitTimeout(values), values);
1935
1975
  out(ctx, `Discarding the ${slug} draft: operation ${op.id} ${op.kind} ${op.state}\n`, op);
1936
1976
  return 0;
1937
1977
  },
@@ -2344,7 +2384,7 @@ export const KEY_HANDLERS = {
2344
2384
  const timeoutMs = waitTimeout(values);
2345
2385
  const op = values.durable === true
2346
2386
  ? await waited(ctx, `operation ${id} continues server side.`, () => ctx.cloud().workspaces.waitForDurable(id, { timeoutMs, signal: ctx.signal }))
2347
- : await waitOperation(ctx, id, timeoutMs);
2387
+ : await waitOperation(ctx, id, timeoutMs, values);
2348
2388
  out(ctx, operationDetail(op), op);
2349
2389
  return 0;
2350
2390
  },
@@ -2488,7 +2528,7 @@ async function resolveSecret(ctx, values, ref) {
2488
2528
  }
2489
2529
  /** `--wait`: the SDK's own wait (the call resolves once the operation finished; one trace covers request and wait). */
2490
2530
  function lifecycleOptions(ctx, values, timeoutMs) {
2491
- return values.wait === true ? { wait: { timeoutMs, signal: ctx.signal } } : {};
2531
+ return values.wait === true ? { wait: { timeoutMs, signal: ctx.signal, ...capacityWait(values) } } : {};
2492
2532
  }
2493
2533
  async function lifecycle(ctx, values, ref, verb, start, processfulOnly) {
2494
2534
  const timeoutMs = waitTimeout(values);
@@ -1,5 +1,6 @@
1
1
  import { out, str } from "./commands.js";
2
2
  import { UsageError } from "./errors.js";
3
+ import { FilesMirror, humanBytes, listMirrorConversations, workspaceHub } from "./files-mirror.js";
3
4
  export function conversationClient(ctx) {
4
5
  const key = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || ctx.store.profile(ctx.apiUrl).api_key !== undefined;
5
6
  const session = Boolean(ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim()) || ctx.store.profile(ctx.apiUrl).session !== undefined;
@@ -32,5 +33,10 @@ export const CONVERSATION_HANDLERS = {
32
33
  out(ctx, `${r.title}: ${r.landed.how}\n`, r);
33
34
  return 0;
34
35
  },
36
+ async 'files pull'(ctx, _values, [conversation, path]) {
37
+ const r = await new FilesMirror(ctx, workspaceHub(ctx)).pull(conversation, path, () => listMirrorConversations(ctx));
38
+ out(ctx, r.already ? `${r.local_path} is already downloaded.\n` : `Downloaded ${r.path} (${humanBytes(r.bytes)}) to ${r.local_path}\n`, r);
39
+ return 0;
40
+ },
35
41
  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
42
  };
@@ -15,15 +15,24 @@ import { localResumeTerminal } from "./conversation-local-resume.js";
15
15
  import { agentReachability } from "./agent-reachability.js";
16
16
  import { promptWithImages, readInitialImages, readInitialPrompt, uploadInitialImages } from "./initial-prompt.js";
17
17
  import { launchCodexCloud, openCodexConversation } from "./codex-cloud.js";
18
+ import { startThreadOn } from "./thread-on-computer.js";
18
19
  /** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
19
20
  export async function conversationTerminal(ctx, ws, c, project, values) {
20
21
  if (c.route === 'codex-cloud')
21
22
  return openCodexConversation(ctx, c);
22
23
  await restoreConversationBinding(ctx, c, ws);
23
- return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id });
24
+ // A thread on another conversation's computer has its own PTY there, never the VM's own (0.16.0+).
25
+ const pty = ws.labels.shard_conversation && ws.labels.shard_conversation !== c.id ? conversationName(c) : undefined;
26
+ 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 } : {}) });
24
27
  }
25
28
  /** Select a durable conversation, then use the harness's native terminal for all interaction. */
26
29
  export const launchConversationProject = async (ctx, values, [path = './', explicit]) => {
30
+ if (values.on !== undefined) {
31
+ const app = explicit ?? (values.claude === true ? 'claude' : values.codex === true ? 'codex' : undefined);
32
+ return startThreadOn(ctx, values, path, app);
33
+ }
34
+ if (values.review === true)
35
+ throw new UsageError('--review needs --on <conversation>.');
27
36
  const text = await readInitialPrompt(ctx, values);
28
37
  // Images are read and validated before allocation, held in memory, and written only into the new VM.
29
38
  const images = await readInitialImages(ctx, values, text);
@@ -26,6 +26,11 @@ export interface Conversation {
26
26
  } | null;
27
27
  ports?: number[];
28
28
  restore?: Record<string, unknown> | null;
29
+ /** The repository and the branch it started from, from APIs that return them. */
30
+ repo_remote?: string;
31
+ base_branch?: string | null;
32
+ role?: string;
33
+ join_mode?: string | null;
29
34
  }
30
35
  export interface LocalConversation {
31
36
  api_url: string;
@@ -33,17 +38,24 @@ export interface LocalConversation {
33
38
  project_id: string;
34
39
  conversation: Conversation;
35
40
  handed_off?: boolean;
41
+ /** The branch the conversation started from (0.16.0+), for APIs that do not return base_branch yet. */
42
+ base_branch?: string;
36
43
  }
37
44
  export declare const conversationName: (c: Conversation) => string;
38
45
  export declare function validateConversation(c: Conversation): Conversation;
39
46
  export declare function localConversations(ctx: Pick<Ctx, 'store' | 'apiUrl'>): Record<string, LocalConversation>;
40
- export declare function rememberConversation(ctx: Pick<Ctx, 'store' | 'apiUrl'>, c: Conversation, folder: string, handedOff?: boolean): void;
47
+ export declare function rememberConversation(ctx: Pick<Ctx, 'store' | 'apiUrl'>, c: Conversation, folder: string, handedOff?: boolean, baseBranch?: string): void;
41
48
  export declare function conversationsUnavailable(e: unknown): boolean;
42
49
  export declare function getConversation(ctx: Ctx, id: string): Promise<Conversation>;
43
50
  export declare function listConversations(ctx: Ctx, projectId?: string): Promise<Conversation[]>;
44
51
  export declare function resolveConversation(ctx: Ctx, ref: string, folder?: string): Promise<Conversation>;
45
52
  /** Re-resolve the binding on every attach: a move changes the VM, never the conversation identity. */
46
53
  export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promise<Workspace>;
54
+ /**
55
+ * A thread on an existing computer (a join) is prepared by that computer's guest: its worktree, then its harness. Waits
56
+ * until the guest confirms it (the conversation no longer carries restore instructions), within the wake timeout.
57
+ */
58
+ export declare function waitThreadReady(ctx: Ctx, c: Conversation, pollMs?: number): Promise<Conversation>;
47
59
  export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws: Workspace): Promise<void>;
48
60
  export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, allocated?: (workspace: WorkspaceView) => void, idempotencyKey?: string): Promise<Conversation>;
49
61
  export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string): Promise<{
@@ -7,6 +7,8 @@ import { ensureProjectGit } from "./project-git.js";
7
7
  import { seedAgentRepository } from "./agent-repository.js";
8
8
  import { UsageError } from "./errors.js";
9
9
  import { conversationClient } from "./conversation-commands.js";
10
+ import { recordSeedSnapshot } from "./files-mirror.js";
11
+ import { rememberTwinSeed, sendTwinSeed, twinCheckoutArgv, twinSeed } from "./worktree-twin.js";
10
12
  export const conversationName = (c) => {
11
13
  const name = c.key.split('/').at(-1);
12
14
  if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(name) || c.branch !== `shard/${name}`)
@@ -23,11 +25,12 @@ export function localConversations(ctx) {
23
25
  const records = new AgentStateStore(ctx.store.dir).read().conversations ?? {};
24
26
  return Object.fromEntries(Object.entries(records).filter(([, r]) => r.api_url === ctx.apiUrl));
25
27
  }
26
- export function rememberConversation(ctx, c, folder, handedOff = false) {
28
+ export function rememberConversation(ctx, c, folder, handedOff = false, baseBranch) {
27
29
  validateConversation(c);
28
30
  new AgentStateStore(ctx.store.dir).update(doc => {
29
31
  const records = doc.conversations ?? {};
30
- records[c.id] = { api_url: ctx.apiUrl, folder, project_id: c.project_id, conversation: c, ...(handedOff ? { handed_off: true } : {}) };
32
+ const base = baseBranch ?? records[c.id]?.base_branch;
33
+ records[c.id] = { api_url: ctx.apiUrl, folder, project_id: c.project_id, conversation: c, ...(handedOff ? { handed_off: true } : {}), ...(base ? { base_branch: base } : {}) };
31
34
  doc.conversations = records;
32
35
  });
33
36
  }
@@ -68,9 +71,36 @@ export async function conversationWorkspace(ctx, c) {
68
71
  await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
69
72
  return ws;
70
73
  }
74
+ /**
75
+ * A thread on an existing computer (a join) is prepared by that computer's guest: its worktree, then its harness. Waits
76
+ * until the guest confirms it (the conversation no longer carries restore instructions), within the wake timeout.
77
+ */
78
+ export async function waitThreadReady(ctx, c, pollMs = 1000) {
79
+ const deadline = Date.now() + ctx.wakeTimeoutMs;
80
+ for (;;) {
81
+ const current = await getConversation(ctx, c.id);
82
+ if (!current.restore || current.restore.kind !== 'join')
83
+ return current;
84
+ if (current.state === 'done' || current.state === 'archived' || current.ended_at)
85
+ throw new UsageError('This thread ended before it started.');
86
+ if (Date.now() >= deadline)
87
+ throw new UsageError(`The computer is still preparing this thread; open it with shard agents open ${c.id} in a moment.`);
88
+ await new Promise((resolve, reject) => {
89
+ const timer = setTimeout(done, Math.min(pollMs, Math.max(1, deadline - Date.now())));
90
+ function done() { ctx.signal.removeEventListener('abort', abort); resolve(); }
91
+ function abort() { clearTimeout(timer); reject(ctx.signal.reason ?? new Error('interrupted')); }
92
+ ctx.signal.addEventListener('abort', abort, { once: true });
93
+ });
94
+ }
95
+ }
71
96
  export async function restoreConversationBinding(ctx, c, ws) {
72
97
  if (!c.restore || c.restore.kind === 'new')
73
98
  return;
99
+ // Threads on an existing computer (0.16.0+): its guest prepares them, never this command.
100
+ if (c.restore.kind === 'join') {
101
+ await waitThreadReady(ctx, c);
102
+ return;
103
+ }
74
104
  const cell = ws.cell();
75
105
  if (c.restore.kind === 'join' && c.restore.read_only === true) {
76
106
  const review = await cell.exec.run(['sf-agent', 'conversation', 'restore', '--args', '{"start":false}'], { cwd: c.worktree_path, env: { SF_CONVERSATION_ID: c.id }, timeoutMs: 120_000, signal: ctx.signal });
@@ -92,13 +122,17 @@ export async function createConversationRecord(ctx, folder, harness, route, name
92
122
  // Both the conversation API and its legacy workspace fallback need a usable local HEAD.
93
123
  await conversationClient(ctx).conversations.list({ limit: 1 });
94
124
  const git = gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal);
95
- const commit = await git(['rev-parse', 'HEAD']);
125
+ const head = await git(['rev-parse', 'HEAD']);
96
126
  const branch = await git(['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => undefined);
127
+ // Uncommitted edits ride along as the twin snapshot the conversation starts from (worktree-twin.ts).
128
+ const seed = await twinSeed(ctx.store.dir, project.folder, head, ctx.store.profile(ctx.apiUrl).project_id).catch(() => ({ commit: head, snapshot: false }));
129
+ const commit = seed.commit;
97
130
  const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
98
131
  base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
99
132
  const c = validateConversation({ ...response.conversation, restore: response.restore });
100
133
  allocated?.(response.workspace);
101
- rememberConversation(ctx, c, project.folder);
134
+ rememberTwinSeed(ctx.store.dir, c.id, project.folder, seed, branch);
135
+ rememberConversation(ctx, c, project.folder, false, branch);
102
136
  return c;
103
137
  }
104
138
  export async function createConversation(ctx, folder, harness, route, name) {
@@ -109,9 +143,11 @@ export async function createConversation(ctx, folder, harness, route, name) {
109
143
  // This is a one-time seed. A conversation never watches or writes the owner's checkout.
110
144
  await seedAgentRepository(ctx, ws, project.folder, { root: c.worktree_path, populate: true });
111
145
  const cell = ws.cell();
112
- const result = await cell.exec.run(['git', '-C', c.worktree_path, 'checkout', '-B', c.branch, c.base_commit], { timeoutMs: 30_000, signal: ctx.signal });
146
+ await sendTwinSeed(ctx.store.dir, cell, c, project.folder, ctx.signal);
147
+ const result = await cell.exec.run(twinCheckoutArgv(ctx.store.dir, c), { timeoutMs: 30_000, signal: ctx.signal });
113
148
  if (result.exitCode !== 0)
114
149
  throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
150
+ await recordSeedSnapshot(ws, c, ctx.signal);
115
151
  return { conversation: c, workspace: ws };
116
152
  }
117
153
  catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { DeviceExecutor, askConnectedAcp } from "./device-bridge.js";
3
3
  import { ConversationMirrorState, mirrorProjectConversations } from "./conversation-stash.js";
4
+ import { FilesMirrorState, mirrorFilesOnce } from "./files-mirror.js";
4
5
  import { withTargetLock } from "./target-lock.js";
5
6
  import { join } from 'node:path';
6
7
  import { conversationsUnavailable } from "./conversations.js";
@@ -10,6 +11,7 @@ import { readFileSync } from 'node:fs';
10
11
  import { writePrivateFile } from "./credentials.js";
11
12
  import { conversationClient } from "./conversation-commands.js";
12
13
  import { Readable } from 'node:stream';
14
+ import { runWorktreeTwin } from "./worktree-twin-engine.js";
13
15
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
14
16
  export async function runDeviceHelper(ctx) {
15
17
  const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
@@ -50,7 +52,7 @@ export async function runDeviceHelper(ctx) {
50
52
  ctx.io.stdout.write(JSON.stringify({ type: 'approval_resolved', request_id: approval.request_id }) + '\n'); });
51
53
  };
52
54
  const working = new Map();
53
- const mirrored = new ConversationMirrorState();
55
+ const mirrored = new ConversationMirrorState(), files = new FilesMirrorState();
54
56
  const deviceFile = join(ctx.store.dir, 'device-id');
55
57
  let deviceId;
56
58
  try {
@@ -62,15 +64,19 @@ export async function runDeviceHelper(ctx) {
62
64
  }
63
65
  const executor = new DeviceExecutor(ctx, ask, deviceId);
64
66
  const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
65
- let mirroring;
67
+ let mirroring, twin;
66
68
  try {
67
69
  return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
70
+ // Finished conversations land in their local branches (worktree-twin-engine.ts); its own loop and backoff.
71
+ twin = runWorktreeTwin(ctx, deviceId, signal).catch(report);
68
72
  // A slow or failed transcript copy must not delay heartbeats, request polling or approvals.
69
73
  mirroring = (async () => {
70
74
  let failures = 0;
71
75
  const reported = new Set();
72
76
  while (!signal.aborted) {
73
77
  let wait = 1000;
78
+ // Committed files of every conversation, under ~/Shardflux; its own interval and backoff.
79
+ await mirrorFilesOnce(ctx, files);
74
80
  try {
75
81
  await mirrorProjectConversations(ctx, mirrored);
76
82
  failures = 0;
@@ -124,6 +130,7 @@ export async function runDeviceHelper(ctx) {
124
130
  await executor.close();
125
131
  await Promise.all([...working.values()]);
126
132
  await mirroring;
133
+ await twin;
127
134
  await input.catch(() => undefined);
128
135
  }
129
136
  }
package/dist/errors.d.ts CHANGED
@@ -121,6 +121,11 @@ export interface ErrorInfo {
121
121
  details?: Record<string, unknown>;
122
122
  source?: 'api' | 'cell' | 'unknown';
123
123
  last_state?: string;
124
+ /** A wait that timed out (0.16.0+): the operation's last state_reason. */
125
+ last_reason?: string;
126
+ /** A wait that timed out on a queued start (0.16.0+): its deadline (RFC 3339) and place in the queue. */
127
+ deadline_at?: string;
128
+ queue_position?: number;
124
129
  operation?: unknown;
125
130
  /** A file-first execution the error is about (failed, lost, still running, or left running by Ctrl-C). */
126
131
  execution_id?: string;
package/dist/errors.js CHANGED
@@ -199,7 +199,17 @@ export function describeError(err) {
199
199
  };
200
200
  }
201
201
  if (err instanceof OperationTimeoutError) {
202
- return { code: 'timeout', message: err.message, operation_id: err.operationId, last_state: err.lastState, ...(err.durable ? { details: { durable: false, state: 'pending' } } : {}) };
202
+ // A queued start (0.16.0+): its last reason, deadline and place in the queue.
203
+ return {
204
+ code: 'timeout',
205
+ message: err.message,
206
+ operation_id: err.operationId,
207
+ last_state: err.lastState,
208
+ ...(err.lastReason ? { last_reason: err.lastReason } : {}),
209
+ ...(err.deadlineAt ? { deadline_at: err.deadlineAt } : {}),
210
+ ...(err.queuePosition !== null ? { queue_position: err.queuePosition } : {}),
211
+ ...(err.durable ? { details: { durable: false, state: 'pending' } } : {}),
212
+ };
203
213
  }
204
214
  // --durable (0.7.0+): the suspend succeeded; its durable copy could not be made (see the lifecycle reference).
205
215
  if (err instanceof DurabilityLostError) {