@shardflux/cli 0.20.0 → 0.23.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 (124) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +242 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-journal.js +58 -6
  10. package/dist/acp-login.d.ts +13 -5
  11. package/dist/acp-login.js +37 -8
  12. package/dist/acp-models.d.ts +6 -1
  13. package/dist/acp-models.js +29 -19
  14. package/dist/acp.js +120 -14
  15. package/dist/agent-commands.d.ts +4 -1
  16. package/dist/agent-commands.js +110 -42
  17. package/dist/agent-config-return.d.ts +46 -2
  18. package/dist/agent-config-return.js +110 -4
  19. package/dist/agent-credentials.js +7 -3
  20. package/dist/agent-repository.d.ts +16 -3
  21. package/dist/agent-repository.js +149 -74
  22. package/dist/agent-setup.d.ts +14 -3
  23. package/dist/agent-setup.js +78 -36
  24. package/dist/agent-target-commands.d.ts +20 -1
  25. package/dist/agent-target-commands.js +73 -31
  26. package/dist/agent-targets.js +6 -1
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +95 -54
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-hub.d.ts +61 -25
  35. package/dist/cli-proxy-hub.js +202 -54
  36. package/dist/cli-proxy.d.ts +44 -1
  37. package/dist/cli-proxy.js +177 -28
  38. package/dist/client-settings.d.ts +39 -0
  39. package/dist/client-settings.js +91 -0
  40. package/dist/codex-cloud.d.ts +14 -4
  41. package/dist/codex-cloud.js +135 -31
  42. package/dist/codex-linux.d.ts +14 -0
  43. package/dist/codex-linux.js +69 -0
  44. package/dist/command-center-actions.js +18 -5
  45. package/dist/command-center-model.d.ts +2 -0
  46. package/dist/command-center-model.js +6 -1
  47. package/dist/command-center.js +3 -3
  48. package/dist/commands.js +173 -24
  49. package/dist/conversation-commands.d.ts +5 -0
  50. package/dist/conversation-commands.js +8 -5
  51. package/dist/conversation-terminal.d.ts +10 -2
  52. package/dist/conversation-terminal.js +78 -13
  53. package/dist/conversations.d.ts +86 -1
  54. package/dist/conversations.js +306 -23
  55. package/dist/desktop-linux.js +16 -3
  56. package/dist/device-bridge.d.ts +1 -1
  57. package/dist/device-bridge.js +6 -4
  58. package/dist/device-helper.js +43 -22
  59. package/dist/errors.d.ts +29 -2
  60. package/dist/errors.js +50 -7
  61. package/dist/filesystem-commands.d.ts +16 -9
  62. package/dist/filesystem-commands.js +133 -57
  63. package/dist/format.d.ts +2 -1
  64. package/dist/format.js +10 -1
  65. package/dist/handlers.js +3 -1
  66. package/dist/initial-prompt.d.ts +8 -0
  67. package/dist/initial-prompt.js +43 -1
  68. package/dist/large-media.d.ts +14 -0
  69. package/dist/large-media.js +100 -0
  70. package/dist/linux-terminal.d.ts +5 -3
  71. package/dist/linux-terminal.js +6 -20
  72. package/dist/local-sessions.d.ts +26 -2
  73. package/dist/local-sessions.js +113 -24
  74. package/dist/machine-setup.d.ts +11 -1
  75. package/dist/machine-setup.js +69 -22
  76. package/dist/machine-status.d.ts +67 -0
  77. package/dist/machine-status.js +140 -0
  78. package/dist/main.js +11 -13
  79. package/dist/menubar.d.ts +6 -5
  80. package/dist/menubar.js +38 -28
  81. package/dist/move-worktrees-restore.py +144 -0
  82. package/dist/move-worktrees.d.ts +69 -0
  83. package/dist/move-worktrees.js +252 -0
  84. package/dist/move.d.ts +10 -1
  85. package/dist/move.js +111 -16
  86. package/dist/onboarding.d.ts +11 -7
  87. package/dist/onboarding.js +63 -29
  88. package/dist/prepared-repository.d.ts +204 -0
  89. package/dist/prepared-repository.js +860 -0
  90. package/dist/prepared-repository.py +1307 -0
  91. package/dist/project-preparation.d.ts +62 -0
  92. package/dist/project-preparation.js +316 -0
  93. package/dist/project-sync-manifest.d.ts +4 -0
  94. package/dist/project-sync-manifest.js +5 -1
  95. package/dist/project-sync-transfer.js +1 -1
  96. package/dist/project-sync.d.ts +22 -4
  97. package/dist/project-sync.js +34 -8
  98. package/dist/project-template-setup.d.ts +33 -1
  99. package/dist/project-template-setup.js +59 -2
  100. package/dist/secret-ask.d.ts +72 -0
  101. package/dist/secret-ask.js +351 -0
  102. package/dist/seed-phases.d.ts +8 -1
  103. package/dist/seed-phases.js +17 -2
  104. package/dist/setup-cache.d.ts +7 -0
  105. package/dist/setup-cache.js +32 -0
  106. package/dist/t3-launch.d.ts +2 -1
  107. package/dist/t3-launch.js +12 -6
  108. package/dist/t3-mode.d.ts +8 -2
  109. package/dist/t3-mode.js +19 -4
  110. package/dist/t3-phone.d.ts +4 -2
  111. package/dist/t3-phone.js +12 -4
  112. package/dist/t3-proxy.d.ts +81 -0
  113. package/dist/t3-proxy.js +325 -0
  114. package/dist/terminal-apps.d.ts +168 -0
  115. package/dist/terminal-apps.js +574 -0
  116. package/dist/terminal-onboarding.js +6 -1
  117. package/dist/this-computer.d.ts +11 -0
  118. package/dist/this-computer.js +36 -0
  119. package/dist/thread-on-computer.js +4 -1
  120. package/dist/update.d.ts +2 -0
  121. package/dist/update.js +43 -9
  122. package/dist/worktree-twin.d.ts +10 -2
  123. package/dist/worktree-twin.js +27 -9
  124. package/package.json +5 -5
