@shardflux/cli 0.15.2 → 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 (41) hide show
  1. package/CHANGELOG.md +82 -12
  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 +26 -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 +22 -0
  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 +9 -2
  30. package/dist/registry.d.ts +2 -42
  31. package/dist/registry.js +6 -90
  32. package/dist/thread-on-computer.d.ts +35 -0
  33. package/dist/thread-on-computer.js +200 -0
  34. package/dist/wait-status.d.ts +22 -0
  35. package/dist/wait-status.js +59 -0
  36. package/dist/worktree-twin-engine.d.ts +95 -0
  37. package/dist/worktree-twin-engine.js +402 -0
  38. package/dist/worktree-twin-land.py +331 -0
  39. package/dist/worktree-twin.d.ts +296 -0
  40. package/dist/worktree-twin.js +718 -0
  41. package/package.json +3 -3
package/dist/handlers.js CHANGED
@@ -15,5 +15,7 @@ import { MENUBAR_HANDLERS } from "./menubar.js";
15
15
  import { UPDATE_HANDLERS } from "./update.js";
16
16
  import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
17
17
  import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversation-local.js";
18
+ import { TWIN_HANDLERS } from "./worktree-twin-engine.js";
18
19
  import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
19
- export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
20
+ import { THREAD_HANDLERS } from "./thread-on-computer.js";
21
+ export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
package/dist/main.js CHANGED
@@ -31,6 +31,7 @@ import { HANDLERS } from "./handlers.js";
31
31
  import { makeFetch } from "./http.js";
32
32
  import { Input } from "./input.js";
33
33
  import { TimingRecorder, notices, timingJson, timingText } from "./timing.js";
34
+ import { waitStatusListener } from "./wait-status.js";
34
35
  import { CLI_DISTRIBUTION, NOTICE_WAIT_MS, UpdateCheck, noticeOf, updateCheckDisabled, updateView } from "./update-check.js";
35
36
  const KEY_SHAPE = /^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/;
36
37
  // Printable (no C0 controls or DEL), as the API's AgentLabel pattern.
