@shardflux/cli 0.15.2 → 0.17.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 +144 -14
- package/README.md +92 -7
- package/dist/acp-models.js +5 -1
- package/dist/agent-commands.d.ts +15 -1
- package/dist/agent-commands.js +84 -23
- package/dist/agent-models.d.ts +61 -0
- package/dist/agent-models.js +79 -0
- package/dist/agent-state.d.ts +10 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +13 -3
- package/dist/agent-target-commands.js +129 -23
- package/dist/agent-targets.d.ts +5 -0
- package/dist/agent-targets.js +14 -5
- package/dist/agent-terminal.d.ts +8 -0
- package/dist/agent-terminal.js +35 -2
- package/dist/args.d.ts +5 -1
- package/dist/args.js +74 -11
- package/dist/cli-proxy-forward.d.ts +45 -0
- package/dist/cli-proxy-forward.js +432 -0
- package/dist/cli-proxy-hub.d.ts +273 -0
- package/dist/cli-proxy-hub.js +749 -0
- package/dist/cli-proxy.d.ts +325 -0
- package/dist/cli-proxy.js +1016 -0
- package/dist/codex-cloud.d.ts +3 -0
- package/dist/codex-cloud.js +17 -5
- package/dist/command-center-actions.js +25 -1
- package/dist/commands.d.ts +22 -0
- package/dist/commands.js +54 -14
- package/dist/conversation-commands.js +6 -0
- package/dist/conversation-local.js +2 -2
- package/dist/conversation-stash.js +4 -3
- package/dist/conversation-terminal.d.ts +5 -0
- package/dist/conversation-terminal.js +54 -6
- package/dist/conversations.d.ts +31 -3
- package/dist/conversations.js +53 -10
- package/dist/device-approvals.d.ts +12 -0
- package/dist/device-approvals.js +73 -0
- package/dist/device-helper.d.ts +3 -1
- package/dist/device-helper.js +14 -4
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +12 -2
- package/dist/files-mirror.d.ts +164 -0
- package/dist/files-mirror.js +761 -0
- package/dist/handlers.js +5 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/linux-helper.d.ts +12 -0
- package/dist/linux-helper.js +88 -0
- package/dist/linux-terminal.d.ts +11 -0
- package/dist/linux-terminal.js +42 -0
- package/dist/main.js +38 -8
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +53 -5
- package/dist/registry.d.ts +2 -42
- package/dist/registry.js +6 -90
- package/dist/t3-launch.d.ts +37 -0
- package/dist/t3-launch.js +133 -0
- package/dist/t3-mode.d.ts +121 -0
- package/dist/t3-mode.js +654 -0
- package/dist/t3-phone.d.ts +17 -0
- package/dist/t3-phone.js +15 -0
- package/dist/t3-thread-script.d.ts +18 -0
- package/dist/t3-thread-script.js +245 -0
- package/dist/terminal-onboarding.d.ts +11 -0
- package/dist/terminal-onboarding.js +75 -0
- package/dist/thread-on-computer.d.ts +35 -0
- package/dist/thread-on-computer.js +206 -0
- package/dist/update.d.ts +1 -1
- package/dist/update.js +12 -1
- package/dist/wait-status.d.ts +22 -0
- package/dist/wait-status.js +59 -0
- package/dist/worktree-twin-engine.d.ts +125 -0
- package/dist/worktree-twin-engine.js +615 -0
- package/dist/worktree-twin-land.py +331 -0
- package/dist/worktree-twin.d.ts +313 -0
- package/dist/worktree-twin.js +843 -0
- package/package.json +3 -3
package/dist/conversations.js
CHANGED
|
@@ -7,6 +7,13 @@ 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";
|
|
12
|
+
/**
|
|
13
|
+
* Whether the conversation has ended: its VM was reclaimed, or it was archived or discarded. A finished turn is `done`
|
|
14
|
+
* while its VM still takes the next turn; on Codex Cloud, `done` ends the conversation.
|
|
15
|
+
*/
|
|
16
|
+
export const conversationEnded = (c) => !!c.ended_at || c.state === 'archived' || c.landed?.how === 'discarded' || (c.route === 'codex-cloud' && c.state === 'done');
|
|
10
17
|
export const conversationName = (c) => {
|
|
11
18
|
const name = c.key.split('/').at(-1);
|
|
12
19
|
if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(name) || c.branch !== `shard/${name}`)
|
|
@@ -23,11 +30,12 @@ export function localConversations(ctx) {
|
|
|
23
30
|
const records = new AgentStateStore(ctx.store.dir).read().conversations ?? {};
|
|
24
31
|
return Object.fromEntries(Object.entries(records).filter(([, r]) => r.api_url === ctx.apiUrl));
|
|
25
32
|
}
|
|
26
|
-
export function rememberConversation(ctx, c, folder, handedOff = false) {
|
|
33
|
+
export function rememberConversation(ctx, c, folder, handedOff = false, baseBranch) {
|
|
27
34
|
validateConversation(c);
|
|
28
35
|
new AgentStateStore(ctx.store.dir).update(doc => {
|
|
29
36
|
const records = doc.conversations ?? {};
|
|
30
|
-
|
|
37
|
+
const base = baseBranch ?? records[c.id]?.base_branch;
|
|
38
|
+
records[c.id] = { api_url: ctx.apiUrl, folder, project_id: c.project_id, conversation: c, ...(handedOff ? { handed_off: true } : {}), ...(base ? { base_branch: base } : {}) };
|
|
31
39
|
doc.conversations = records;
|
|
32
40
|
});
|
|
33
41
|
}
|
|
@@ -68,9 +76,36 @@ export async function conversationWorkspace(ctx, c) {
|
|
|
68
76
|
await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
69
77
|
return ws;
|
|
70
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* A thread on an existing computer (a join) is prepared by that computer's guest: its worktree, then its harness. Waits
|
|
81
|
+
* until the guest confirms it (the conversation no longer carries restore instructions), within the wake timeout.
|
|
82
|
+
*/
|
|
83
|
+
export async function waitThreadReady(ctx, c, pollMs = 1000) {
|
|
84
|
+
const deadline = Date.now() + ctx.wakeTimeoutMs;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const current = await getConversation(ctx, c.id);
|
|
87
|
+
if (!current.restore || current.restore.kind !== 'join')
|
|
88
|
+
return current;
|
|
89
|
+
if (current.state === 'done' || current.state === 'archived' || current.ended_at)
|
|
90
|
+
throw new UsageError('This thread ended before it started.');
|
|
91
|
+
if (Date.now() >= deadline)
|
|
92
|
+
throw new UsageError(`The computer is still preparing this thread; open it with shard agents open ${c.id} in a moment.`);
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
const timer = setTimeout(done, Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
95
|
+
function done() { ctx.signal.removeEventListener('abort', abort); resolve(); }
|
|
96
|
+
function abort() { clearTimeout(timer); reject(ctx.signal.reason ?? new Error('interrupted')); }
|
|
97
|
+
ctx.signal.addEventListener('abort', abort, { once: true });
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
71
101
|
export async function restoreConversationBinding(ctx, c, ws) {
|
|
72
102
|
if (!c.restore || c.restore.kind === 'new')
|
|
73
103
|
return;
|
|
104
|
+
// Threads on an existing computer (0.16.0+): its guest prepares them, never this command.
|
|
105
|
+
if (c.restore.kind === 'join') {
|
|
106
|
+
await waitThreadReady(ctx, c);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
74
109
|
const cell = ws.cell();
|
|
75
110
|
if (c.restore.kind === 'join' && c.restore.read_only === true) {
|
|
76
111
|
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 });
|
|
@@ -86,32 +121,38 @@ export async function restoreConversationBinding(ctx, c, ws) {
|
|
|
86
121
|
if (result.exitCode !== 0)
|
|
87
122
|
throw new Error(`Conversation restore failed: ${result.stderr.trim()}`);
|
|
88
123
|
}
|
|
89
|
-
export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID()) {
|
|
124
|
+
export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}) {
|
|
90
125
|
const project = projectIdentity(folder);
|
|
91
126
|
await ensureProjectGit(ctx, project.folder);
|
|
92
127
|
// Both the conversation API and its legacy workspace fallback need a usable local HEAD.
|
|
93
128
|
await conversationClient(ctx).conversations.list({ limit: 1 });
|
|
94
129
|
const git = gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal);
|
|
95
|
-
const
|
|
130
|
+
const head = await git(['rev-parse', 'HEAD']);
|
|
96
131
|
const branch = await git(['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => undefined);
|
|
132
|
+
// Uncommitted edits ride along as the twin snapshot the conversation starts from (worktree-twin.ts).
|
|
133
|
+
const seed = await twinSeed(ctx.store.dir, project.folder, head, ctx.store.profile(ctx.apiUrl).project_id).catch(() => ({ commit: head, snapshot: false }));
|
|
134
|
+
const commit = seed.commit;
|
|
97
135
|
const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
|
|
98
|
-
base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
|
|
136
|
+
base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
|
|
99
137
|
const c = validateConversation({ ...response.conversation, restore: response.restore });
|
|
100
138
|
allocated?.(response.workspace);
|
|
101
|
-
|
|
139
|
+
rememberTwinSeed(ctx.store.dir, c.id, project.folder, seed, branch);
|
|
140
|
+
rememberConversation(ctx, c, project.folder, false, branch);
|
|
102
141
|
return c;
|
|
103
142
|
}
|
|
104
|
-
export async function createConversation(ctx, folder, harness, route, name) {
|
|
143
|
+
export async function createConversation(ctx, folder, harness, route, name, choice = {}) {
|
|
105
144
|
const project = projectIdentity(folder);
|
|
106
|
-
const c = await createConversationRecord(ctx, project.folder, harness, route, name);
|
|
145
|
+
const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice);
|
|
107
146
|
try {
|
|
108
147
|
const ws = await conversationWorkspace(ctx, c);
|
|
109
148
|
// This is a one-time seed. A conversation never watches or writes the owner's checkout.
|
|
110
149
|
await seedAgentRepository(ctx, ws, project.folder, { root: c.worktree_path, populate: true });
|
|
111
150
|
const cell = ws.cell();
|
|
112
|
-
|
|
151
|
+
await sendTwinSeed(ctx.store.dir, cell, c, project.folder, ctx.signal);
|
|
152
|
+
const result = await cell.exec.run(twinCheckoutArgv(ctx.store.dir, c), { timeoutMs: 30_000, signal: ctx.signal });
|
|
113
153
|
if (result.exitCode !== 0)
|
|
114
154
|
throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
|
|
155
|
+
await recordSeedSnapshot(ws, c, ctx.signal);
|
|
115
156
|
return { conversation: c, workspace: ws };
|
|
116
157
|
}
|
|
117
158
|
catch (error) {
|
|
@@ -127,8 +168,10 @@ export async function createConversation(ctx, folder, harness, route, name) {
|
|
|
127
168
|
}
|
|
128
169
|
}
|
|
129
170
|
export async function resumeConversation(ctx, c) {
|
|
130
|
-
if (c
|
|
171
|
+
if (!conversationEnded(c))
|
|
131
172
|
return getConversation(ctx, c.id);
|
|
132
173
|
const response = await conversationClient(ctx).conversations.resume(c.id, { idempotencyKey: randomUUID(), signal: ctx.signal });
|
|
133
174
|
return validateConversation({ ...response.conversation, restore: response.restore });
|
|
134
175
|
}
|
|
176
|
+
/** The model and effort a conversation's harness starts with, as its record keeps them. */
|
|
177
|
+
export const recordedChoice = (c) => ({ ...(c.model ? { model: c.model } : {}), ...(c.effort ? { effort: c.effort } : {}) });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DeviceApproval, DeviceChoice } from './device-bridge.js';
|
|
2
|
+
import type { Ctx, Handler } from './commands.js';
|
|
3
|
+
type Pending = DeviceApproval & {
|
|
4
|
+
nonce: string;
|
|
5
|
+
pid: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function pendingDeviceApprovals(dir: string): Pending[];
|
|
8
|
+
export declare function answerDeviceApproval(dir: string, id: string, choice: DeviceChoice): void;
|
|
9
|
+
/** Local user-owned files carry approvals; cloud data can never answer its own request. */
|
|
10
|
+
export declare function askLocalDeviceApproval(ctx: Ctx, approval: DeviceApproval): Promise<DeviceChoice>;
|
|
11
|
+
export declare const DEVICE_HANDLERS: Record<string, Handler>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { writePrivateFile } from "./credentials.js";
|
|
7
|
+
import { UsageError } from "./errors.js";
|
|
8
|
+
import { json } from "./format.js";
|
|
9
|
+
const paths = (dir, id) => {
|
|
10
|
+
if (!/^[a-zA-Z0-9_-]{1,96}$/.test(id))
|
|
11
|
+
throw new UsageError('Invalid device request id.');
|
|
12
|
+
return { pending: join(dir, 'device-approvals', `${id}.json`), answer: join(dir, 'device-answers', `${id}.json`) };
|
|
13
|
+
};
|
|
14
|
+
export function pendingDeviceApprovals(dir) {
|
|
15
|
+
let names;
|
|
16
|
+
try {
|
|
17
|
+
names = readdirSync(join(dir, 'device-approvals'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
return names.filter(n => n.endsWith('.json')).flatMap(n => {
|
|
23
|
+
try {
|
|
24
|
+
const p = JSON.parse(readFileSync(paths(dir, n.slice(0, -5)).pending, 'utf8'));
|
|
25
|
+
process.kill(p.pid, 0);
|
|
26
|
+
return [p];
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function answerDeviceApproval(dir, id, choice) {
|
|
34
|
+
const pending = pendingDeviceApprovals(dir).find(p => p.request_id === id);
|
|
35
|
+
if (!pending)
|
|
36
|
+
throw new UsageError('That request is no longer waiting for approval. Run shard device requests.');
|
|
37
|
+
writePrivateFile(join(dir, 'device-answers'), paths(dir, id).answer, JSON.stringify({ nonce: pending.nonce, choice }));
|
|
38
|
+
}
|
|
39
|
+
/** Local user-owned files carry approvals; cloud data can never answer its own request. */
|
|
40
|
+
export async function askLocalDeviceApproval(ctx, approval) {
|
|
41
|
+
const file = paths(ctx.store.dir, approval.request_id), nonce = randomUUID();
|
|
42
|
+
rmSync(file.answer, { force: true });
|
|
43
|
+
writePrivateFile(join(ctx.store.dir, 'device-approvals'), file.pending, JSON.stringify({ ...approval, nonce, pid: process.pid }));
|
|
44
|
+
spawnSync('notify-send', ['--', 'Shardflux approval', `${approval.summary}\nReview: shard device requests`], { env: ctx.io.env, stdio: 'ignore', timeout: 2000 });
|
|
45
|
+
try {
|
|
46
|
+
while (!ctx.signal.aborted) {
|
|
47
|
+
try {
|
|
48
|
+
const answer = JSON.parse(readFileSync(file.answer, 'utf8'));
|
|
49
|
+
if (answer.nonce === nonce && ['once', 'always', 'deny'].includes(answer.choice))
|
|
50
|
+
return answer.choice;
|
|
51
|
+
}
|
|
52
|
+
catch { /* wait for the local CLI */ }
|
|
53
|
+
await delay(200, undefined, { signal: ctx.signal }).catch(() => undefined);
|
|
54
|
+
}
|
|
55
|
+
return 'deny';
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
rmSync(file.pending, { force: true });
|
|
59
|
+
rmSync(file.answer, { force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export const DEVICE_HANDLERS = {
|
|
63
|
+
'device requests'(ctx) {
|
|
64
|
+
const requests = pendingDeviceApprovals(ctx.store.dir);
|
|
65
|
+
ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}\n shard device approve ${r.request_id} | shard device deny ${r.request_id}\n`).join('') : 'No device requests waiting for approval.\n');
|
|
66
|
+
return Promise.resolve(0);
|
|
67
|
+
},
|
|
68
|
+
...Object.fromEntries(['approve', 'deny'].map(action => [`device ${action}`, (ctx, _values, [id]) => {
|
|
69
|
+
answerDeviceApproval(ctx.store.dir, id, action === 'approve' ? 'once' : 'deny');
|
|
70
|
+
ctx.io.stdout.write(ctx.json ? json({ request_id: id, choice: action }) : `Device request ${id}: ${action === 'approve' ? 'approved once' : 'denied'}.\n`);
|
|
71
|
+
return Promise.resolve(0);
|
|
72
|
+
}])),
|
|
73
|
+
};
|
package/dist/device-helper.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { Ctx } from './commands.js';
|
|
2
2
|
/** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
|
|
3
|
-
export declare function runDeviceHelper(ctx: Ctx
|
|
3
|
+
export declare function runDeviceHelper(ctx: Ctx, options?: {
|
|
4
|
+
service?: boolean;
|
|
5
|
+
}): Promise<number>;
|
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,13 +11,15 @@ 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";
|
|
15
|
+
import { askLocalDeviceApproval } from "./device-approvals.js";
|
|
13
16
|
/** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
|
|
14
|
-
export async function runDeviceHelper(ctx) {
|
|
17
|
+
export async function runDeviceHelper(ctx, options = {}) {
|
|
15
18
|
const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
|
|
16
19
|
ctx = { ...ctx, signal };
|
|
17
20
|
const pending = new Map();
|
|
18
21
|
const inputSource = Readable.from(ctx.io.stdin, { objectMode: false, signal });
|
|
19
|
-
const input = (async () => {
|
|
22
|
+
const input = options.service ? Promise.resolve() : (async () => {
|
|
20
23
|
let tail = '';
|
|
21
24
|
for await (const bytes of inputSource) {
|
|
22
25
|
tail += Buffer.from(bytes).toString();
|
|
@@ -39,6 +42,8 @@ export async function runDeviceHelper(ctx) {
|
|
|
39
42
|
const acp = await askConnectedAcp(ctx.store.dir, approval, signal);
|
|
40
43
|
if (acp)
|
|
41
44
|
return acp;
|
|
45
|
+
if (options.service)
|
|
46
|
+
return askLocalDeviceApproval(ctx, approval);
|
|
42
47
|
return new Promise(resolve => {
|
|
43
48
|
const denied = () => { pending.delete(approval.request_id); resolve('deny'); };
|
|
44
49
|
signal.addEventListener('abort', denied, { once: true });
|
|
@@ -50,7 +55,7 @@ export async function runDeviceHelper(ctx) {
|
|
|
50
55
|
ctx.io.stdout.write(JSON.stringify({ type: 'approval_resolved', request_id: approval.request_id }) + '\n'); });
|
|
51
56
|
};
|
|
52
57
|
const working = new Map();
|
|
53
|
-
const mirrored = new ConversationMirrorState();
|
|
58
|
+
const mirrored = new ConversationMirrorState(), files = new FilesMirrorState();
|
|
54
59
|
const deviceFile = join(ctx.store.dir, 'device-id');
|
|
55
60
|
let deviceId;
|
|
56
61
|
try {
|
|
@@ -62,15 +67,19 @@ export async function runDeviceHelper(ctx) {
|
|
|
62
67
|
}
|
|
63
68
|
const executor = new DeviceExecutor(ctx, ask, deviceId);
|
|
64
69
|
const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
|
|
65
|
-
let mirroring;
|
|
70
|
+
let mirroring, twin;
|
|
66
71
|
try {
|
|
67
72
|
return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
|
|
73
|
+
// Finished conversations land in their local branches (worktree-twin-engine.ts); its own loop and backoff.
|
|
74
|
+
twin = runWorktreeTwin(ctx, deviceId, signal).catch(report);
|
|
68
75
|
// A slow or failed transcript copy must not delay heartbeats, request polling or approvals.
|
|
69
76
|
mirroring = (async () => {
|
|
70
77
|
let failures = 0;
|
|
71
78
|
const reported = new Set();
|
|
72
79
|
while (!signal.aborted) {
|
|
73
80
|
let wait = 1000;
|
|
81
|
+
// Committed files of every conversation, under ~/Shardflux; its own interval and backoff.
|
|
82
|
+
await mirrorFilesOnce(ctx, files);
|
|
74
83
|
try {
|
|
75
84
|
await mirrorProjectConversations(ctx, mirrored);
|
|
76
85
|
failures = 0;
|
|
@@ -124,6 +133,7 @@ export async function runDeviceHelper(ctx) {
|
|
|
124
133
|
await executor.close();
|
|
125
134
|
await Promise.all([...working.values()]);
|
|
126
135
|
await mirroring;
|
|
136
|
+
await twin;
|
|
127
137
|
await input.catch(() => undefined);
|
|
128
138
|
}
|
|
129
139
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -114,6 +114,8 @@ export declare class ExecSessionRunningError extends HintedError {
|
|
|
114
114
|
export interface ErrorInfo {
|
|
115
115
|
code: string;
|
|
116
116
|
message: string;
|
|
117
|
+
/** A refusal `shard` detects itself (CodedError, 0.17.0+): the next step, as plain text (the `hint:` line). */
|
|
118
|
+
fix?: string;
|
|
117
119
|
status?: number;
|
|
118
120
|
request_id?: string;
|
|
119
121
|
retryable?: boolean;
|
|
@@ -121,6 +123,11 @@ export interface ErrorInfo {
|
|
|
121
123
|
details?: Record<string, unknown>;
|
|
122
124
|
source?: 'api' | 'cell' | 'unknown';
|
|
123
125
|
last_state?: string;
|
|
126
|
+
/** A wait that timed out (0.16.0+): the operation's last state_reason. */
|
|
127
|
+
last_reason?: string;
|
|
128
|
+
/** A wait that timed out on a queued start (0.16.0+): its deadline (RFC 3339) and place in the queue. */
|
|
129
|
+
deadline_at?: string;
|
|
130
|
+
queue_position?: number;
|
|
124
131
|
operation?: unknown;
|
|
125
132
|
/** A file-first execution the error is about (failed, lost, still running, or left running by Ctrl-C). */
|
|
126
133
|
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) {
|
|
@@ -231,7 +241,7 @@ export function describeError(err) {
|
|
|
231
241
|
if (err instanceof GitError)
|
|
232
242
|
return { code: 'git_error', message: err.message, retryable: false };
|
|
233
243
|
if (err instanceof CodedError)
|
|
234
|
-
return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}) };
|
|
244
|
+
return { code: err.code, message: err.message, retryable: false, ...(err.hint ? { fix: err.hint } : {}), ...(err.details ? { details: err.details } : {}) };
|
|
235
245
|
if (err instanceof CheckoutTimeoutError) {
|
|
236
246
|
return { code: 'timeout', message: `${err.message}; the checkout stays open (pay at its URL, then check with: shard billing status)`, last_state: err.checkout.status, details: { checkout_id: err.checkout.id, status: err.checkout.status, url: err.checkout.url } };
|
|
237
247
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Workspace } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx } from './commands.js';
|
|
3
|
+
import type { Conversation, LocalConversation } from './conversations.js';
|
|
4
|
+
/**
|
|
5
|
+
* Files across conversations on the Mac (docs/progress/cross-conversation-files.md): a read-only folder per
|
|
6
|
+
* conversation, `~/Shardflux/<repository>/<base branch>/<conversation>/`, holding what that conversation committed
|
|
7
|
+
* relative to its fork point. Git (the project hub) is the only channel; the user's own checkouts are never written.
|
|
8
|
+
* Files above the per-file limit, or past the conversation's total, are placeholders until `shard files pull`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const HUB_PATH = "/shardflux/project/git/hub.git";
|
|
11
|
+
export declare const MANIFEST = ".shardflux-manifest.json";
|
|
12
|
+
export declare const PLACEHOLDER = ".placeholder.txt";
|
|
13
|
+
export declare const FILES_CONFIG = ".shardflux/files.yaml";
|
|
14
|
+
export declare const DEFAULT_MAX_FILE: number;
|
|
15
|
+
export declare const DEFAULT_MAX_CONVERSATION: number;
|
|
16
|
+
export type MirrorConversation = Conversation & {
|
|
17
|
+
name?: string;
|
|
18
|
+
join_mode?: string | null;
|
|
19
|
+
base_branch?: string | null;
|
|
20
|
+
repo_remote?: string;
|
|
21
|
+
landed?: {
|
|
22
|
+
how: string;
|
|
23
|
+
ref?: string;
|
|
24
|
+
at?: string;
|
|
25
|
+
} | null;
|
|
26
|
+
};
|
|
27
|
+
export interface ManifestFile {
|
|
28
|
+
path: string;
|
|
29
|
+
oid: string;
|
|
30
|
+
size: number;
|
|
31
|
+
mode: string;
|
|
32
|
+
state: 'present' | 'placeholder' | 'submodule' | 'symlink';
|
|
33
|
+
reason?: 'file_limit' | 'conversation_limit';
|
|
34
|
+
fetched?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface Manifest {
|
|
37
|
+
version: 1;
|
|
38
|
+
conversation_id: string;
|
|
39
|
+
project_id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
title: string;
|
|
42
|
+
repository: string;
|
|
43
|
+
branch: string;
|
|
44
|
+
base_branch: string | null;
|
|
45
|
+
commit: string;
|
|
46
|
+
fork_point: string;
|
|
47
|
+
seed?: string;
|
|
48
|
+
max_file_bytes: number;
|
|
49
|
+
max_conversation_bytes: number;
|
|
50
|
+
files: ManifestFile[];
|
|
51
|
+
deleted: string[];
|
|
52
|
+
landed?: {
|
|
53
|
+
how: string;
|
|
54
|
+
ref: string;
|
|
55
|
+
at: string;
|
|
56
|
+
note: string;
|
|
57
|
+
};
|
|
58
|
+
updated_at: string;
|
|
59
|
+
}
|
|
60
|
+
export interface Limits {
|
|
61
|
+
maxFile: number;
|
|
62
|
+
maxConversation: number;
|
|
63
|
+
}
|
|
64
|
+
export interface Change {
|
|
65
|
+
status: string;
|
|
66
|
+
mode: string;
|
|
67
|
+
oid: string;
|
|
68
|
+
path: string;
|
|
69
|
+
}
|
|
70
|
+
export declare function filesRoot(env: Record<string, string | undefined>): string;
|
|
71
|
+
/** A base branch as one path component: slashes and other separators become "-". */
|
|
72
|
+
export declare function branchFolder(branch: string | null | undefined): string;
|
|
73
|
+
/** A human name as one Finder path component. */
|
|
74
|
+
export declare function humanFolder(text: string, fallback: string): string;
|
|
75
|
+
export declare function repositoryName(identity: string): string;
|
|
76
|
+
export declare function parseFilesConfig(text: string | undefined): Limits;
|
|
77
|
+
export declare function parseDiffTree(bytes: Buffer): Change[];
|
|
78
|
+
export declare function safeRelative(path: string): boolean;
|
|
79
|
+
/** Smallest first while the conversation's total stays within its limit; fetched files stay while unchanged. */
|
|
80
|
+
export declare function planFiles(files: ManifestFile[], limits: Limits, prev: Map<string, ManifestFile>): ManifestFile[];
|
|
81
|
+
export declare function humanBytes(n: number): string;
|
|
82
|
+
export declare function placeholderText(m: Manifest, f: ManifestFile): string;
|
|
83
|
+
export interface GitResult {
|
|
84
|
+
code: number;
|
|
85
|
+
stdout: Buffer;
|
|
86
|
+
stderr: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Read-only git against the project hub. `blobs` delivers `git cat-file --batch` output for the given ids, any number
|
|
90
|
+
* of files at once. (The hub's upload-pack, reached through the platform Git bridge, serves advertised refs only, so a
|
|
91
|
+
* fetch of single blobs by id is refused; this needs no hub configuration.)
|
|
92
|
+
*/
|
|
93
|
+
export interface Hub {
|
|
94
|
+
git(args: string[], stdin?: string): Promise<GitResult>;
|
|
95
|
+
blobs(oids: string[], onData: (chunk: Uint8Array) => void): Promise<void>;
|
|
96
|
+
close(): void;
|
|
97
|
+
}
|
|
98
|
+
/** A hub on this machine (tests). */
|
|
99
|
+
export declare function localHub(path: string): Hub;
|
|
100
|
+
/** The project hub through a ready VM of the project: git runs there, file contents stream back over the same exec API. */
|
|
101
|
+
export declare function cellHub(ws: Workspace, signal: AbortSignal): Hub;
|
|
102
|
+
/** Copy files' contents from the hub into root, in one stream. */
|
|
103
|
+
export declare function copyFromHub(hub: Hub, root: string, files: ManifestFile[]): Promise<void>;
|
|
104
|
+
export interface PlaceholderEntry {
|
|
105
|
+
conversation_id: string;
|
|
106
|
+
conversation: string;
|
|
107
|
+
title: string;
|
|
108
|
+
folder: string;
|
|
109
|
+
path: string;
|
|
110
|
+
size: number;
|
|
111
|
+
local_path: string;
|
|
112
|
+
}
|
|
113
|
+
export declare class FilesMirrorState {
|
|
114
|
+
readonly revisions: Map<string, string>;
|
|
115
|
+
retryAt: number;
|
|
116
|
+
failures: number;
|
|
117
|
+
listedAt: number;
|
|
118
|
+
readonly reported: Set<string>;
|
|
119
|
+
}
|
|
120
|
+
/** The repository a conversation belongs to: its normalized remote; worktrees of an origin-less repository share its Git common directory. */
|
|
121
|
+
export declare function repositoryIdentity(c: MirrorConversation, local?: LocalConversation): {
|
|
122
|
+
id: string;
|
|
123
|
+
name: string;
|
|
124
|
+
};
|
|
125
|
+
export declare class FilesMirror {
|
|
126
|
+
#private;
|
|
127
|
+
readonly root: string;
|
|
128
|
+
readonly ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'signal'>;
|
|
129
|
+
readonly connect: (projectId: string, all: MirrorConversation[]) => Promise<Hub>;
|
|
130
|
+
constructor(ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'signal'>, connect: (projectId: string, all: MirrorConversation[]) => Promise<Hub>, root?: string);
|
|
131
|
+
get stateFile(): string;
|
|
132
|
+
get indexFile(): string;
|
|
133
|
+
/** One pass: only conversations whose API record moved touch the hub. */
|
|
134
|
+
sync(all: MirrorConversation[], state?: FilesMirrorState): Promise<void>;
|
|
135
|
+
/** Find a mirrored conversation by id, name or folder name. */
|
|
136
|
+
find(ref: string): {
|
|
137
|
+
rel: string;
|
|
138
|
+
manifest: Manifest;
|
|
139
|
+
};
|
|
140
|
+
/** Download one listed file, whatever its size. */
|
|
141
|
+
pull(ref: string, path: string, all: () => Promise<MirrorConversation[]>): Promise<{
|
|
142
|
+
conversation: string;
|
|
143
|
+
path: string;
|
|
144
|
+
local_path: string;
|
|
145
|
+
bytes: number;
|
|
146
|
+
already: boolean;
|
|
147
|
+
}>;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The seed snapshot (sh; $1 repository, $2 conversation name, $3 hub): HEAD plus the working tree the conversation was
|
|
151
|
+
* seeded with, committed in the conversation's VM with a temporary index (never on this computer), kept per worktree
|
|
152
|
+
* as refs/worktree/shard/seed and in the hub as refs/shard/seed/<name>. Mirrors leave out what it already contains,
|
|
153
|
+
* so the user's own uncommitted edits never show as the conversation's change. Threads on one computer are worktrees
|
|
154
|
+
* of one repository (0.16.0+), so the seed is per worktree; the shared refs/shard/seed of earlier seeds is read, and
|
|
155
|
+
* still written, only in the repository's main worktree.
|
|
156
|
+
*/
|
|
157
|
+
export declare const SEED_SNAPSHOT = "set -eu\ncd \"$1\"\nmain=0; [ \"$(git rev-parse --absolute-git-dir)\" = \"$(git rev-parse --path-format=absolute --git-common-dir)\" ] && main=1\nif git rev-parse -q --verify refs/worktree/shard/seed >/dev/null; then seed=$(git rev-parse refs/worktree/shard/seed)\nelif [ \"$main\" = 1 ] && git rev-parse -q --verify refs/shard/seed >/dev/null; then seed=$(git rev-parse refs/shard/seed); else\nidx=$(mktemp); trap 'rm -f \"$idx\"' EXIT\ncp \"$(git rev-parse --path-format=absolute --git-path index)\" \"$idx\" 2>/dev/null || { rm -f \"$idx\"; GIT_INDEX_FILE=\"$idx\" git read-tree HEAD; }\nGIT_INDEX_FILE=\"$idx\" git add -A\ntree=$(GIT_INDEX_FILE=\"$idx\" git write-tree)\nif [ \"$tree\" = \"$(git rev-parse 'HEAD^{tree}')\" ]; then seed=$(git rev-parse HEAD); else\nseed=$(GIT_AUTHOR_NAME=Shardflux GIT_AUTHOR_EMAIL=seed@shardflux.dev GIT_COMMITTER_NAME=Shardflux GIT_COMMITTER_EMAIL=seed@shardflux.dev git commit-tree \"$tree\" -p HEAD -m \"Shardflux: the working tree this conversation started from\"); fi\ngit update-ref refs/worktree/shard/seed \"$seed\"\nif [ \"$main\" = 1 ]; then git update-ref refs/shard/seed \"$seed\"; fi\nfi\nif [ -d \"$3\" ]; then git -c core.hooksPath=/dev/null push -q \"$3\" \"+$seed:refs/shard/seed/$2\" >/dev/null 2>&1 || true; fi\nprintf '%s\n' \"$seed\"\n";
|
|
158
|
+
/** Best effort: without a snapshot the folder also lists the start state's own edits. */
|
|
159
|
+
export declare function recordSeedSnapshot(ws: Pick<Workspace, 'cell'>, c: Pick<Conversation, 'worktree_path' | 'branch'>, signal?: AbortSignal): Promise<string | null>;
|
|
160
|
+
/** A ready VM of the project (never woken), else the project's space reader, woken when needed. */
|
|
161
|
+
export declare function workspaceHub(ctx: Ctx): (projectId: string, all: MirrorConversation[]) => Promise<Hub>;
|
|
162
|
+
export declare function listMirrorConversations(ctx: Ctx): Promise<MirrorConversation[]>;
|
|
163
|
+
/** The device helper's pass: list every few seconds; touch the hub only for conversations whose record moved. */
|
|
164
|
+
export declare function mirrorFilesOnce(ctx: Ctx, state: FilesMirrorState, intervalMs?: number): Promise<void>;
|