@@ -0,0 +1,62 @@
1
+ import type { Ctx, Handler } from './commands.js';
2
+ import type { PreparedTransfer } from './prepared-repository.js';
3
+ import { CodedError } from './errors.js';
4
+ export interface SelectedProject {
5
+ api_url: string;
6
+ folder: string;
7
+ enabled: boolean;
8
+ state: 'queued' | 'syncing' | 'setup' | 'ready' | 'paused' | 'error';
9
+ revision?: string;
10
+ deps_hash?: string;
11
+ workspace_id?: string;
12
+ project_id?: string;
13
+ /** What the last preparation saw: HEAD, the index, and each changed file's size and times (a change detector only). */
14
+ observed?: {
15
+ head: string;
16
+ index: string;
17
+ files: Record<string, string>;
18
+ };
19
+ updated_at?: string;
20
+ prepared_at?: string;
21
+ error?: string;
22
+ transfer?: PreparedTransfer;
23
+ /** The project's setup failed as it stands (no check to run, a failing install): tried again once it changes. */
24
+ blocked?: boolean;
25
+ /** Preparation VMs this revision's attempts deleted: the next open is a new one, never the deleted one again. */
26
+ attempt?: number;
27
+ }
28
+ /** The guest's setup state (`sf-agent template --json`): its steps, each failed one with the guest's error. */
29
+ export interface SetupStatus {
30
+ state?: string;
31
+ tasks?: {
32
+ name: string;
33
+ state: string;
34
+ error?: {
35
+ code?: string;
36
+ message: string;
37
+ };
38
+ }[];
39
+ }
40
+ export declare function setupFailure(status: SetupStatus): CodedError;
41
+ /** New content past this, or a dependency change, makes a quiet project worth preparing again. */
42
+ export declare const PREPARE_AGAIN_BYTES: number;
43
+ /** Quiet this long after the last edit before preparing again. */
44
+ export declare const QUIET_MS = 30000;
45
+ export declare const selectionId: (api: string, folder: string) => string;
46
+ export declare function selectedProjects(ctx: Pick<Ctx, 'store' | 'apiUrl'>): SelectedProject[];
47
+ export declare function selectProject(ctx: Pick<Ctx, 'store' | 'apiUrl'>, path: string): SelectedProject;
48
+ /** HEAD, the index and each changed file's metadata: cheap (no hashing), a detector of change, never of content. */
49
+ export declare function observeProject(folder: string, signal?: AbortSignal): Promise<NonNullable<SelectedProject['observed']>>;
50
+ /** Bytes of new or changed content since `before` (sizes of files whose metadata moved), and whether Git state moved. */
51
+ export declare function changedSince(before: SelectedProject['observed'], now: NonNullable<SelectedProject['observed']>): {
52
+ bytes: number;
53
+ git: boolean;
54
+ };
55
+ /** One selected project, one short-lived VM. Never touches a running chat's worktree. */
56
+ export declare function prepareSelectedProject(ctx: Ctx, selected: SelectedProject): Promise<void>;
57
+ /**
58
+ * Watches the selected projects only. Prepares a new selection at once; after edits, waits until the project is quiet
59
+ * and prepares again only when that would make a start faster. Offline failures retry with backoff.
60
+ */
61
+ export declare function runProjectPreparation(ctx: Ctx): Promise<void>;
62
+ export declare const PROJECT_PREPARATION_HANDLERS: Record<string, Handler>;
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Selected projects, prepared ahead of a start (0.22.0+). The user picks them (onboarding, settings, or
3
+ * `shard projects sync add`); nothing else is scanned or sent. For each, a short-lived preparation VM puts the
4
+ * project's current revision into the project cache, and when its dependencies changed, installs them and saves the
5
+ * project template, so a new chat sends only what changed since and boots with its dependencies installed.
6
+ *
7
+ * Preparation follows edits without chasing them: once a project has been quiet for a while it is prepared again
8
+ * when the change would slow a start (new content past a threshold, or a dependency change), never for a few lines
9
+ * a start sends in milliseconds anyway. No VM stays up: each preparation deletes its own.
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { watch } from 'node:fs';
13
+ import { lstat } from 'node:fs/promises';
14
+ import { setTimeout as delay } from 'node:timers/promises';
15
+ import { join, resolve } from 'node:path';
16
+ import { ShardfluxApiError } from '@shardflux/sdk';
17
+ import { out } from "./commands.js";
18
+ import { AgentStateStore } from "./agent-state.js";
19
+ import { localConversations } from "./conversations.js";
20
+ import { projectIdentity } from "./project-identity.js";
21
+ import { localChanges, preflightProjectSeed } from "./agent-repository.js";
22
+ import { projectGit } from "./project-sync-manifest.js";
23
+ import { inspectWorktree, pauseReason, twinSeed, worktreeId } from "./worktree-twin.js";
24
+ import { applyPreparedSnapshot, capturePreparedSnapshot, preparedCachePath, preparedGitConfig, pruneLocalPrepared, prunePreparedCache, releaseLocalSnapshot, transferPreparedSnapshot } from "./prepared-repository.js";
25
+ import { withTargetLock } from "./target-lock.js";
26
+ import { CodedError, UsageError } from "./errors.js";
27
+ import { dependencyHash } from "./project-template-setup.js";
28
+ /**
29
+ * A setup that ran and failed: the same files fail again, so it waits for a change instead of a timer. Reported as
30
+ * `project_setup_failed` with the failed step and its own message (0.23.0+; before, `internal_error`).
31
+ */
32
+ class SetupFailed extends CodedError {
33
+ }
34
+ export function setupFailure(status) {
35
+ const step = status.tasks?.find(t => t.state === 'failed');
36
+ const message = step ? `Project setup failed at ${step.name}: ${step.error?.message ?? 'the step failed'}` : 'Project setup failed.';
37
+ // A step setup could not work out (a missing lockfile) or a command that failed; the repository's own command for the
38
+ // step is the other way forward.
39
+ const first = step?.error?.code === 'setup_detection_unresolved' ? 'commit what the message names' : 'fix the cause in the repository and commit';
40
+ const fix = `${first}${step ? `, or set run for ${step.name} under steps in .shardflux/setup.yaml` : ''}; the project is prepared again after the change`;
41
+ return new SetupFailed('project_setup_failed', message, fix, step ? { step: step.name, ...(step.error?.code ? { error_code: step.error.code } : {}) } : undefined);
42
+ }
43
+ /** New content past this, or a dependency change, makes a quiet project worth preparing again. */
44
+ export const PREPARE_AGAIN_BYTES = 8 << 20;
45
+ /** Quiet this long after the last edit before preparing again. */
46
+ export const QUIET_MS = 30_000;
47
+ export const selectionId = (api, folder) => createHash('sha256').update(api).update('\0').update(folder).digest('hex');
48
+ export function selectedProjects(ctx) {
49
+ return Object.values(new AgentStateStore(ctx.store.dir).read().prepared_projects ?? {}).filter(p => p.api_url === ctx.apiUrl);
50
+ }
51
+ function patch(ctx, folder, update) {
52
+ new AgentStateStore(ctx.store.dir).update(doc => {
53
+ const selections = (doc.prepared_projects ?? {}), id = selectionId(ctx.apiUrl, folder), current = selections[id];
54
+ if (current)
55
+ selections[id] = { ...current, ...update, ...(!current.enabled && update.enabled === undefined ? { state: 'paused' } : {}), updated_at: new Date().toISOString() };
56
+ doc.prepared_projects = selections;
57
+ });
58
+ }
59
+ export function selectProject(ctx, path) {
60
+ const { folder } = projectIdentity(path), id = selectionId(ctx.apiUrl, folder);
61
+ new AgentStateStore(ctx.store.dir).update(doc => {
62
+ const selections = (doc.prepared_projects ?? {});
63
+ selections[id] = { ...selections[id], api_url: ctx.apiUrl, folder, enabled: true, state: 'queued', error: undefined, blocked: undefined };
64
+ doc.prepared_projects = selections;
65
+ });
66
+ return selectedProjects(ctx).find(p => p.folder === folder);
67
+ }
68
+ /** HEAD, the index and each changed file's metadata: cheap (no hashing), a detector of change, never of content. */
69
+ export async function observeProject(folder, signal) {
70
+ const head = (await projectGit(folder, ['rev-parse', 'HEAD'], signal)).trim();
71
+ const index = createHash('sha256').update(await projectGit(folder, ['ls-files', '--stage', '-z'], signal)).digest('hex');
72
+ const files = {};
73
+ for (const path of (await localChanges(folder, signal)).copy) {
74
+ try {
75
+ const st = await lstat(join(folder, path));
76
+ files[path] = `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}:${st.mode}`;
77
+ }
78
+ catch (error) {
79
+ if (error.code !== 'ENOENT')
80
+ throw error;
81
+ }
82
+ }
83
+ return { head, index, files };
84
+ }
85
+ /** Bytes of new or changed content since `before` (sizes of files whose metadata moved), and whether Git state moved. */
86
+ export function changedSince(before, now) {
87
+ let bytes = 0;
88
+ for (const [path, meta] of Object.entries(now.files))
89
+ if (before?.files[path] !== meta)
90
+ bytes += Number(meta.split(':')[0]) || 0;
91
+ return { bytes, git: !before || before.head !== now.head || before.index !== now.index || Object.keys(before.files).some(path => !(path in now.files)) };
92
+ }
93
+ /** One selected project, one short-lived VM. Never touches a running chat's worktree. */
94
+ export async function prepareSelectedProject(ctx, selected) {
95
+ await withTargetLock(join(ctx.store.dir, 'project-prepare-locks', selectionId(ctx.apiUrl, selected.folder)), async () => {
96
+ const current = selectedProjects(ctx).find(p => p.folder === selected.folder);
97
+ if (!current?.enabled)
98
+ return;
99
+ const cancel = new AbortController();
100
+ const watcher = watch(ctx.store.dir, (_event, name) => {
101
+ if (name?.toString() === 'agents.json' && !selectedProjects(ctx).find(p => p.folder === current.folder)?.enabled)
102
+ cancel.abort(new Error('Project preparation paused or removed.'));
103
+ });
104
+ ctx = { ...ctx, signal: AbortSignal.any([ctx.signal, cancel.signal]) };
105
+ let owned, setupStarted = false, revision, observed;
106
+ const identity = projectIdentity(current.folder), remote = identity.remote ?? `file://${identity.folder}`;
107
+ try {
108
+ const info = await inspectWorktree(identity.folder);
109
+ const paused = pauseReason(info);
110
+ if (paused)
111
+ throw new UsageError(paused);
112
+ await preflightProjectSeed(identity.folder, ctx.signal);
113
+ if (!info.head)
114
+ throw new UsageError('Commit this project once before preparing it.');
115
+ observed = await observeProject(identity.folder, ctx.signal);
116
+ const seed = await twinSeed(ctx.store.dir, identity.folder, info.head, ctx.store.profile(ctx.apiUrl).project_id);
117
+ const snapshot = await capturePreparedSnapshot(ctx.store.dir, identity.folder, seed, identity.remote, ctx.signal);
118
+ revision = snapshot.revision;
119
+ const deps = snapshot.manifest.deps_hash;
120
+ patch(ctx, identity.folder, { state: 'syncing', error: undefined });
121
+ const opened = await ctx.cloud().projectTemplates.prepare({ repoRemote: remote, depsHash: deps, worktreeId: worktreeId(identity.folder),
122
+ projectId: ctx.store.profile(ctx.apiUrl).project_id }, { signal: ctx.signal, idempotencyKey: `prepare-${snapshot.revision}-${current.attempt ?? 0}` });
123
+ owned = opened.workspace_id;
124
+ patch(ctx, identity.folder, { workspace_id: owned });
125
+ const ws = await ctx.cloud().workspaces.get(owned);
126
+ if (!ws.ready)
127
+ await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
128
+ if (ws.labels.shard_preparation !== 'true')
129
+ throw new Error('The API did not bind a preparation workspace.');
130
+ const cell = ws.cell(), cache = preparedCachePath(remote);
131
+ const pin = `prepare-${selectionId(ctx.apiUrl, identity.folder).slice(0, 32)}`;
132
+ const transfer = await transferPreparedSnapshot(cell, identity.folder, cache, snapshot, { signal: ctx.signal, pin });
133
+ if (opened.setup_required) {
134
+ // Dependencies changed: install them in a checkout of this revision and save the project template from it. The
135
+ // checkout sits where a conversation's worktree does, so a chat from the template starts with them in place.
136
+ const root = ws.labels.repo_path;
137
+ if (!root?.startsWith('/home/user/'))
138
+ throw new Error('Preparation workspace omitted its repository path.');
139
+ patch(ctx, identity.folder, { state: 'setup' });
140
+ await applyPreparedSnapshot(cell, cache, snapshot.revision, root, `shard/prepare-${worktreeId(identity.folder)}`, { signal: ctx.signal, preparation: true,
141
+ replacePreparation: true, dissociate: 'now', git: await preparedGitConfig(identity.folder, snapshot.manifest, ctx.signal), releasePin: pin });
142
+ const setup = await cell.exec.run(['sf-agent', 'setup-start', '--deps-hash', deps], { cwd: root, timeoutMs: 30_000, maxOutputBytes: 8192, signal: ctx.signal });
143
+ if (setup.exitCode !== 0)
144
+ throw new Error(`Project setup could not start: ${setup.stderr.trim()}`);
145
+ setupStarted = true;
146
+ // Ready means the dependency-keyed template is published, not just that an install command exited.
147
+ for (const deadline = Date.now() + 30 * 60_000;;) {
148
+ ctx.signal.throwIfAborted();
149
+ try {
150
+ if ((await ctx.cloud().projectTemplates.get(remote, { signal: ctx.signal })).deps_hash === deps)
151
+ break;
152
+ }
153
+ catch (error) {
154
+ if (!(error instanceof ShardfluxApiError && error.status === 404))
155
+ throw error;
156
+ }
157
+ const status = await cell.exec.run(['sf-agent', 'template', '--json'], { cwd: root, timeoutMs: 30_000, maxOutputBytes: 32768, signal: ctx.signal });
158
+ if (status.exitCode !== 0)
159
+ throw new Error(`Project setup status failed: ${status.stderr.trim()}`);
160
+ const state = JSON.parse(status.stdout);
161
+ if (state.state === 'failed')
162
+ throw setupFailure(state);
163
+ if (Date.now() > deadline)
164
+ throw new Error('The project template is still being saved; the next preparation picks it up.');
165
+ await delay(5000, undefined, { signal: ctx.signal });
166
+ }
167
+ }
168
+ await prunePreparedCache(cell, cache, ctx.signal).catch(() => undefined);
169
+ patch(ctx, identity.folder, { state: 'ready', revision: snapshot.revision, deps_hash: deps, observed, transfer, project_id: ws.data.project_id,
170
+ prepared_at: new Date().toISOString(), error: undefined, blocked: undefined, attempt: undefined });
171
+ await ctx.cloud().workspaces.delete(owned);
172
+ owned = undefined;
173
+ patch(ctx, identity.folder, { workspace_id: undefined });
174
+ }
175
+ catch (error) {
176
+ // An interrupted setup keeps its VM: the same keyed open resumes its install. A failed one, and anything else,
177
+ // leaves nothing behind.
178
+ const failed = error instanceof SetupFailed;
179
+ if (owned && (!setupStarted || failed)) {
180
+ await ctx.cloud().workspaces.delete(owned).catch(() => undefined);
181
+ patch(ctx, current.folder, { workspace_id: undefined, attempt: (current.attempt ?? 0) + 1 });
182
+ }
183
+ patch(ctx, current.folder, { state: ctx.signal.aborted ? 'queued' : 'error', error: (error instanceof Error ? error.message : String(error)).slice(0, 2000),
184
+ ...(failed ? { blocked: true, observed } : {}) });
185
+ throw error;
186
+ }
187
+ finally {
188
+ watcher.close();
189
+ if (revision)
190
+ await releaseLocalSnapshot(identity.folder, revision);
191
+ await pruneLocalPrepared(ctx.store.dir).catch(() => undefined);
192
+ }
193
+ }, ctx.signal);
194
+ }
195
+ /**
196
+ * Watches the selected projects only. Prepares a new selection at once; after edits, waits until the project is quiet
197
+ * and prepares again only when that would make a start faster. Offline failures retry with backoff.
198
+ */
199
+ export async function runProjectPreparation(ctx) {
200
+ const watchers = new Map(), changed = new Map(), retry = new Map();
201
+ try {
202
+ while (!ctx.signal.aborted) {
203
+ const projects = selectedProjects(ctx), active = new Set(projects.filter(p => p.enabled).map(p => p.folder));
204
+ for (const [folder, watcher] of watchers)
205
+ if (!active.has(folder)) {
206
+ watcher.close();
207
+ watchers.delete(folder);
208
+ changed.delete(folder);
209
+ }
210
+ for (const p of projects) {
211
+ if (!p.enabled)
212
+ continue;
213
+ if (!watchers.has(p.folder)) {
214
+ try {
215
+ const watcher = watch(p.folder, { recursive: true }, (_event, path) => {
216
+ const name = path?.toString() ?? '';
217
+ if (name.startsWith('.git/') && !/^\.git\/(?:index|HEAD|refs\/heads\/|packed-refs)$/.test(name))
218
+ return;
219
+ changed.set(p.folder, Date.now());
220
+ });
221
+ watcher.on('error', () => { watcher.close(); watchers.delete(p.folder); });
222
+ watchers.set(p.folder, watcher);
223
+ }
224
+ catch { /* the periodic pass below still notices changes */ }
225
+ }
226
+ const backoff = retry.get(p.folder);
227
+ if (backoff && backoff.at > Date.now())
228
+ continue;
229
+ const touched = changed.get(p.folder);
230
+ if (touched !== undefined && Date.now() - touched < QUIET_MS)
231
+ continue;
232
+ // A setup that failed as the project stands waits for an edit (its files, setup.yaml, a commit), not a timer.
233
+ if (p.state === 'error' && p.blocked) {
234
+ const delta = changedSince(p.observed, await observeProject(p.folder, ctx.signal).catch(() => p.observed));
235
+ if (!delta.bytes && !delta.git) {
236
+ changed.delete(p.folder);
237
+ continue;
238
+ }
239
+ }
240
+ // A ready project without edits is rechecked every ten minutes (a missed watcher event, a branch switch).
241
+ if (p.state === 'ready' && touched === undefined && Date.now() - Date.parse(p.updated_at ?? '1970-01-01') < 600_000)
242
+ continue;
243
+ try {
244
+ if (p.state === 'ready') {
245
+ const now = await observeProject(p.folder, ctx.signal), delta = changedSince(p.observed, now);
246
+ const settled = delta.bytes < PREPARE_AGAIN_BYTES && !delta.git;
247
+ if (settled) {
248
+ patch(ctx, p.folder, {});
249
+ changed.delete(p.folder);
250
+ continue;
251
+ }
252
+ // Small edits travel with the start; only a dependency change or bulk content is worth a VM now.
253
+ if (delta.bytes < PREPARE_AGAIN_BYTES && dependencyHash(p.folder) === p.deps_hash) {
254
+ patch(ctx, p.folder, {});
255
+ changed.delete(p.folder);
256
+ continue;
257
+ }
258
+ }
259
+ const before = changed.get(p.folder);
260
+ await prepareSelectedProject(ctx, p);
261
+ retry.delete(p.folder);
262
+ if (changed.get(p.folder) === before)
263
+ changed.delete(p.folder);
264
+ }
265
+ catch (error) {
266
+ if (ctx.signal.aborted)
267
+ break;
268
+ if (error instanceof SetupFailed) {
269
+ retry.delete(p.folder);
270
+ continue;
271
+ }
272
+ const wait = Math.min((backoff?.wait ?? 30_000) * 2, 30 * 60_000);
273
+ retry.set(p.folder, { at: Date.now() + wait, wait });
274
+ }
275
+ }
276
+ await delay(2000, undefined, { signal: ctx.signal }).catch(() => undefined);
277
+ }
278
+ }
279
+ finally {
280
+ for (const watcher of watchers.values())
281
+ watcher.close();
282
+ }
283
+ }
284
+ export const PROJECT_PREPARATION_HANDLERS = {
285
+ 'projects sync list'(ctx) {
286
+ const selected = selectedProjects(ctx), known = [...new Set(Object.values(localConversations(ctx)).map(r => r.folder))];
287
+ out(ctx, selected.map(p => `${p.folder}\t${p.state}${p.error ? `\t${p.error}` : ''}`).join('\n') + (selected.length ? '\n' : ''), { data: selected, known });
288
+ return Promise.resolve(0);
289
+ },
290
+ async 'projects sync add'(ctx, values, [path]) {
291
+ if (!path)
292
+ throw new UsageError('Choose a local project path.');
293
+ const p = selectProject(ctx, path);
294
+ if (values.wait === true)
295
+ await prepareSelectedProject(ctx, p);
296
+ out(ctx, `Selected ${p.folder}; it is prepared in the background.\n`, { project: selectedProjects(ctx).find(s => s.folder === p.folder) });
297
+ return 0;
298
+ },
299
+ 'projects sync pause'(ctx, _values, [path]) {
300
+ if (!path)
301
+ throw new UsageError('Choose a selected project path.');
302
+ const folder = selectedProjects(ctx).find(p => p.folder === resolve(path))?.folder ?? projectIdentity(path).folder;
303
+ patch(ctx, folder, { enabled: false, state: 'paused' });
304
+ out(ctx, `Paused ${folder}.\n`, { project: selectedProjects(ctx).find(p => p.folder === folder) });
305
+ return Promise.resolve(0);
306
+ },
307
+ 'projects sync remove'(ctx, _values, [path]) {
308
+ if (!path)
309
+ throw new UsageError('Choose a selected project path.');
310
+ const folder = selectedProjects(ctx).find(p => p.folder === resolve(path))?.folder ?? projectIdentity(path).folder;
311
+ new AgentStateStore(ctx.store.dir).update(doc => { const selections = (doc.prepared_projects ?? {}); delete selections[selectionId(ctx.apiUrl, folder)]; doc.prepared_projects = selections; });
312
+ out(ctx, `Removed ${folder} from background preparation.\n`, { removed: true, folder });
313
+ return Promise.resolve(0);
314
+ },
315
+ async 'projects sync run'(ctx) { await runProjectPreparation(ctx); return 0; },
316
+ };
@@ -8,6 +8,10 @@ export interface SyncChange {
8
8
  }