@@ -126,7 +127,7 @@ function hintFor(info, cred, file, command = '') {
126
127
  if (reason === 'edit_not_text')
127
128
  return 'the file is not UTF-8 text; use files write for binary data';
128
129
  if (reason === 'host_capacity')
129
- return 'the workspace cannot be woken right now and the call did not run; retry in a few seconds';
130
+ return "the workspace's host is making room to wake it and the call did not run (it was already retried); run the command again";
130
131
  if (reason === 'host_feature_unavailable')
131
132
  return hostFeatureHint(info.details?.feature);
132
133
  // Inbound ports (0.9.0): the next step names the command.
@@ -534,6 +535,7 @@ export async function run(argv, io, opts = {}) {
534
535
  agentLabel,
535
536
  wake,
536
537
  wakeTimeoutMs,
538
+ wakeTimeoutExplicit: parsed.global.wakeTimeout !== undefined || Boolean(io.env.SHARDFLUX_WAKE_TIMEOUT_MS?.trim()),
537
539
  signal,
538
540
  timing: showTiming,
539
541
  traces,
@@ -569,13 +571,18 @@ export async function run(argv, io, opts = {}) {
569
571
  if (cloud)
570
572
  return cloud;
571
573
  const key = apiKey();
574
+ // Timings, and (0.16.0+) where a wait stands: a queued start's position, a call waiting for room (stderr).
575
+ const waitStatus = waitStatusListener(io.stderr, parsed.global.json);
572
576
  cloud = new Shardflux({
573
577
  apiKey: key.token,
574
578
  baseUrl: apiUrl,
575
579
  userAgent,
576
580
  fetch: watchedFetch,
577
581
  sleep,
578
- onProgress: traces.listener,
582
+ onProgress: (e) => {
583
+ traces.listener(e);
584
+ waitStatus(e);
585
+ },
579
586
  versionCheck: false,
580
587
  });
581
588
  return cloud;
@@ -1,48 +1,8 @@
1
- export declare const NPM_REGISTRY = "https://registry.npmjs.org";
2
- /** Each registry request's timeout (ms). */
3
- export declare const REGISTRY_TIMEOUT_MS = 3000;
1
+ import type { RegistryOptions } from '@shardflux/sdk';
2
+ export { NPM_REGISTRY, npmLatest, withRegistryLatest } from '@shardflux/sdk';
4
3
  /** The npm packages whose versions `shard update` compares. */
5
4
  export declare const NPM_CLIENTS: readonly ["shardflux", "@shardflux/cli", "@shardflux/sdk", "@shardflux/mcp", "@shardflux/menubar-darwin"];
6
- export interface RegistryAnswer {
7
- latest: string | null;
8
- /** The registry's validator for the next request (npm's dist-tags endpoint sends none today). */
9
- etag: string | null;
10
- /** 304: what the caller cached under `etag` is still current. */
11
- notModified: boolean;
12
- }
13
- export interface RegistryOptions {
14
- fetch: typeof fetch;
15
- /** Default https://registry.npmjs.org (SHARDFLUX_NPM_REGISTRY overrides it for the CLI). */
16
- registry?: string;
17
- etag?: string | null;
18
- timeoutMs?: number;
19
- signal?: AbortSignal;
20
- userAgent?: string;
21
- }
22
5
  /** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
23
6
  export declare function npmRegistry(env: Record<string, string | undefined>): string;
24
- /** `latest` of an npm package from `<registry>/-/package/<name>/dist-tags`, or null when the registry did not answer. */
25
- export declare function npmLatest(pkg: string, opts: RegistryOptions): Promise<RegistryAnswer | null>;
26
7
  /** `latest` of every package in `packages`, asked in parallel; a package the registry did not answer is absent. */
27
8
  export declare function npmLatestAll(packages: readonly string[], opts: RegistryOptions): Promise<Record<string, string>>;
28
- export interface CatalogEntry {
29
- package: string;
30
- ecosystem: string;
31
- latest: string | null;
32
- minimum_supported: string | null;
33
- upgrade_command: string | null;
34
- release_notes_url: string | null;
35
- hold?: string | null;
36
- [key: string]: unknown;
37
- }
38
- /**
39
- * A GET /v1/client-versions body whose npm entries carry the registry's `latest` (`registry`: package -> version; an
40
- * absent package keeps the catalog's). Works with every catalog shape the API has served: no `hold` field, `latest`
41
- * null, a package missing from the list, or no catalog at all (`catalog` null: the API did not answer).
42
- *
43
- * `hold`: when the registry's latest is that version, it is not recommended; the catalog's own `latest` is, when it is
44
- * older, else `minimum_supported` (so "no longer supported" still works and nobody is told to update).
45
- */
46
- export declare function withRegistryLatest(catalog: unknown, registry: Record<string, string | null | undefined>): {
47
- clients: CatalogEntry[];
48
- };
package/dist/registry.js CHANGED
@@ -1,102 +1,18 @@
1
1
  /**
2
- * The latest version of each Shardflux client comes from its registry (0.15.2+), so a release is known the moment it
3
- * is published: npm's `latest` dist-tag, which is what `npm i -g shardflux` and `shard update` install. (`stable` is an
4
- * opt-in tag for installs behind a release-age gate, docs/progress/packaging.md; it never drives the update notice.)
5
- *
6
- * The API catalog (GET /v1/client-versions) still says which versions it serves (`minimum_supported`) and may name a
7
- * version to stop recommending (`hold`, an emergency brake). Its `latest` is only the fallback when the registry does
8
- * not answer. Every request here is bounded and never throws: offline means "no answer".
2
+ * The latest version of each Shardflux client comes from npm (0.15.2+; the SDK's registry helpers): its `latest`
3
+ * dist-tag is what `npm i -g shardflux` and `shard update` install. The API catalog adds `minimum_supported` and
4
+ * `hold` (see withRegistryLatest).
9
5
  */
10
- import { compareVersions } from '@shardflux/sdk';
11
- export const NPM_REGISTRY = 'https://registry.npmjs.org';
12
- /** Each registry request's timeout (ms). */
13
- export const REGISTRY_TIMEOUT_MS = 3_000;
6
+ import { npmLatest, registryFromEnv } from '@shardflux/sdk';
7
+ export { NPM_REGISTRY, npmLatest, withRegistryLatest } from '@shardflux/sdk';
14
8
  /** The npm packages whose versions `shard update` compares. */
15
9
  export const NPM_CLIENTS = ['shardflux', '@shardflux/cli', '@shardflux/sdk', '@shardflux/mcp', '@shardflux/menubar-darwin'];
16
- const str = (v) => (typeof v === 'string' && v.length > 0 ? v : null);
17
- const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
18
10
  /** The registry to ask: SHARDFLUX_NPM_REGISTRY, else npm's public registry. */
19
11
  export function npmRegistry(env) {
20
- const own = env.SHARDFLUX_NPM_REGISTRY?.trim();
21
- return own ? own.replace(/\/+$/, '') : NPM_REGISTRY;
22
- }
23
- /** `latest` of an npm package from `<registry>/-/package/<name>/dist-tags`, or null when the registry did not answer. */
24
- export async function npmLatest(pkg, opts) {
25
- try {
26
- const timeout = AbortSignal.timeout(opts.timeoutMs ?? REGISTRY_TIMEOUT_MS);
27
- const headers = { accept: 'application/json' };
28
- if (opts.userAgent)
29
- headers['user-agent'] = opts.userAgent;
30
- if (opts.etag)
31
- headers['if-none-match'] = opts.etag;
32
- const res = await opts.fetch(`${(opts.registry ?? NPM_REGISTRY).replace(/\/+$/, '')}/-/package/${encodeURIComponent(pkg)}/dist-tags`, {
33
- method: 'GET',
34
- headers,
35
- signal: opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout,
36
- });
37
- const etag = res.headers.get('etag');
38
- if (res.status === 304) {
39
- await res.body?.cancel().catch(() => undefined);
40
- return { latest: null, etag: etag ?? opts.etag ?? null, notModified: true };
41
- }
42
- if (!res.ok) {
43
- await res.body?.cancel().catch(() => undefined);
44
- return null;
45
- }
46
- const body = JSON.parse(await res.text());
47
- const latest = isObj(body) ? str(body.latest) : null;
48
- // A tag that is not a version (a broken registry mirror) is no answer.
49
- if (latest === null || compareVersions(latest, latest) === null)
50
- return null;
51
- return { latest, etag, notModified: false };
52
- }
53
- catch {
54
- return null;
55
- }
12
+ return registryFromEnv(env, 'npm');
56
13
  }
57
14
  /** `latest` of every package in `packages`, asked in parallel; a package the registry did not answer is absent. */
58
15
  export async function npmLatestAll(packages, opts) {
59
16
  const answers = await Promise.all(packages.map(async (pkg) => [pkg, await npmLatest(pkg, { ...opts, etag: null })]));
60
17
  return Object.fromEntries(answers.flatMap(([pkg, a]) => (a?.latest ? [[pkg, a.latest]] : [])));
61
18
  }
62
- const DEFAULT_UPGRADE = {
63
- shardflux: 'npm i -g shardflux',
64
- '@shardflux/cli': 'npm i -g shardflux',
65
- '@shardflux/menubar-darwin': 'shard update',
66
- };
67
- /**
68
- * A GET /v1/client-versions body whose npm entries carry the registry's `latest` (`registry`: package -> version; an
69
- * absent package keeps the catalog's). Works with every catalog shape the API has served: no `hold` field, `latest`
70
- * null, a package missing from the list, or no catalog at all (`catalog` null: the API did not answer).
71
- *
72
- * `hold`: when the registry's latest is that version, it is not recommended; the catalog's own `latest` is, when it is
73
- * older, else `minimum_supported` (so "no longer supported" still works and nobody is told to update).
74
- */
75
- export function withRegistryLatest(catalog, registry) {
76
- const listed = isObj(catalog) && Array.isArray(catalog.clients) ? catalog.clients.filter(isObj) : [];
77
- const entries = listed.map((c) => ({
78
- ...c,
79
- package: String(c.package),
80
- ecosystem: String(c.ecosystem),
81
- latest: str(c.latest),
82
- minimum_supported: str(c.minimum_supported),
83
- upgrade_command: str(c.upgrade_command),
84
- release_notes_url: str(c.release_notes_url),
85
- hold: str(c.hold),
86
- }));
87
- for (const pkg of Object.keys(registry)) {
88
- if (!entries.some((e) => e.ecosystem === 'npm' && e.package === pkg)) {
89
- entries.push({ package: pkg, ecosystem: 'npm', latest: null, minimum_supported: null, upgrade_command: DEFAULT_UPGRADE[pkg] ?? null, release_notes_url: `https://www.npmjs.com/package/${pkg}?activeTab=versions`, hold: null });
90
- }
91
- }
92
- return {
93
- clients: entries.map((e) => {
94
- const fromRegistry = e.ecosystem === 'npm' ? str(registry[e.package]) : null;
95
- let latest = fromRegistry ?? e.latest;
96
- if (latest !== null && e.hold && compareVersions(latest, e.hold) === 0) {
97
- latest = e.latest !== null && compareVersions(e.latest, e.hold) === -1 ? e.latest : e.minimum_supported;
98
- }
99
- return { ...e, latest };
100
- }),
101
- };
102
- }
@@ -0,0 +1,35 @@
1
+ import type { Ctx, Handler } from './commands.js';
2
+ import type { Values } from './args.js';
3
+ import type { Conversation, Harness } from './conversations.js';
4
+ /** A conversation still bound to its computer: not done, archived or ended. */
5
+ export declare const activeConversation: (c: Pick<Conversation, "state" | "ended_at">) => boolean;
6
+ export interface ComputerThread {
7
+ id: string;
8
+ key: string;
9
+ title: string;
10
+ harness: Harness;
11
+ state: Conversation['state'];
12
+ route: Conversation['route'];
13
+ role: string;
14
+ join_mode: string | null;
15
+ branch: string;
16
+ }
17
+ export interface Computer {
18
+ workspace_id: string;
19
+ workspace_key: string;
20
+ state: 'running' | 'suspended';
21
+ last_activity_at: string | null;
22
+ conversations: ComputerThread[];
23
+ }
24
+ /** The project's conversations: its repository's, or (for a folder without a remote) the ones started from it. */
25
+ export declare function projectConversations(ctx: Pick<Ctx, 'store' | 'apiUrl'>, all: Conversation[], folder: string): Conversation[];
26
+ /** Computers a new thread can start on: each live VM (running or parked, never deleted) of the project's active conversations. */
27
+ export declare function listComputers(ctx: Ctx, folder: string): Promise<Computer[]>;
28
+ /**
29
+ * The conversation whose computer `--on` names: a conversation id, key or name, or the VM's workspace key or id (then
30
+ * the VM's own conversation, else its oldest live thread).
31
+ */
32
+ export declare function resolveOn(ctx: Ctx, ref: string, folder: string): Promise<Conversation>;
33
+ /** Starts the thread on `--on`'s computer and opens it like a new conversation of that app. */
34
+ export declare function startThreadOn(ctx: Ctx, values: Values, path: string, explicitApp?: string): Promise<number>;
35
+ export declare const THREAD_HANDLERS: Record<string, Handler>;
@@ -0,0 +1,200 @@
1
+ // A new Claude or Codex thread on an existing conversation's computer (0.16.0+): `shard agents new --on <ref>`,
2
+ // `shard ./ --on <ref>` and `shard agents computers`. The thread joins that VM as a worker (its own branch and
3
+ // worktree, sharing the VM's packages, caches, services, ports and files) or, with --review, as a read-only Claude
4
+ // reviewer. Codex threads are genuine Codex Cloud threads. docs/progress/thread-on-existing-vm.md.
5
+ import { randomUUID } from 'node:crypto';
6
+ import { ShardfluxApiError } from '@shardflux/sdk';
7
+ import { out, str } from "./commands.js";
8
+ import { UsageError } from "./errors.js";
9
+ import { table } from "./format.js";
10
+ import { AgentStateStore } from "./agent-state.js";
11
+ import { normalizeRemote, projectIdentity } from "./project-identity.js";
12
+ import { progress } from "./progress.js";
13
+ import { conversationClient } from "./conversation-commands.js";
14
+ import { conversationName, conversationWorkspace, getConversation, listConversations, localConversations, rememberConversation, validateConversation, waitThreadReady } from "./conversations.js";
15
+ import { syncAgentCredentials } from "./agent-credentials.js";
16
+ import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
17
+ import { promptWithImages, readInitialImages, readInitialPrompt, uploadInitialImages } from "./initial-prompt.js";
18
+ import { recordSeedSnapshot } from "./files-mirror.js";
19
+ import { ensureCodexCloud, printCloudThread, submitCloudInitialPrompt } from "./codex-cloud.js";
20
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+ const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
22
+ /** A conversation still bound to its computer: not done, archived or ended. */
23
+ export const activeConversation = (c) => c.state !== 'done' && c.state !== 'archived' && !c.ended_at;
24
+ /** The project's conversations: its repository's, or (for a folder without a remote) the ones started from it. */
25
+ export function projectConversations(ctx, all, folder) {
26
+ const project = projectIdentity(folder), remote = project.remote ?? `file://${project.folder}`, records = localConversations(ctx);
27
+ const same = (r) => { try {
28
+ return normalizeRemote(r, project.folder) === remote;
29
+ }
30
+ catch {
31
+ return false;
32
+ } };
33
+ return all.filter(c => c.repo_remote ? same(c.repo_remote) : records[c.id]?.folder === project.folder);
34
+ }
35
+ /** The VM's own conversation first (its label), then the oldest thread on it. */
36
+ function host(bound, label) {
37
+ return bound.find(c => c.id === label) ?? [...bound].sort((a, b) => a.created_at.localeCompare(b.created_at))[0];
38
+ }
39
+ const thread = (c) => ({ id: c.id, key: c.key, title: c.title, harness: c.harness, state: c.state, route: c.route,
40
+ role: c.role ?? 'main', join_mode: c.join_mode ?? null, branch: c.branch });
41
+ /** Computers a new thread can start on: each live VM (running or parked, never deleted) of the project's active conversations. */
42
+ export async function listComputers(ctx, folder) {
43
+ const client = conversationClient(ctx);
44
+ const groups = new Map();
45
+ for (const c of projectConversations(ctx, await listConversations(ctx), folder).filter(activeConversation))
46
+ groups.set(c.workspace_id, [...groups.get(c.workspace_id) ?? [], c]);
47
+ const computers = await Promise.all([...groups].map(async ([id, bound]) => {
48
+ let ws;
49
+ try {
50
+ ws = await client.workspaces.get(id);
51
+ }
52
+ catch (error) {
53
+ if (error instanceof ShardfluxApiError && error.status === 404)
54
+ return null;
55
+ throw error;
56
+ }
57
+ const observed = String(ws.data.observed_state ?? '');
58
+ if (ws.data.deleted_at || ['failed', 'deleted', 'deleting'].includes(observed))
59
+ return null;
60
+ const own = host(bound, ws.labels.shard_conversation);
61
+ const ordered = [own, ...bound.filter(c => c !== own).sort((a, b) => a.created_at.localeCompare(b.created_at))];
62
+ const last = bound.map(c => c.last_activity_at ?? c.created_at).sort().at(-1) ?? null;
63
+ return { workspace_id: ws.id, workspace_key: ws.key, state: observed === 'suspended' ? 'suspended' : 'running', last_activity_at: last, conversations: ordered.map(thread) };
64
+ }));
65
+ return computers.filter((c) => c !== null).sort((a, b) => (b.last_activity_at ?? '').localeCompare(a.last_activity_at ?? ''));
66
+ }
67
+ /**
68
+ * The conversation whose computer `--on` names: a conversation id, key or name, or the VM's workspace key or id (then
69
+ * the VM's own conversation, else its oldest live thread).
70
+ */
71
+ export async function resolveOn(ctx, ref, folder) {
72
+ if (!ref.trim())
73
+ throw new UsageError('--on needs a conversation or computer; list them with shard agents computers.');
74
+ let target;
75
+ if (/^conv_[a-z0-9]{26}$/.test(ref))
76
+ target = await getConversation(ctx, ref);
77
+ else {
78
+ const all = await listConversations(ctx);
79
+ const keyed = all.filter(c => c.key === ref);
80
+ if (keyed.length === 1)
81
+ target = keyed[0];
82
+ else if (UUID.test(ref) || ref.includes('/')) {
83
+ const client = conversationClient(ctx);
84
+ let ws = null;
85
+ try {
86
+ ws = UUID.test(ref) ? await client.workspaces.get(ref) : await client.workspaces.findByKey(ref, { includeDeleted: false, signal: ctx.signal });
87
+ }
88
+ catch (error) {
89
+ if (!(error instanceof ShardfluxApiError && error.status === 404))
90
+ throw error;
91
+ }
92
+ if (ws)
93
+ target = host(all.filter(c => c.workspace_id === ws.id && activeConversation(c)), ws.labels.shard_conversation);
94
+ }
95
+ else {
96
+ const named = projectConversations(ctx, all, folder).filter(c => conversationName(c) === ref);
97
+ const live = named.filter(activeConversation);
98
+ if ((live.length || named.length) > 1)
99
+ throw new UsageError(`Several conversations are named ${ref}; use its key or id (shard agents computers lists them).`);
100
+ target = live[0] ?? named[0];
101
+ }
102
+ }
103
+ if (!target)
104
+ throw new UsageError(`No conversation or computer "${ref}" in this project. List them with shard agents computers.`);
105
+ if (!activeConversation(target))
106
+ throw new UsageError(`${target.title || target.key} has ended, so its computer is not available. Start the thread on a new computer, or continue that conversation with shard agents open ${target.id}.`);
107
+ return target;
108
+ }
109
+ /** `git rev-parse HEAD` of the target's worktree in its VM: the new thread starts from its committed work. */
110
+ async function targetHead(ws, target, signal) {
111
+ const head = await ws.cell().exec.run(['git', '-C', target.worktree_path, 'rev-parse', 'HEAD'], { timeoutMs: 30_000, maxOutputBytes: 4096, signal });
112
+ const sha = head.stdout.trim();
113
+ return head.exitCode === 0 && SHA.test(sha) ? sha : target.base_commit;
114
+ }
115
+ /** Records a thread that could never be admitted as discarded, so it never holds the shared computer. */
116
+ async function abandon(ctx, c) {
117
+ try {
118
+ await conversationClient(ctx).conversations.land(c.id, 'discard', { idempotencyKey: `abandon-${c.id}`, signal: ctx.signal });
119
+ }
120
+ catch { /* the API refuses it again on the next try */ }
121
+ }
122
+ /** Starts the thread on `--on`'s computer and opens it like a new conversation of that app. */
123
+ export async function startThreadOn(ctx, values, path, explicitApp) {
124
+ const ref = str(values.on) ?? '';
125
+ for (const flag of ['like', 'in', 'resume', 'new'])
126
+ if (values[flag] !== undefined && !(flag === 'in' && ['terminal', 'codex-app'].includes(String(values.in))))
127
+ throw new UsageError(`--${flag} cannot be combined with --on.`);
128
+ if (values.computer === true)
129
+ throw new UsageError('--computer is a setting of a new computer; a thread with --on uses its computer as it is.');
130
+ const text = await readInitialPrompt(ctx, values);
131
+ const images = await readInitialImages(ctx, values, text);
132
+ const initialPrompt = text === undefined ? undefined : promptWithImages(text, images);
133
+ const project = projectIdentity(path), projectKey = project.remote ?? project.folder, store = new AgentStateStore(ctx.store.dir);
134
+ const chosen = explicitApp ?? str(values.app);
135
+ if (chosen !== undefined && chosen !== 'claude' && chosen !== 'codex')
136
+ throw new UsageError('--app must be claude or codex');
137
+ const app = chosen ?? store.app(projectKey);
138
+ const review = values.review === true;
139
+ if (review && app === 'codex')
140
+ throw new UsageError('--review starts a read-only Claude reviewer. A Codex thread on a computer works on its own branch; omit --review.');
141
+ const name = str(values.name);
142
+ if (name !== undefined && !/^[a-z0-9][a-z0-9-]{0,47}$/.test(name))
143
+ throw new UsageError('--name must match ^[a-z0-9][a-z0-9-]{0,47}$');
144
+ const target = await resolveOn(ctx, ref, path);
145
+ let signedIn = true;
146
+ if (app === 'claude') {
147
+ await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
148
+ signedIn = Boolean(await cloudClaudeLogin(ctx));
149
+ if (!signedIn)
150
+ requireClaudeTerminal(ctx);
151
+ if (!ctx.json && values.detach !== true && (!ctx.io.stdin.isTTY || !ctx.io.stdin.setRawMode))
152
+ throw new UsageError('agent terminal requires a TTY; add --json to start it detached');
153
+ }
154
+ else
155
+ await ensureCodexCloud(ctx);
156
+ progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} on the computer with ${target.title || target.key}…`);
157
+ const ws = await conversationWorkspace(ctx, target);
158
+ const commit = await targetHead(ws, target, ctx.signal);
159
+ const baseBranch = target.base_branch ?? undefined;
160
+ await uploadInitialImages(ws.cell(), images, ctx.signal);
161
+ const response = await conversationClient(ctx).conversations.create({ projectId: target.project_id, repoRemote: target.repo_remote ?? ws.labels.repo_remote ?? '',
162
+ harness: app, route: app === 'codex' ? 'codex-cloud' : 'terminal', mode: 'join', parentId: target.id, ...(review ? { joinMode: 'reviewer' } : {}),
163
+ base: { commit, ...(baseBranch ? { branch: baseBranch } : {}) }, ...(name ? { name } : {}) }, { idempotencyKey: randomUUID(), signal: ctx.signal });
164
+ let c = validateConversation({ ...response.conversation, restore: response.restore });
165
+ rememberConversation(ctx, c, project.folder, false, baseBranch);
166
+ store.save(projectKey, app);
167
+ if (app === 'codex') {
168
+ try {
169
+ await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-${c.id}`, signal: ctx.signal });
170
+ }
171
+ catch (error) {
172
+ await abandon(ctx, c);
173
+ throw error;
174
+ }
175
+ const task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
176
+ // The worktree exists once admission prepared it: record where the thread started for the files shelf.
177
+ await recordSeedSnapshot(ws, c, ctx.signal);
178
+ if (initialPrompt !== undefined)
179
+ await submitCloudInitialPrompt(ctx, c, initialPrompt, ws.key);
180
+ await printCloudThread(ctx, c, task, initialPrompt !== undefined);
181
+ return 0;
182
+ }
183
+ // The computer's guest prepares the worktree and starts the harness; the terminal attaches to that session.
184
+ c = await waitThreadReady(ctx, c);
185
+ if (!review)
186
+ await recordSeedSnapshot(ws, c, ctx.signal);
187
+ if (!signedIn)
188
+ await loginClaudeInWorkspace(ctx, ws);
189
+ const { conversationTerminal } = await import("./conversation-terminal.js");
190
+ return conversationTerminal(ctx, ws, c, projectKey, { ...values, ...(initialPrompt === undefined ? {} : { initialPrompt }) });
191
+ }
192
+ export const THREAD_HANDLERS = {
193
+ async 'agents computers'(ctx, values) {
194
+ const computers = await listComputers(ctx, str(values.path) ?? './');
195
+ const label = (t) => `${t.title || t.key} (${t.harness}, ${t.state.replace('_', ' ')})`;
196
+ out(ctx, computers.length ? table(['COMPUTER', 'STATE', 'THREADS'], computers.map(c => [c.workspace_key, c.state, c.conversations.map(label).join('; ')]))
197
+ : 'No computers to start a thread on. Start a conversation with shard ./ first.\n', { computers });
198
+ return 0;
199
+ },
200
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Where a wait stands (0.16.0+): while a command waits on an operation whose start is queued for capacity, or a call
3
+ * waits for room (the workspace's host making room to wake it, or a free working slot of the plan), one line on stderr
4
+ * says so: the queue position, what the pool is doing (a new host starting, when it is expected) and until when the
5
+ * start holds its place. A line is printed only when it changes; dim on a terminal, plain when piped; never with
6
+ * --json, where stdout stays one document and stderr is left to the error.
7
+ */
8
+ import type { ProgressEvent, ProgressListener, RetryRecord } from '@shardflux/sdk';
9
+ import type { Writer } from './commands.js';
10
+ /** `2026-10-09T21:40:12Z` as `21:40Z` (UTC); the input when it is not a timestamp. */
11
+ export declare function clockZ(iso: string): string;
12
+ /** A queued start in words: `queued for capacity (position 3): a new host is starting, ready by 21:40Z; it holds its place until 21:51Z`. */
13
+ export declare function queueLine(e: Pick<Extract<ProgressEvent, {
14
+ type: 'phase';
15
+ }>, 'queuePosition' | 'scaleOut' | 'expectedReadyBy' | 'deadlineAt'>): string;
16
+ /** A call waiting to be admitted, in words. */
17
+ export declare function admissionLine(r: Pick<RetryRecord, 'reason' | 'queuePosition'>): string;
18
+ /**
19
+ * The client listener that prints where a wait stands. Silent with `json`. A phase other than capacity_pending ends
20
+ * the queue, so a later queue prints again.
21
+ */
22
+ export declare function waitStatusListener(stderr: Writer, json: boolean): ProgressListener;
@@ -0,0 +1,59 @@
1
+ /** `2026-10-09T21:40:12Z` as `21:40Z` (UTC); the input when it is not a timestamp. */
2
+ export function clockZ(iso) {
3
+ const t = Date.parse(iso);
4
+ return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 16)}Z` : iso;
5
+ }
6
+ /** A queued start in words: `queued for capacity (position 3): a new host is starting, ready by 21:40Z; it holds its place until 21:51Z`. */
7
+ export function queueLine(e) {
8
+ const position = e.queuePosition ? ` (position ${e.queuePosition})` : '';
9
+ const pool = e.scaleOut === 'requested'
10
+ ? `: a new host is starting${e.expectedReadyBy ? `, ready by ${clockZ(e.expectedReadyBy)}` : ''}`
11
+ : e.scaleOut === 'clamped'
12
+ ? ': it starts as soon as a host has room'
13
+ : '';
14
+ const until = e.deadlineAt ? `; it holds its place until ${clockZ(e.deadlineAt)}` : '';
15
+ return `queued for capacity${position}${pool}${until}`;
16
+ }
17
+ /** A call waiting to be admitted, in words. */
18
+ export function admissionLine(r) {
19
+ const position = r.queuePosition ? ` (position ${r.queuePosition})` : '';
20
+ if (r.reason === 'host_capacity')
21
+ return `waiting for room on the workspace's host${position}`;
22
+ if (r.reason === 'concurrent_workspaces')
23
+ return 'waiting for a free working slot: every workspace the plan lets work at once is busy';
24
+ return `waiting to be admitted${r.reason ? ` (${r.reason})` : ''}${position}`;
25
+ }
26
+ /**
27
+ * The client listener that prints where a wait stands. Silent with `json`. A phase other than capacity_pending ends
28
+ * the queue, so a later queue prints again.
29
+ */
30
+ export function waitStatusListener(stderr, json) {
31
+ let last = null;
32
+ return (e) => {
33
+ if (json)
34
+ return;
35
+ let line;
36
+ if (e.type === 'retry') {
37
+ if (!e.retry.admission)
38
+ return;
39
+ line = admissionLine(e.retry);
40
+ }
41
+ else if (e.type === 'phase') {
42
+ if (e.phase !== 'capacity_pending') {
43
+ if (e.action !== 'tool')
44
+ last = null;
45
+ return;
46
+ }
47
+ line = queueLine(e);
48
+ }
49
+ else {
50
+ if (e.action !== 'token' && e.action !== 'tool')
51
+ last = null;
52
+ return;
53
+ }
54
+ if (line === last)
55
+ return;
56
+ last = line;
57
+ stderr.write(stderr.isTTY ? `\x1b[2m· ${line}\x1b[0m\n` : `${line}\n`);
58
+ };
59
+ }
@@ -0,0 +1,95 @@
1
+ import type { Ctx, Handler } from './commands.js';
2
+ import type { Conversation, LocalConversation } from './conversations.js';
3
+ import { TwinLedger } from './worktree-twin.js';
4
+ import type { LandingMode, TwinCell } from './worktree-twin.js';
5
+ export interface TwinAgent {
6
+ id: string;
7
+ workspace_id: string;
8
+ state: 'working' | 'waiting' | 'exited';
9
+ since: string;
10
+ }
11
+ export type TwinConversation = Conversation & {
12
+ changes: {
13
+ files: number;
14
+ insertions: number;
15
+ deletions: number;
16
+ commits: number;
17
+ uncommitted: boolean;
18
+ };
19
+ landed: {
20
+ how: string;
21
+ ref: string;
22
+ at: string;
23
+ } | null;
24
+ summary: string;
25
+ name?: string;
26
+ join_mode?: string | null;
27
+ base_branch?: string | null;
28
+ last_activity_at?: string;
29
+ repo_remote?: string;
30
+ };
31
+ /** Its VM is gone: the conversation ended (archived after the retention window, or done). */
32
+ export declare const ended: (c: TwinConversation) => boolean;
33
+ export interface EngineDeps {
34
+ records(): LocalConversation[];
35
+ conversations(projectId: string): Promise<TwinConversation[]>;
36
+ agents(projectId: string): Promise<TwinAgent[]>;
37
+ /** The conversation's VM (woken when suspended), and its release; null when the VM is gone. */
38
+ cell(c: TwinConversation): Promise<{
39
+ cell: TwinCell;
40
+ close(): void;
41
+ } | null>;
42
+ /** A VM of the project that reads the project hub (an awake one, else the project's space reader). */
43
+ hubCell(projectId: string): Promise<{
44
+ cell: TwinCell;
45
+ close(): void;
46
+ }>;
47
+ /** Restores an ended conversation into a fresh VM (so its agent can resolve a conflict). */
48
+ resume(c: TwinConversation): Promise<void>;
49
+ land(c: TwinConversation, action: 'merge' | 'pr', into: string): Promise<void>;
50
+ message(c: TwinConversation, text: string): Promise<void>;
51
+ ledger: TwinLedger;
52
+ stateDir: string;
53
+ device: string;
54
+ log(line: string): void;
55
+ now(): number;
56
+ hub?: string;
57
+ vmTmp?: string;
58
+ quietMs?: number;
59
+ }
60
+ /** A turn ended with committed work only; the agent is not asking anything. */
61
+ export declare function finished(c: TwinConversation, agents: TwinAgent[]): TwinAgent | 'state' | null;
62
+ export declare function eligible(c: TwinConversation, ledger: TwinLedger, now: number): boolean;
63
+ export declare function conflictMessage(into: string, files: string[]): string;
64
+ export declare class TwinEngine {
65
+ #private;
66
+ constructor(deps: EngineDeps);
67
+ /** One pass over every project with a local conversation; returns the suggested wait before the next. */
68
+ tick(signal?: AbortSignal): Promise<number>;
69
+ /** Back online or awake: every conversation's retry backoff is cleared, so the next pass tries all of them. */
70
+ resetBackoff(): void;
71
+ landOne(c: TwinConversation, record: LocalConversation, mode: Exclude<LandingMode, 'off'>, agent: TwinAgent | null, signal?: AbortSignal): Promise<number | undefined>;
72
+ }
73
+ export interface LoopDeps {
74
+ tick(signal: AbortSignal): Promise<number>;
75
+ resetBackoff(): void;
76
+ sleep(ms: number, signal: AbortSignal): Promise<void>;
77
+ /** Wall-clock and monotonic milliseconds: the monotonic clock stands still while the computer sleeps. */
78
+ wall(): number;
79
+ mono(): number;
80
+ /** The computer's network addresses (any change is a network change). */
81
+ network(): string;
82
+ /** Whether the API host answers now (a TCP connect, no request). */
83
+ reachable(): Promise<boolean>;
84
+ log(line: string): void;
85
+ }
86
+ /**
87
+ * Runs the twin's passes. A failing pass (offline) backs off up to 5 minutes, but the wait ends at once when the
88
+ * computer wakes from sleep (the wall clock jumps past the monotonic one), when its network changes, or when the API
89
+ * host answers again (probed every 5 s while failing); every conversation's retry backoff is then cleared and a pass
90
+ * runs immediately. The first pass that succeeds after failures clears them too and runs again at once.
91
+ */
92
+ export declare function runTwinLoop(deps: LoopDeps, signal: AbortSignal): Promise<void>;
93
+ /** The device helper's twin. Nothing here asks the user anything. */
94
+ export declare function runWorktreeTwin(ctx: Ctx, device: string, signal: AbortSignal): Promise<void>;
95
+ export declare const TWIN_HANDLERS: Record<string, Handler>;