@shardflux/cli 0.18.0 → 0.19.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 +96 -1
- package/README.md +28 -1
- package/dist/account-args.js +1 -0
- package/dist/agent-commands.d.ts +1 -0
- package/dist/agent-commands.js +15 -5
- package/dist/agent-config-files.d.ts +16 -2
- package/dist/agent-config-files.js +48 -61
- package/dist/agent-config-sync.d.ts +12 -1
- package/dist/agent-config-sync.js +228 -54
- package/dist/agent-repository.d.ts +39 -9
- package/dist/agent-repository.js +309 -92
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +2 -0
- package/dist/agent-target-commands.js +55 -18
- package/dist/args.d.ts +1 -1
- package/dist/args.js +16 -4
- package/dist/command-center-actions.js +31 -5
- package/dist/conversation-commands.js +6 -1
- package/dist/conversation-router.js +7 -11
- package/dist/conversation-stash.js +64 -18
- package/dist/conversation-terminal.d.ts +11 -1
- package/dist/conversation-terminal.js +28 -7
- package/dist/conversations.d.ts +17 -3
- package/dist/conversations.js +58 -6
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/files-mirror.d.ts +1 -9
- package/dist/files-mirror.js +19 -3
- package/dist/format.js +37 -2
- package/dist/handlers.js +3 -1
- package/dist/local-sessions.d.ts +60 -0
- package/dist/local-sessions.js +445 -0
- package/dist/main.js +12 -0
- package/dist/move.d.ts +44 -0
- package/dist/move.js +248 -0
- package/dist/moved-sessions.d.ts +33 -0
- package/dist/moved-sessions.js +91 -0
- package/dist/onboarding.d.ts +18 -3
- package/dist/onboarding.js +72 -46
- package/dist/project-sync-manifest.d.ts +31 -3
- package/dist/project-sync-manifest.js +192 -108
- package/dist/project-sync-transfer.d.ts +50 -4
- package/dist/project-sync-transfer.js +404 -106
- package/dist/project-sync.js +30 -33
- package/dist/seed-phases.d.ts +29 -0
- package/dist/seed-phases.js +83 -0
- package/dist/t3-launch.js +3 -1
- package/dist/t3-mode.d.ts +7 -2
- package/dist/t3-mode.js +39 -5
- package/dist/thread-on-computer.js +4 -0
- package/dist/worktree-twin.d.ts +2 -6
- package/dist/worktree-twin.js +16 -51
- package/package.json +5 -5
package/dist/project-sync.js
CHANGED
|
@@ -4,8 +4,8 @@ import { lstat } from 'node:fs/promises';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { AgentStateStore } from "./agent-state.js";
|
|
6
6
|
import { projectWorkspacePath, seedAgentRepository } from "./agent-repository.js";
|
|
7
|
-
import { contentIdentity, diffManifests, excludedSyncPath, ignoredProjectPaths, localManifest,
|
|
8
|
-
import {
|
|
7
|
+
import { contentIdentity, diffManifests, excludedSyncPath, ignoredProjectPaths, localManifest, parseManifest } from "./project-sync-manifest.js";
|
|
8
|
+
import { copyLocalSyncFile, pullRemoteFile, pushLocalFiles, remoteManifest } from "./project-sync-transfer.js";
|
|
9
9
|
export { projectWorkspacePath } from "./agent-repository.js";
|
|
10
10
|
/** L8 can read these reports without creating a watcher or touching a VM. */
|
|
11
11
|
export function projectSyncConflicts(dir, key) {
|
|
@@ -22,7 +22,7 @@ export function projectReconciler(options) {
|
|
|
22
22
|
const run = async () => {
|
|
23
23
|
signal?.throwIfAborted();
|
|
24
24
|
const localRaw = await localManifest(folder, signal, cache, Object.keys(state.common));
|
|
25
|
-
const remoteRaw = await cell
|
|
25
|
+
const remoteRaw = await remoteManifest(cell, root, [...new Set([...Object.keys(state.common), ...localRaw.entries.map((entry) => entry.path)])], signal);
|
|
26
26
|
const localPaths = new Set(localRaw.entries.map((entry) => entry.path));
|
|
27
27
|
const alreadySkipped = new Set(localRaw.skipped.map((entry) => entry.path));
|
|
28
28
|
for (const path of await ignoredProjectPaths(folder, remoteRaw.entries.map((entry) => entry.path).filter((path) => !localPaths.has(path) && !alreadySkipped.has(path)), signal))
|
|
@@ -36,24 +36,10 @@ export function projectReconciler(options) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
const changes = diffManifests(local, remote, state.common, Date.now(), blocked);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
if (batch.size === 0)
|
|
44
|
-
return;
|
|
45
|
-
await applyRemoteSyncBatch(cell, root, batch, expected, signal);
|
|
46
|
-
batch = new Map();
|
|
47
|
-
expected = Object.create(null);
|
|
48
|
-
batchBytes = 0;
|
|
49
|
-
};
|
|
50
|
-
const upload = async (path, file, previous) => {
|
|
51
|
-
if (batch.size >= 128 || batchBytes + (file?.data.length ?? 0) > 8 << 20)
|
|
52
|
-
await flush();
|
|
53
|
-
batch.set(path, file);
|
|
54
|
-
expected[path] = previous;
|
|
55
|
-
batchBytes += file?.data.length ?? 0;
|
|
56
|
-
};
|
|
39
|
+
// Laptop-side deletions run before writes and VM-side deletions before uploads, so a directory can become a file.
|
|
40
|
+
const pushes = [], removals = [];
|
|
41
|
+
const expected = Object.create(null);
|
|
42
|
+
const pulls = [];
|
|
57
43
|
for (const change of changes) {
|
|
58
44
|
signal?.throwIfAborted();
|
|
59
45
|
const { path, winner } = change;
|
|
@@ -74,28 +60,39 @@ export function projectReconciler(options) {
|
|
|
74
60
|
if (remote[copy])
|
|
75
61
|
copy += `-${randomUUID()}`;
|
|
76
62
|
if (loserEntry) {
|
|
77
|
-
|
|
78
|
-
const backup = { ...
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
63
|
+
// Preserve both copies before modifying either original: the losing version lands here, then in the VM.
|
|
64
|
+
const backup = { ...loserEntry, path: copy };
|
|
65
|
+
if (winner === 'local')
|
|
66
|
+
await pullRemoteFile(cell, root, folder, copy, backup, { from: path, signal });
|
|
67
|
+
else
|
|
68
|
+
await copyLocalSyncFile(folder, loserEntry, copy);
|
|
69
|
+
await pushLocalFiles(cell, root, folder, [backup], [], { signal });
|
|
83
70
|
}
|
|
84
71
|
const conflict = { path, copy: loserEntry ? copy : null, losingDeleted: !loserEntry, agent, winner, at: Date.now() };
|
|
85
72
|
state.conflicts.push(conflict);
|
|
86
73
|
save(state);
|
|
87
74
|
report(`Project sync conflict ${path}: ${winner} wins; ${conflict.copy ?? 'the other side deleted the file'}`);
|
|
88
75
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
76
|
+
if (winner === 'local') {
|
|
77
|
+
if (winnerEntry)
|
|
78
|
+
pushes.push(winnerEntry);
|
|
79
|
+
else
|
|
80
|
+
removals.push(path);
|
|
81
|
+
expected[path] = remote[path];
|
|
82
|
+
}
|
|
92
83
|
else
|
|
93
|
-
|
|
84
|
+
pulls.push({ path, ...(winnerEntry ? { entry: winnerEntry } : {}), ...(local[path] ? { expected: local[path] } : {}) });
|
|
94
85
|
}
|
|
95
|
-
|
|
86
|
+
for (const pull of pulls)
|
|
87
|
+
if (!pull.entry)
|
|
88
|
+
await pullRemoteFile(cell, root, folder, pull.path, undefined, { ...(pull.expected ? { expected: pull.expected } : {}), signal });
|
|
89
|
+
for (const pull of pulls)
|
|
90
|
+
if (pull.entry)
|
|
91
|
+
await pullRemoteFile(cell, root, folder, pull.path, pull.entry, { ...(pull.expected ? { expected: pull.expected } : {}), signal });
|
|
92
|
+
await pushLocalFiles(cell, root, folder, pushes, removals, { signal, expected });
|
|
96
93
|
// Only observed equality advances the durable common hash. Changes made
|
|
97
94
|
// during transfer remain relative to the previous common hash on next run.
|
|
98
|
-
const [afterLocal, afterRemote] = changes.length === 0 ? [localRaw, remoteRaw] : await Promise.all([localManifest(folder, signal, cache, Object.keys(state.common)), cell
|
|
95
|
+
const [afterLocal, afterRemote] = changes.length === 0 ? [localRaw, remoteRaw] : await Promise.all([localManifest(folder, signal, cache, Object.keys(state.common)), remoteManifest(cell, root, Object.keys(state.common), signal)]);
|
|
99
96
|
const l = parseManifest(afterLocal), r = parseManifest(afterRemote);
|
|
100
97
|
for (const path of new Set([...Object.keys(l), ...Object.keys(r), ...Object.keys(state.common)])) {
|
|
101
98
|
if (blocked.has(path))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The start path's phase clock. SHARDFLUX_TRACE_SEED=1 prints how long each phase of a conversation start took
|
|
3
|
+
* (`seed <phase> <ms> ms`) on stderr, for measurements and support. Silent otherwise.
|
|
4
|
+
*
|
|
5
|
+
* The same marks drive the launchers' phase line (0.19.0+): with SHARDFLUX_PROGRESS_FILE set to a private file the
|
|
6
|
+
* launcher created, a start rewrites that file with the phase it is in, as one JSON line
|
|
7
|
+
* (`{"phase":"copy_changes","line":"Copying your changes…"}`), each time the phase changes. Nothing is written
|
|
8
|
+
* without the variable, and a path that is not the caller's own private regular file is never written.
|
|
9
|
+
*/
|
|
10
|
+
export type StartPhase = 'computer' | 'project' | 'checkout' | 'copy_changes' | 'harness';
|
|
11
|
+
type App = 'claude' | 'codex';
|
|
12
|
+
export declare const startPhaseLine: (phase: StartPhase, app: App) => string;
|
|
13
|
+
export declare function seedPhase(name: string, env?: Record<string, string | undefined>): void;
|
|
14
|
+
/** A conversation start begins: the launcher's file (if any) is remembered and the first phase is written. */
|
|
15
|
+
export declare function startProgress(app: App, env?: Record<string, string | undefined>): void;
|
|
16
|
+
/** Moves the current start to this phase; the file is rewritten only when the phase changes. */
|
|
17
|
+
export declare function startPhase(phase: StartPhase): void;
|
|
18
|
+
/**
|
|
19
|
+
* The caller's own private file in the caller's own private directory: a regular file (a symlink is refused, never
|
|
20
|
+
* followed) owned by this user with no group or other access, in a directory owned by this user that nobody else can
|
|
21
|
+
* write to. A file the launcher already removed is not created again.
|
|
22
|
+
*/
|
|
23
|
+
export declare function privateProgressFile(file: string): boolean;
|
|
24
|
+
/** Atomic: a sibling written exclusively (O_EXCL never follows a link), then renamed over the file. Never throws. */
|
|
25
|
+
export declare function writeProgress(file: string, doc: {
|
|
26
|
+
phase: StartPhase;
|
|
27
|
+
line: string;
|
|
28
|
+
}): boolean;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { closeSync, lstatSync, openSync, renameSync, unlinkSync, writeSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute } from 'node:path';
|
|
3
|
+
const LINES = {
|
|
4
|
+
computer: 'Starting a computer…',
|
|
5
|
+
project: 'Getting your project…',
|
|
6
|
+
checkout: 'Checking out your files…',
|
|
7
|
+
copy_changes: 'Copying your changes…',
|
|
8
|
+
};
|
|
9
|
+
export const startPhaseLine = (phase, app) => phase === 'harness' ? `Starting ${app === 'codex' ? 'Codex' : 'Claude'}…` : LINES[phase];
|
|
10
|
+
/** seedPhase marks the END of a phase: the phase that starts there. Unlisted marks stay in the current phase. */
|
|
11
|
+
const NEXT = {
|
|
12
|
+
vm_running: 'project',
|
|
13
|
+
apply_history: 'checkout',
|
|
14
|
+
local_changes: 'checkout',
|
|
15
|
+
checkout_head: 'copy_changes',
|
|
16
|
+
seed_done: 'harness',
|
|
17
|
+
};
|
|
18
|
+
let last = performance.now();
|
|
19
|
+
let target = null;
|
|
20
|
+
export function seedPhase(name, env = process.env) {
|
|
21
|
+
const now = performance.now();
|
|
22
|
+
if (env.SHARDFLUX_TRACE_SEED)
|
|
23
|
+
process.stderr.write(`seed ${name} ${Math.round(now - last)} ms\n`);
|
|
24
|
+
last = now;
|
|
25
|
+
const next = NEXT[name];
|
|
26
|
+
if (next)
|
|
27
|
+
startPhase(next);
|
|
28
|
+
}
|
|
29
|
+
/** A conversation start begins: the launcher's file (if any) is remembered and the first phase is written. */
|
|
30
|
+
export function startProgress(app, env = process.env) {
|
|
31
|
+
const file = env.SHARDFLUX_PROGRESS_FILE;
|
|
32
|
+
target = file ? { file, app, phase: null } : null;
|
|
33
|
+
startPhase('computer');
|
|
34
|
+
}
|
|
35
|
+
/** Moves the current start to this phase; the file is rewritten only when the phase changes. */
|
|
36
|
+
export function startPhase(phase) {
|
|
37
|
+
if (!target || target.phase === phase)
|
|
38
|
+
return;
|
|
39
|
+
target.phase = phase;
|
|
40
|
+
writeProgress(target.file, { phase, line: startPhaseLine(phase, target.app) });
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The caller's own private file in the caller's own private directory: a regular file (a symlink is refused, never
|
|
44
|
+
* followed) owned by this user with no group or other access, in a directory owned by this user that nobody else can
|
|
45
|
+
* write to. A file the launcher already removed is not created again.
|
|
46
|
+
*/
|
|
47
|
+
export function privateProgressFile(file) {
|
|
48
|
+
if (!isAbsolute(file) || typeof process.getuid !== 'function')
|
|
49
|
+
return false;
|
|
50
|
+
const uid = process.getuid();
|
|
51
|
+
try {
|
|
52
|
+
const stat = lstatSync(file), dir = lstatSync(dirname(file));
|
|
53
|
+
return stat.isFile() && stat.uid === uid && (stat.mode & 0o077) === 0
|
|
54
|
+
&& dir.isDirectory() && dir.uid === uid && (dir.mode & 0o022) === 0;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Atomic: a sibling written exclusively (O_EXCL never follows a link), then renamed over the file. Never throws. */
|
|
61
|
+
export function writeProgress(file, doc) {
|
|
62
|
+
if (!privateProgressFile(file))
|
|
63
|
+
return false;
|
|
64
|
+
const temp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
65
|
+
try {
|
|
66
|
+
const fd = openSync(temp, 'wx', 0o600);
|
|
67
|
+
try {
|
|
68
|
+
writeSync(fd, `${JSON.stringify(doc)}\n`);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
renameSync(temp, file);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(temp);
|
|
79
|
+
}
|
|
80
|
+
catch { /* never created */ }
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/t3-launch.js
CHANGED
|
@@ -125,7 +125,9 @@ export async function openT3Conversation(ctx, conversationId, values = {}) {
|
|
|
125
125
|
return 0;
|
|
126
126
|
}
|
|
127
127
|
// Its VM as T3's SSH environment, with the checks every `agents open t3` makes: --reachable and the cloud Claude login.
|
|
128
|
-
|
|
128
|
+
// A conversation whose VM was removed restores into a fresh one first (0.18.1+).
|
|
129
|
+
const { reopenConversation } = await import("./conversation-terminal.js");
|
|
130
|
+
let { ws } = await reopenConversation(ctx, conversation, values);
|
|
129
131
|
if (values.reachable !== undefined)
|
|
130
132
|
ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
|
|
131
133
|
// A pooled thread (0.18.0+) runs on the account hub, not on the account's cloud Claude login.
|
package/dist/t3-mode.d.ts
CHANGED
|
@@ -64,7 +64,8 @@ export { t3HomeDir };
|
|
|
64
64
|
export declare function t3ProviderRegistered(env: Record<string, string | undefined>): boolean;
|
|
65
65
|
/**
|
|
66
66
|
* T3 is connected when a new thread can start in it now: in ACP mode the Shardflux provider is in T3's settings and
|
|
67
|
-
* this computer is signed in; in SSH mode
|
|
67
|
+
* this computer is signed in; in SSH mode the account has its T3 Connect sign-in (0.19.0+: any project is set up on its
|
|
68
|
+
* first thread and linked to the person's T3 account), or this project has a T3 SSH environment set up from here.
|
|
68
69
|
* `last_used_at` is looked up only when asked for (`agents targets`).
|
|
69
70
|
*/
|
|
70
71
|
export declare function t3Status(ctx: Ctx, opts?: {
|
|
@@ -104,7 +105,11 @@ export declare function t3ThreadTitle(prompt: string): string;
|
|
|
104
105
|
* shares one bundle id, so `open -b` could start another installed channel); on Linux its desktop entry with
|
|
105
106
|
* `gtk-launch`, else its program (t3-linux.ts).
|
|
106
107
|
*/
|
|
107
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Brings T3 Code to the front; `page` (a t3code://app/settings/… link, which T3 0.0.45 routes) opens it on that page.
|
|
110
|
+
* On Linux the page goes through T3's own URL handler (registered by the app with xdg-mime); without one, T3 opens.
|
|
111
|
+
*/
|
|
112
|
+
export declare function openT3(env: Record<string, string | undefined>, install: Pick<T3Install, 'path' | 'version' | 'linux'> | undefined, page?: string): Promise<void>;
|
|
108
113
|
/** Starts the thread in the VM's T3 server (T3_THREAD_SCRIPT); the prompt travels on stdin, never in argv. */
|
|
109
114
|
export declare function startSshThread(ctx: Ctx, ws: Workspace, version: string | undefined, projectPath: string, request: T3ThreadRequest): Promise<T3ThreadResult>;
|
|
110
115
|
interface Descriptor {
|
package/dist/t3-mode.js
CHANGED
|
@@ -111,7 +111,8 @@ function sameProject(key, slug) {
|
|
|
111
111
|
}
|
|
112
112
|
/**
|
|
113
113
|
* T3 is connected when a new thread can start in it now: in ACP mode the Shardflux provider is in T3's settings and
|
|
114
|
-
* this computer is signed in; in SSH mode
|
|
114
|
+
* this computer is signed in; in SSH mode the account has its T3 Connect sign-in (0.19.0+: any project is set up on its
|
|
115
|
+
* first thread and linked to the person's T3 account), or this project has a T3 SSH environment set up from here.
|
|
115
116
|
* `last_used_at` is looked up only when asked for (`agents targets`).
|
|
116
117
|
*/
|
|
117
118
|
export function t3Status(ctx, opts = {}) {
|
|
@@ -120,7 +121,8 @@ export function t3Status(ctx, opts = {}) {
|
|
|
120
121
|
return { installed: false, mode: null, connected: false, version: null, last_used_at: null };
|
|
121
122
|
const connected = acpSignedIn(ctx) && (install.mode === 'acp'
|
|
122
123
|
? t3ProviderRegistered(ctx.io.env)
|
|
123
|
-
:
|
|
124
|
+
: new AgentStateStore(ctx.store.dir).t3Connected(ctx.apiUrl)
|
|
125
|
+
|| !!opts.project && new AgentStateStore(ctx.store.dir).t3Environments(ctx.apiUrl).some(e => sameProject(e.workspace, opts.project)));
|
|
124
126
|
return { installed: true, mode: install.mode, connected, version: install.version, last_used_at: opts.lastUsed ? lastUsedDates(ctx, usagePaths(ctx, install).paths)[install.path] ?? null : null };
|
|
125
127
|
}
|
|
126
128
|
/** T3 and the other installed desktop apps whose last use decides the default: one Spotlight lookup covers them all. */
|
|
@@ -214,13 +216,28 @@ export function t3ThreadTitle(prompt) {
|
|
|
214
216
|
* shares one bundle id, so `open -b` could start another installed channel); on Linux its desktop entry with
|
|
215
217
|
* `gtk-launch`, else its program (t3-linux.ts).
|
|
216
218
|
*/
|
|
217
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Brings T3 Code to the front; `page` (a t3code://app/settings/… link, which T3 0.0.45 routes) opens it on that page.
|
|
221
|
+
* On Linux the page goes through T3's own URL handler (registered by the app with xdg-mime); without one, T3 opens.
|
|
222
|
+
*/
|
|
223
|
+
export async function openT3(env, install, page) {
|
|
218
224
|
if (!install)
|
|
219
225
|
throw t3NotInstalled();
|
|
226
|
+
// T3 0.0.45 routes a t3code://app/settings/… link only into a window that exists (DesktopClerk resumeProviderAuth),
|
|
227
|
+
// so T3 is opened first and the page follows once its window is up.
|
|
228
|
+
if (page) {
|
|
229
|
+
await openT3(env, install);
|
|
230
|
+
await delay(T3_PAGE_DELAY_MS);
|
|
231
|
+
if (install.linux) {
|
|
232
|
+
await openLinuxT3Page(env, page);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
220
236
|
if (install.linux)
|
|
221
237
|
return openLinuxT3(env, { path: install.path, version: install.version, linux: install.linux });
|
|
222
|
-
// As on Linux: an inherited ELECTRON_RUN_AS_NODE would start T3 as plain Node (t3OpenEnv).
|
|
223
|
-
|
|
238
|
+
// As on Linux: an inherited ELECTRON_RUN_AS_NODE would start T3 as plain Node (t3OpenEnv). `open -a` hands the page
|
|
239
|
+
// to the detected install itself (T3 channels share one bundle id).
|
|
240
|
+
const child = spawn('open', ['-a', install.path, ...(page ? [page] : [])], { env: t3OpenEnv(env), stdio: 'ignore' });
|
|
224
241
|
await new Promise((resolve, reject) => {
|
|
225
242
|
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('T3 Code did not open in time')); }, 10_000);
|
|
226
243
|
child.once('error', e => { clearTimeout(timer); reject(e); });
|
|
@@ -230,6 +247,23 @@ export async function openT3(env, install) {
|
|
|
230
247
|
reject(new Error(`could not open T3 Code (open exited ${code})`)); });
|
|
231
248
|
});
|
|
232
249
|
}
|
|
250
|
+
/** How long T3 gets to show its window before a page link (a cold start takes about two seconds on a Mac). */
|
|
251
|
+
const T3_PAGE_DELAY_MS = 2500;
|
|
252
|
+
/** A t3code:// page through T3's registered handler on Linux: false when none is registered (or xdg-open fails). */
|
|
253
|
+
async function openLinuxT3Page(env, page) {
|
|
254
|
+
const run = (command, args) => new Promise(resolve => {
|
|
255
|
+
let out = '';
|
|
256
|
+
const child = spawn(command, args, { env: t3OpenEnv(env), stdio: ['ignore', 'pipe', 'ignore'] });
|
|
257
|
+
const timer = setTimeout(() => { child.unref(); resolve({ code: null, out }); }, 5000);
|
|
258
|
+
child.stdout?.on('data', (chunk) => { out += chunk.toString(); });
|
|
259
|
+
child.once('error', () => { clearTimeout(timer); resolve({ code: null, out }); });
|
|
260
|
+
child.once('exit', code => { clearTimeout(timer); resolve({ code, out }); });
|
|
261
|
+
});
|
|
262
|
+
const handler = await run('xdg-mime', ['query', 'default', 'x-scheme-handler/t3code']);
|
|
263
|
+
if (handler.code !== 0 || !handler.out.trim())
|
|
264
|
+
return false;
|
|
265
|
+
return (await run('xdg-open', [page])).code === 0;
|
|
266
|
+
}
|
|
233
267
|
// ── SSH mode: the T3 server in the project VM ──────────────────────────────────────────────────────────────────────
|
|
234
268
|
/** Starts the thread in the VM's T3 server (T3_THREAD_SCRIPT); the prompt travels on stdin, never in argv. */
|
|
235
269
|
export async function startSshThread(ctx, ws, version, projectPath, request) {
|
|
@@ -11,6 +11,7 @@ import { table } from "./format.js";
|
|
|
11
11
|
import { AgentStateStore } from "./agent-state.js";
|
|
12
12
|
import { normalizeRemote, projectIdentity } from "./project-identity.js";
|
|
13
13
|
import { progress } from "./progress.js";
|
|
14
|
+
import { startPhase, startProgress } from "./seed-phases.js";
|
|
14
15
|
import { conversationClient } from "./conversation-commands.js";
|
|
15
16
|
import { conversationEnded, conversationName, conversationWorkspace, getConversation, listConversations, localConversations, rememberConversation, validateConversation, waitThreadReady } from "./conversations.js";
|
|
16
17
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
@@ -160,7 +161,10 @@ export async function startThreadOn(ctx, values, path, explicitApp) {
|
|
|
160
161
|
else
|
|
161
162
|
await ensureCodexCloud(ctx);
|
|
162
163
|
progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} on the computer with ${target.title || target.key}…`);
|
|
164
|
+
// The launcher's phase line (0.19.0+): the computer wakes, then the thread's harness starts on it.
|
|
165
|
+
startProgress(app, ctx.io.env);
|
|
163
166
|
const ws = await conversationWorkspace(ctx, target);
|
|
167
|
+
startPhase('harness');
|
|
164
168
|
const commit = await targetHead(ws, target, ctx.signal);
|
|
165
169
|
const baseBranch = target.base_branch ?? undefined;
|
|
166
170
|
await uploadInitialImages(ws.cell(), images, ctx.signal);
|
package/dist/worktree-twin.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { CellClient } from '@shardflux/sdk';
|
|
2
2
|
import { AgentStateStore } from './agent-state.js';
|
|
3
|
-
/** Files above this stay at HEAD's version in a twin (the bound the seed copy already uses). */
|
|
4
|
-
export declare const TWIN_MAX_FILE_BYTES: number;
|
|
5
3
|
/** A file the landing rewrites must be untouched this long first. */
|
|
6
4
|
export declare const QUIET_MS = 2000;
|
|
7
5
|
export declare const HUB_PATH = "/shardflux/project/git/hub.git";
|
|
@@ -50,17 +48,15 @@ export interface Snapshot {
|
|
|
50
48
|
branch: string | null;
|
|
51
49
|
root: string;
|
|
52
50
|
id: string;
|
|
53
|
-
held: string[];
|
|
54
51
|
headTree: string;
|
|
55
52
|
}
|
|
56
53
|
/**
|
|
57
|
-
* One snapshot commit of the worktree's working state (what `git add -A` sees; `.shard/`
|
|
58
|
-
*
|
|
54
|
+
* One snapshot commit of the worktree's working state (what `git add -A` sees, any file size; `.shard/` left out),
|
|
55
|
+
* first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
|
|
59
56
|
*/
|
|
60
57
|
export declare function snapshotWorktree(info: WorktreeInfo, options: {
|
|
61
58
|
device: string;
|
|
62
59
|
stateDir: string;
|
|
63
|
-
maxFileBytes?: number;
|
|
64
60
|
now?: Date;
|
|
65
61
|
signal?: AbortSignal;
|
|
66
62
|
}): Promise<Snapshot>;
|
package/dist/worktree-twin.js
CHANGED
|
@@ -17,14 +17,10 @@ import { tmpdir } from 'node:os';
|
|
|
17
17
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
18
18
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
19
19
|
import { AgentStateStore } from "./agent-state.js";
|
|
20
|
-
import {
|
|
21
|
-
import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
22
|
-
/** Files above this stay at HEAD's version in a twin (the bound the seed copy already uses). */
|
|
23
|
-
export const TWIN_MAX_FILE_BYTES = MAX_SYNC_FILE_BYTES;
|
|
20
|
+
import { readSyncFileTo, writeSyncFileFrom } from "./project-sync-transfer.js";
|
|
24
21
|
/** A file the landing rewrites must be untouched this long first. */
|
|
25
22
|
export const QUIET_MS = 2000;
|
|
26
23
|
export const HUB_PATH = '/shardflux/project/git/hub.git';
|
|
27
|
-
const TRANSFER_LIMIT = 256 << 20;
|
|
28
24
|
const INLINE_BYTES = 512 << 10;
|
|
29
25
|
const BACKUP_DAYS = 14;
|
|
30
26
|
const BACKUP_BYTES = 1 << 30;
|
|
@@ -111,15 +107,14 @@ export const twinRef = (id) => `refs/shard/twin/${id}`;
|
|
|
111
107
|
export const hubTwinRef = (device, id) => `refs/shard/twin/${device}/${id}`;
|
|
112
108
|
const identity = (now) => ({ GIT_AUTHOR_NAME: 'Shardflux', GIT_AUTHOR_EMAIL: 'twin@shardflux.dev', GIT_COMMITTER_NAME: 'Shardflux', GIT_COMMITTER_EMAIL: 'twin@shardflux.dev',
|
|
113
109
|
GIT_AUTHOR_DATE: `@${Math.floor(now.getTime() / 1000)} +0000`, GIT_COMMITTER_DATE: `@${Math.floor(now.getTime() / 1000)} +0000` });
|
|
114
|
-
const excludedPath = (path) => path.split('/').some(part => part === '.git' || part === '.shard' || part.includes('.shard-tmp-'));
|
|
115
110
|
/**
|
|
116
|
-
* One snapshot commit of the worktree's working state (what `git add -A` sees; `.shard/`
|
|
117
|
-
*
|
|
111
|
+
* One snapshot commit of the worktree's working state (what `git add -A` sees, any file size; `.shard/` left out),
|
|
112
|
+
* first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
|
|
118
113
|
*/
|
|
119
114
|
export async function snapshotWorktree(info, options) {
|
|
120
115
|
if (!info.head)
|
|
121
116
|
throw new Error('the worktree has no commit yet');
|
|
122
|
-
const id = worktreeId(info.root),
|
|
117
|
+
const id = worktreeId(info.root), signal = options.signal;
|
|
123
118
|
const dir = join(options.stateDir, 'twin');
|
|
124
119
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
125
120
|
const index = join(dir, `${id}.${process.pid}.${randomBytes(4).toString('hex')}.index`);
|
|
@@ -135,23 +130,9 @@ export async function snapshotWorktree(info, options) {
|
|
|
135
130
|
}
|
|
136
131
|
else
|
|
137
132
|
await git(info.root, ['read-tree', info.head], { env, signal });
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
for (const path of candidates) {
|
|
141
|
-
if (excludedPath(path))
|
|
142
|
-
continue;
|
|
143
|
-
const st = lstatSync(join(info.root, path), { throwIfNoEntry: false });
|
|
144
|
-
if (st?.isFile() && st.size > max)
|
|
145
|
-
held.push(path);
|
|
146
|
-
}
|
|
147
|
-
const specs = ['.', ':(exclude).shard', ':(exclude,glob)**/.shard/**', ':(exclude,glob)**/*.shard-tmp-*', ...held.map(p => `:(exclude,literal)${p}`)];
|
|
133
|
+
// Every file, whatever its size: Git stores large files as streamed loose objects.
|
|
134
|
+
const specs = ['.', ':(exclude).shard', ':(exclude,glob)**/.shard/**', ':(exclude,glob)**/*.shard-tmp-*'];
|
|
148
135
|
await git(info.root, ['add', '-A', '--ignore-errors', '--pathspec-from-file=-', '--pathspec-file-nul'], { env, input: specs.join('\0') + '\0', signal });
|
|
149
|
-
if (held.length) {
|
|
150
|
-
const atHead = (await git(info.root, ['ls-tree', '-z', '--full-tree', info.head, '--', ...held], { signal })).stdout.toString('utf8').split('\0').filter(Boolean);
|
|
151
|
-
const known = new Set(atHead.map(line => line.slice(line.indexOf('\t') + 1)));
|
|
152
|
-
const lines = [...atHead, ...held.filter(p => !known.has(p)).map(p => `0 ${'0'.repeat(40)}\t${p}`)];
|
|
153
|
-
await git(info.root, ['update-index', '-z', '--index-info'], { env, input: lines.join('\0') + '\0', signal });
|
|
154
|
-
}
|
|
155
136
|
const tree = await gitText(info.root, ['write-tree'], { env, signal });
|
|
156
137
|
const headTree = await gitText(info.root, ['rev-parse', `${info.head}^{tree}`], { signal });
|
|
157
138
|
const previous = await verifyCommit(info.root, twinRef(id));
|
|
@@ -166,7 +147,7 @@ export async function snapshotWorktree(info, options) {
|
|
|
166
147
|
commit = await gitText(info.root, ['commit-tree', tree, '-p', info.head, '-F', '-'], { env: identity(options.now ?? new Date()), input: message, signal });
|
|
167
148
|
await git(info.root, ['update-ref', twinRef(id), commit], { signal });
|
|
168
149
|
}
|
|
169
|
-
return { commit, tree, base: info.head, branch: info.branch, root: info.root, id,
|
|
150
|
+
return { commit, tree, base: info.head, branch: info.branch, root: info.root, id, headTree };
|
|
170
151
|
}
|
|
171
152
|
finally {
|
|
172
153
|
rmSync(index, { force: true });
|
|
@@ -181,7 +162,7 @@ export async function refTwin(repo, branch, device, now = new Date()) {
|
|
|
181
162
|
const id = createHash('sha256').update(`${realpathSync(repo)}\0${branch}`).digest('hex').slice(0, 16);
|
|
182
163
|
const commit = await gitText(repo, ['commit-tree', tree, '-p', base, '-F', '-'], { env: identity(now), input: `Shardflux twin of ${branch}\n\n${SNAPSHOT_TRAILER}: ${device}/${id}\nShardflux-Branch: ${branch}\n` });
|
|
183
164
|
await git(repo, ['update-ref', twinRef(id), commit]);
|
|
184
|
-
return { commit, tree, base, branch, root: realpathSync(repo), id,
|
|
165
|
+
return { commit, tree, base, branch, root: realpathSync(repo), id, headTree: tree };
|
|
185
166
|
}
|
|
186
167
|
async function rawDiff(cwd, a, b) {
|
|
187
168
|
const parts = (await git(cwd, ['diff-tree', '-r', '-z', '--raw', '--no-renames', '--no-abbrev', a, b])).stdout.toString('utf8').split('\0');
|
|
@@ -549,15 +530,11 @@ async function mergeInVm(cell, input, snap, mark) {
|
|
|
549
530
|
const hasBundle = bundled.code === 0;
|
|
550
531
|
let inline, uploaded = false;
|
|
551
532
|
if (hasBundle) {
|
|
552
|
-
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
// A small bundle rides in the script's input: no file round trips.
|
|
556
|
-
if (bytes.length <= INLINE_BYTES)
|
|
557
|
-
inline = bytes.toString('base64');
|
|
533
|
+
// A small bundle rides in the script's input: no file round trips. A larger one streams from disk.
|
|
534
|
+
if (statSync(local).size <= INLINE_BYTES)
|
|
535
|
+
inline = (await readFile(local)).toString('base64');
|
|
558
536
|
else {
|
|
559
|
-
|
|
560
|
-
await cell.files.write(remote, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode: '0600', append: at > 0 });
|
|
537
|
+
await writeSyncFileFrom(cell, remote, local, input.signal);
|
|
561
538
|
uploaded = true;
|
|
562
539
|
}
|
|
563
540
|
}
|
|
@@ -589,18 +566,10 @@ async function mergeInVm(cell, input, snap, mark) {
|
|
|
589
566
|
return land;
|
|
590
567
|
const fetched = join(tmpdir(), `shard-land-${randomUUID()}.bundle`), refs = landRefs(c.name);
|
|
591
568
|
try {
|
|
592
|
-
let bytes;
|
|
593
569
|
if (land.bundle_b64)
|
|
594
|
-
|
|
595
|
-
else
|
|
596
|
-
|
|
597
|
-
if (info.size > TRANSFER_LIMIT)
|
|
598
|
-
throw new Error('the landing exceeds the 256 MiB transfer bound');
|
|
599
|
-
bytes = await cell.files.read(out, { length: info.size + 1 });
|
|
600
|
-
if (bytes.length !== info.size)
|
|
601
|
-
throw new Error('the landing bundle changed while it was read');
|
|
602
|
-
}
|
|
603
|
-
await writeFile(fetched, bytes, { mode: 0o600 });
|
|
570
|
+
await writeFile(fetched, Buffer.from(land.bundle_b64, 'base64'), { mode: 0o600 });
|
|
571
|
+
else
|
|
572
|
+
await readSyncFileTo(cell, out, (await cell.files.stat(out)).size, fetched, input.signal);
|
|
604
573
|
await git(input.repo, ['fetch', '-q', '--no-tags', fetched, `+refs/shard/land/tip:${refs.tip}`, `+refs/shard/land/result:${refs.result}`]);
|
|
605
574
|
if (await verifyCommit(input.repo, refs.tip) !== land.tip || await verifyCommit(input.repo, refs.result) !== land.result)
|
|
606
575
|
throw new Error('the landing bundle does not match the landing');
|
|
@@ -816,11 +785,7 @@ export async function sendTwinSeed(stateDir, cell, conversation, folder, signal)
|
|
|
816
785
|
await git(folder, ['update-ref', ref, conversation.base_commit]);
|
|
817
786
|
try {
|
|
818
787
|
await git(folder, ['bundle', 'create', '-q', local, ref, '--not', `${conversation.base_commit}^1`]);
|
|
819
|
-
|
|
820
|
-
if (bytes.length > TRANSFER_LIMIT)
|
|
821
|
-
throw new Error('the uncommitted changes exceed the 256 MiB transfer bound');
|
|
822
|
-
for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
|
|
823
|
-
await cell.files.write(remote, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode: '0600', append: at > 0 });
|
|
788
|
+
await writeSyncFileFrom(cell, remote, local, signal);
|
|
824
789
|
const r = await cell.exec.run(['git', '-C', conversation.worktree_path, '-c', 'core.hooksPath=/dev/null', 'fetch', '-q', '--no-tags', remote, `+${ref}:refs/shard/twin-seed`], { timeoutMs: 120_000, ...(signal ? { signal } : {}) });
|
|
825
790
|
if (r.exitCode !== 0)
|
|
826
791
|
throw new Error(`the conversation could not receive its starting snapshot: ${r.stderr.trim()}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"smol-toml": "1.9.0",
|
|
48
48
|
"snappyjs": "0.7.0",
|
|
49
49
|
"yaml": "2.9.1",
|
|
50
|
-
"@shardflux/sdk": "^0.
|
|
50
|
+
"@shardflux/sdk": "^0.25.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@eslint/js": "10.0.1",
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"typescript-eslint": "8.70.1"
|
|
59
59
|
},
|
|
60
60
|
"optionalDependencies": {
|
|
61
|
-
"@shardflux/desktop-linux-x64": "0.1.
|
|
62
|
-
"@shardflux/desktop-linux-arm64": "0.1.
|
|
63
|
-
"@shardflux/menubar-darwin": "^0.
|
|
61
|
+
"@shardflux/desktop-linux-x64": "0.1.2",
|
|
62
|
+
"@shardflux/desktop-linux-arm64": "0.1.2",
|
|
63
|
+
"@shardflux/menubar-darwin": "^0.7.0"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|
|
66
66
|
"build": "node scripts/build.mjs",
|