9
9
  export declare function validSyncPath(path: string): boolean;
10
10
  export declare function excludedSyncPath(path: string): boolean;
11
+ /**
12
+ * A symbolic link's identity is its target: its own mode is not content (Linux reports 0777, macOS the mode it was
13
+ * made with, 0755), so a link that is the same on both sides is never a change or a conflict (0.21.1+).
14
+ */
11
15
  export declare function contentIdentity(entry?: FileManifestEntry): string;
12
16
  /** Deletions have an observation timestamp: a detached filesystem has no deletion clock. Ties favor the laptop. */
13
17
  export declare function diffManifests(local: Manifest, remote: Manifest, common: Common, observedAt?: number, blocked?: Set<string>): SyncChange[];
@@ -8,8 +8,12 @@ export function validSyncPath(path) {
8
8
  return path.length > 0 && Buffer.byteLength(path) <= 4096 && !path.includes('\0') && !path.includes('\\') && !path.startsWith('/') && !path.split('/').some((part) => !part || part === '.' || part === '..' || part === '.git' || part === '.shard');
9
9
  }
10
10
  export function excludedSyncPath(path) { return path.split('/').some((part) => part === '.git' || part === '.shard' || part.includes('.shard-tmp-')); }
11
+ /**
12
+ * A symbolic link's identity is its target: its own mode is not content (Linux reports 0777, macOS the mode it was
13
+ * made with, 0755), so a link that is the same on both sides is never a change or a conflict (0.21.1+).
14
+ */
11
15
  export function contentIdentity(entry) {
12
- return entry ? `${entry.sha256}:${entry.mode}:${entry.symlink ? 'link' : 'file'}` : 'absent';
16
+ return entry ? `${entry.sha256}:${entry.symlink ? 0o777 : entry.mode}:${entry.symlink ? 'link' : 'file'}` : 'absent';
13
17
  }
