@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.
- package/CHANGELOG.md +83 -0
- package/README.md +50 -3
- package/dist/acp-models.js +5 -1
- package/dist/agent-commands.d.ts +8 -2
- package/dist/agent-commands.js +23 -11
- package/dist/agent-target-commands.d.ts +7 -2
- package/dist/agent-target-commands.js +12 -7
- package/dist/agent-targets.d.ts +3 -0
- package/dist/agent-targets.js +4 -1
- package/dist/agent-terminal.d.ts +8 -0
- package/dist/agent-terminal.js +35 -2
- package/dist/args.d.ts +1 -1
- package/dist/args.js +29 -3
- package/dist/codex-cloud.d.ts +2 -0
- package/dist/codex-cloud.js +2 -0
- package/dist/command-center-actions.js +9 -0
- package/dist/commands.d.ts +23 -1
- package/dist/commands.js +54 -14
- package/dist/conversation-commands.js +6 -0
- package/dist/conversation-terminal.js +10 -1
- package/dist/conversations.d.ts +13 -1
- package/dist/conversations.js +41 -5
- package/dist/device-helper.js +9 -2
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -1
- package/dist/files-mirror.d.ts +164 -0
- package/dist/files-mirror.js +761 -0
- package/dist/handlers.js +3 -1
- package/dist/main.js +39 -14
- package/dist/menubar.d.ts +57 -0
- package/dist/menubar.js +264 -45
- package/dist/registry.d.ts +8 -0
- package/dist/registry.js +18 -0
- package/dist/thread-on-computer.d.ts +35 -0
- package/dist/thread-on-computer.js +200 -0
- package/dist/update-check.d.ts +9 -1
- package/dist/update-check.js +55 -17
- package/dist/update.d.ts +2 -0
- package/dist/update.js +26 -8
- package/dist/wait-status.d.ts +22 -0
- package/dist/wait-status.js +59 -0
- package/dist/worktree-twin-engine.d.ts +95 -0
- package/dist/worktree-twin-engine.js +402 -0
- package/dist/worktree-twin-land.py +331 -0
- package/dist/worktree-twin.d.ts +296 -0
- package/dist/worktree-twin.js +718 -0
- 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
|
+
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', '
|
|
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('--');
|
package/dist/codex-cloud.d.ts
CHANGED
|
@@ -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 = {
|
package/dist/codex-cloud.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -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 (
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/conversations.d.ts
CHANGED
|
@@ -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<{
|
package/dist/conversations.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/device-helper.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|