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