14
18
  /** Deletions have an observation timestamp: a detached filesystem has no deletion clock. Ties favor the laptop. */
15
19
  export function diffManifests(local, remote, common, observedAt = Date.now(), blocked = new Set()) {
@@ -45,7 +45,7 @@ export async function readLocalSyncFile(folder, entry) {
45
45
  await file.close();
46
46
  }
47
47
  }
48
- if ((st.mode & 0o777) !== entry.mode)
48
+ if (!entry.symlink && (st.mode & 0o777) !== entry.mode)
49
49
  throw new Error(`${entry.path} mode changed during sync`);
50
50
  verifySyncBytes(entry, data);
51
51
  return { entry, data };
@@ -1,7 +1,7 @@
1
1
  import type { Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
3
  import type { projectIdentity } from './project-identity.js';
4
- import type { Common } from './project-sync-manifest.js';
4
+ import type { Common, LocalHashCache } from './project-sync-manifest.js';
5
5
  import type { SyncCell } from './project-sync-transfer.js';
6
6
  export interface ProjectSyncConflict {
7
7
  path: string;
@@ -19,6 +19,8 @@ export interface ProjectSync {
19
19
  root: string;
20
20
  conflicts(): readonly ProjectSyncConflict[];
21
21
  reconcile(): Promise<void>;
22
+ /** The last reconcile found both sides equal, so its baseline is current (0.21.1+). */
23
+ settled(): boolean;
22
24
  stop(): Promise<void>;
23
25
  }
24
26
  export { projectWorkspacePath } from './agent-repository.js';
@@ -34,9 +36,25 @@ export declare function projectReconciler(options: {
34
36
  save: (state: ProjectSyncState) => void;
35
37
  report: (message: string) => void;
36
38
  signal?: AbortSignal;
37
- }): () => Promise<void>;
38
- export declare function startProjectSync(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>, options?: {
39
+ vmBaseline?: boolean;
40
+ cache?: LocalHashCache;
41
+ inFlight?: () => Iterable<string>;
42
+ }): (() => Promise<void>) & {
43
+ settled(): boolean;
44
+ };
45
+ export type ProjectSyncBoundary = Pick<ProjectSync, 'root' | 'conflicts' | 'reconcile' | 'settled'>;
46
+ export interface ProjectSyncOptions {
39
47
  root?: string;
40
48
  agent?: string;
41
49
  seed?: boolean;
42
- }): Promise<ProjectSync>;
50
+ stateKey?: string;
51
+ /** The VM has run nothing since this checkout seeded it: a boundary with no saved state starts from its files (0.21.1+). */
52
+ vmBaseline?: boolean;
53
+ /** This computer's file hashes, filled ahead of the first reconcile (localManifest with the same map, 0.21.1+). */
54
+ cache?: LocalHashCache;
55
+ /** Paths another transfer is still bringing to the VM (a prepared start's large media, 0.22.0+): left alone until then. */
56
+ inFlight?: () => Iterable<string>;
57
+ }
58
+ /** Editors reconcile between turns without a watcher keeping an idle VM active. */
59
+ export declare function projectSyncBoundary(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>, options?: ProjectSyncOptions): Promise<ProjectSyncBoundary>;
60
+ export declare function startProjectSync(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>, options?: ProjectSyncOptions): Promise<ProjectSync>;
@@ -15,9 +15,10 @@ export function projectSyncConflicts(dir, key) {
15
15
  /** Core reconciler used by the attach module and by the real Linux harness. */
16
16
  export function projectReconciler(options) {
17
17
  const { folder, root, cell, agent, state, save, report, signal } = options;
18
+ let settled = false, vmBaseline = options.vmBaseline === true;
18
19
  if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent))
