@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +28 -1
  3. package/dist/account-args.js +1 -0
  4. package/dist/agent-commands.d.ts +1 -0
  5. package/dist/agent-commands.js +15 -5
  6. package/dist/agent-config-files.d.ts +16 -2
  7. package/dist/agent-config-files.js +48 -61
  8. package/dist/agent-config-sync.d.ts +12 -1
  9. package/dist/agent-config-sync.js +228 -54
  10. package/dist/agent-repository.d.ts +39 -9
  11. package/dist/agent-repository.js +309 -92
  12. package/dist/agent-state.d.ts +3 -0
  13. package/dist/agent-state.js +13 -0
  14. package/dist/agent-target-commands.d.ts +2 -0
  15. package/dist/agent-target-commands.js +55 -18
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +16 -4
  18. package/dist/command-center-actions.js +31 -5
  19. package/dist/conversation-commands.js +6 -1
  20. package/dist/conversation-router.js +7 -11
  21. package/dist/conversation-stash.js +64 -18
  22. package/dist/conversation-terminal.d.ts +11 -1
  23. package/dist/conversation-terminal.js +28 -7
  24. package/dist/conversations.d.ts +17 -3
  25. package/dist/conversations.js +58 -6
  26. package/dist/errors.d.ts +1 -1
  27. package/dist/errors.js +1 -1
  28. package/dist/files-mirror.d.ts +1 -9
  29. package/dist/files-mirror.js +19 -3
  30. package/dist/format.js +37 -2
  31. package/dist/handlers.js +3 -1
  32. package/dist/local-sessions.d.ts +60 -0
  33. package/dist/local-sessions.js +445 -0
  34. package/dist/main.js +12 -0
  35. package/dist/move.d.ts +44 -0
  36. package/dist/move.js +248 -0
  37. package/dist/moved-sessions.d.ts +33 -0
  38. package/dist/moved-sessions.js +91 -0
  39. package/dist/onboarding.d.ts +18 -3
  40. package/dist/onboarding.js +72 -46
  41. package/dist/project-sync-manifest.d.ts +31 -3
  42. package/dist/project-sync-manifest.js +192 -108
  43. package/dist/project-sync-transfer.d.ts +50 -4
  44. package/dist/project-sync-transfer.js +404 -106
  45. package/dist/project-sync.js +30 -33
  46. package/dist/seed-phases.d.ts +29 -0
  47. package/dist/seed-phases.js +83 -0
  48. package/dist/t3-launch.js +3 -1
  49. package/dist/t3-mode.d.ts +7 -2
  50. package/dist/t3-mode.js +39 -5
  51. package/dist/thread-on-computer.js +4 -0
  52. package/dist/worktree-twin.d.ts +2 -6
  53. package/dist/worktree-twin.js +16 -51
  54. package/package.json +5 -5
@@ -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, MAX_SYNC_FILE_BYTES, parseManifest } from "./project-sync-manifest.js";
8
- import { applyLocalSyncFile, applyRemoteSyncBatch, readLocalSyncFile, readRemoteSyncFile } from "./project-sync-transfer.js";
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.files.manifest(root, { maxFileBytes: MAX_SYNC_FILE_BYTES, knownPaths: [...new Set([...Object.keys(state.common), ...localRaw.entries.map((entry) => entry.path)])], signal });
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
- let batch = new Map();
40
- let expected = Object.create(null);
41
- let batchBytes = 0;
42
- const flush = async () => {
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
- const losing = winner === 'local' ? await readRemoteSyncFile(cell, root, loserEntry) : await readLocalSyncFile(folder, loserEntry);
78
- const backup = { ...losing, entry: { ...losing.entry, path: copy } };
79
- await applyLocalSyncFile(folder, copy, backup);
80
- await upload(copy, backup);
81
- // Preserve both copies before modifying either original.
82
- await flush();
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
- const file = winnerEntry ? (winner === 'local' ? await readLocalSyncFile(folder, winnerEntry) : await readRemoteSyncFile(cell, root, winnerEntry)) : undefined;
90
- if (winner === 'local')
91
- await upload(path, file, remote[path]);
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
- await applyLocalSyncFile(folder, path, file, local[path]);
84
+ pulls.push({ path, ...(winnerEntry ? { entry: winnerEntry } : {}), ...(local[path] ? { expected: local[path] } : {}) });
94
85
  }
95
- await flush();
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.files.manifest(root, { maxFileBytes: MAX_SYNC_FILE_BYTES, knownPaths: Object.keys(state.common), signal })]);
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
- let ws = await conversationWorkspace(ctx, conversation);
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 this project has a T3 SSH environment set up from this computer (signed in).
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
- export declare function openT3(env: Record<string, string | undefined>, install: Pick<T3Install, 'path' | 'version' | 'linux'> | undefined): Promise<void>;
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 this project has a T3 SSH environment set up from this computer (signed in).
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
- : !!opts.project && new AgentStateStore(ctx.store.dir).t3Environments(ctx.apiUrl).some(e => sameProject(e.workspace, opts.project)));
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
- export async function openT3(env, install) {
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
- const child = spawn('open', ['-a', install.path], { env: t3OpenEnv(env), stdio: 'ignore' });
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);
@@ -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/` and files above the size gate
58
- * left at HEAD's version), first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
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>;
@@ -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 { MAX_SYNC_FILE_BYTES } from "./project-sync-manifest.js";
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/` and files above the size gate
117
- * left at HEAD's version), first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
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), max = options.maxFileBytes ?? TWIN_MAX_FILE_BYTES, signal = options.signal;
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
- const candidates = (await git(info.root, ['ls-files', '-z', '-m', '-o', '--exclude-standard'], { signal })).stdout.toString('utf8').split('\0').filter(Boolean);
139
- const held = [];
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, held, headTree };
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, held: [], headTree: tree };
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
- const bytes = await readFile(local);
553
- if (bytes.length > TRANSFER_LIMIT)
554
- throw new Error('the twin exceeds the 256 MiB transfer bound');
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
- for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
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
- bytes = Buffer.from(land.bundle_b64, 'base64');
595
- else {
596
- const info = await cell.files.stat(out);
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
- const bytes = await readFile(local);
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.18.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.24.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.0",
62
- "@shardflux/desktop-linux-arm64": "0.1.0",
63
- "@shardflux/menubar-darwin": "^0.6.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",