19
20
  throw new Error('invalid conflict agent name');
20
- const cache = new Map();
21
+ const cache = options.cache ?? new Map();
21
22
  let serial = Promise.resolve();
22
23
  const run = async () => {
23
24
  signal?.throwIfAborted();
@@ -35,7 +36,21 @@ export function projectReconciler(options) {
35
36
  report(`Project sync skipped ${side} ${skip.path}: ${skip.reason}`);
36
37
  }
37
38
  }
38
- const changes = diffManifests(local, remote, state.common, Date.now(), blocked);
39
+ // Files another transfer is still bringing to the VM: this pass leaves them alone (a later one sees them landed).
40
+ for (const path of options.inFlight?.() ?? [])
41
+ blocked.add(path);
42
+ // Until a pass completes, a VM that has run nothing since this checkout seeded it holds the baseline of every file
43
+ // both sides have: an edit made here before the first pass is this computer's change, not a conflict (0.21.1+).
44
+ let common = state.common;
45
+ if (vmBaseline) {
46
+ common = Object.assign(Object.create(null), state.common);
47
+ for (const path of Object.keys(remote))
48
+ if (local[path] && !Object.hasOwn(common, path))
49
+ common[path] = contentIdentity(remote[path]);
50
+ }
51
+ const changes = diffManifests(local, remote, common, Date.now(), blocked);
52
+ // A pass that moved files advances the baseline only for paths it already had: the next pass settles the rest.
53
+ settled = changes.length === 0;
39
54
  // Laptop-side deletions run before writes and VM-side deletions before uploads, so a directory can become a file.
40
55
  const pushes = [], removals = [];
41
56
  const expected = Object.create(null);
@@ -105,10 +120,12 @@ export function projectReconciler(options) {
105
120
  }
106
121
  }
107
122
  save(state);
123
+ vmBaseline = false;
108
124
  };
109
- return () => { const next = serial.then(run); serial = next.catch(() => undefined); return next; };
125
+ return Object.assign(() => { settled = false; const next = serial.then(run); serial = next.catch(() => undefined); return next; }, { settled: () => settled });
110
126
  }
111
- export async function startProjectSync(ctx, ws, project, options = {}) {
127
+ /** Editors reconcile between turns without a watcher keeping an idle VM active. */
128
+ export async function projectSyncBoundary(ctx, ws, project, options = {}) {
112
129
  if (!options.root && ws.labels.agent && ws.labels.agent !== 'main')
113
130
  throw new Error('two-way project sync requires a conversation worktree');
114
131
  const root = options.root ?? projectWorkspacePath(project);
@@ -117,9 +134,10 @@ export async function startProjectSync(ctx, ws, project, options = {}) {
117
134
  if (options.seed !== false)
118
135
  await seedAgentRepository(ctx, ws, project.folder, { root });
119
136
  const store = new AgentStateStore(ctx.store.dir);
120
- const key = `${ws.id}:${project.folder}`;
137
+ const key = options.stateKey ?? `${ws.id}:${project.folder}`;
121
138
  const states = store.read().project_sync;
122
- const state = states?.[key] ?? { common: Object.create(null), conflicts: [] };
139
+ const stored = states?.[key];
140
+ const state = stored ?? { common: Object.create(null), conflicts: [] };
123
141
  const save = (value) => {
124
142
  store.update(doc => {
125
143
  const current = (doc.project_sync ?? {});
@@ -128,7 +146,15 @@ export async function startProjectSync(ctx, ws, project, options = {}) {
128
146
  });
129
147
  };
130
148
  const report = (message) => { ctx.io.stderr.write(`${message}\n`); };
131
- const reconcile = projectReconciler({ folder: project.folder, root, agent: options.agent ?? ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal });
149
+ const reconcile = projectReconciler({ folder: project.folder, root, agent: options.agent ?? ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal,
150
+ vmBaseline: options.vmBaseline === true && !stored, ...(options.cache ? { cache: options.cache } : {}), ...(options.inFlight ? { inFlight: options.inFlight } : {}) });
151
+ return { root, conflicts: () => state.conflicts.slice(), reconcile, settled: () => reconcile.settled() };
152
+ }
153
+ export async function startProjectSync(ctx, ws, project, options = {}) {
154
+ const boundary = await projectSyncBoundary(ctx, ws, project, options);
155
+ const { root, reconcile } = boundary;
156
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
157
+ const report = (message) => { ctx.io.stderr.write(`${message}\n`); };
132
158
  const abort = new AbortController();
133
159
  const signal = ctx.signal ? AbortSignal.any([ctx.signal, abort.signal]) : abort.signal;
134
160
  let stopped = false, dirty = false;
@@ -219,7 +245,7 @@ export async function startProjectSync(ctx, ws, project, options = {}) {
219
245
  throw error;
220
246
  }
221
247
  return {
222
- root, conflicts: () => state.conflicts.slice(), reconcile,
248
+ ...boundary,
223
249
  async stop() {
224
250
  if (stopped)
225
251
  return;
@@ -1,4 +1,4 @@
1
- import type { Workspace } from '@shardflux/sdk';
1
+ import type { CellClient, Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx, Handler } from './commands.js';
3
3
  import { projectIdentity } from './project-identity.js';
4
4
  export declare function dependencyHash(folder: string): string;
@@ -14,4 +14,36 @@ export declare function prepareProjectTemplate(ctx: Ctx, project: ReturnType<typ
14
14
  shard_repo_remote: string;
15
15
  };
16
16
  }>;
17
+ /** A new conversation's project setup: the hash its worktree is set up with, once its VM carries the setup labels. */
18
+ export interface ConversationSetup {
19
+ hash: string;
20
+ labelled: Promise<void>;
21
+ }
22
+ /**
23
+ * Whether a new conversation sets its repository up (0.21.1+): when the project has no template for the repository's
24
+ * current dependency hash. Read as the conversation starts, so the hash is of the files it is seeded with; the API
25
+ * read runs while its VM starts. Never fails the start: an unreadable answer is reported and the conversation runs
26
+ * without setup.
27
+ */
28
+ export declare function readConversationSetup(ctx: Ctx, project: ReturnType<typeof projectIdentity>): Promise<{
29
+ depsHash: string;
30
+ labels: Record<string, string>;
31
+ } | undefined>;
32
+ /**
33
+ * The conversation's VM gets the labels a project workspace has, with its worktree as the repository, so the setup
34
+ * that passes there saves the project template the next conversation starts from. Runs beside the agent's start; the
35
+ * setup itself is queued once it is done. A failed label write is reported: setup still prepares this conversation.
36
+ */
37
+ export declare function labelConversationSetup(ctx: Ctx, ws: Workspace, worktree: string, setup: {
38
+ depsHash: string;
39
+ labels: Record<string, string>;
40
+ } | undefined): ConversationSetup | undefined;
41
+ /**
42
+ * The hash a new conversation's worktree is set up with, recorded in its VM before the seed. Its guest starts the agent
43
+ * as soon as the repository appears, and with this record starts it with the setup's context and toolchains on PATH
44
+ * (services/guest-agent conversation_runtime.go setupIntent; a guest without it ignores the record).
45
+ */
46
+ export declare function recordSetupIntent(cell: Pick<CellClient, 'exec'>, c: {
47
+ id: string;
48
+ }, depsHash: string, signal?: AbortSignal): Promise<void>;
17
49
  export declare const PROJECT_TEMPLATE_HANDLERS: Record<string, Handler>;