@shardflux/cli 0.18.1 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -1
- package/README.md +27 -0
- package/dist/account-args.js +1 -0
- package/dist/agent-commands.d.ts +1 -0
- package/dist/agent-commands.js +15 -5
- package/dist/agent-config-files.d.ts +16 -2
- package/dist/agent-config-files.js +48 -61
- package/dist/agent-config-sync.d.ts +12 -1
- package/dist/agent-config-sync.js +228 -54
- package/dist/agent-repository.d.ts +39 -9
- package/dist/agent-repository.js +309 -92
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +2 -0
- package/dist/agent-target-commands.js +45 -15
- package/dist/args.d.ts +1 -1
- package/dist/args.js +14 -2
- package/dist/conversation-router.js +7 -11
- package/dist/conversation-stash.js +64 -18
- package/dist/conversation-terminal.d.ts +1 -1
- package/dist/conversation-terminal.js +9 -10
- package/dist/conversations.d.ts +9 -2
- package/dist/conversations.js +23 -6
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/files-mirror.d.ts +1 -9
- package/dist/files-mirror.js +19 -3
- package/dist/format.js +37 -2
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +15 -2
- package/dist/initial-prompt.js +29 -4
- package/dist/local-sessions.d.ts +60 -0
- package/dist/local-sessions.js +445 -0
- package/dist/main.js +12 -0
- package/dist/move.d.ts +44 -0
- package/dist/move.js +248 -0
- package/dist/moved-sessions.d.ts +33 -0
- package/dist/moved-sessions.js +91 -0
- package/dist/onboarding.d.ts +18 -3
- package/dist/onboarding.js +72 -46
- package/dist/project-sync-manifest.d.ts +31 -3
- package/dist/project-sync-manifest.js +192 -108
- package/dist/project-sync-transfer.d.ts +50 -4
- package/dist/project-sync-transfer.js +404 -106
- package/dist/project-sync.js +30 -33
- package/dist/seed-phases.d.ts +29 -0
- package/dist/seed-phases.js +83 -0
- package/dist/t3-mode.d.ts +7 -2
- package/dist/t3-mode.js +39 -5
- package/dist/thread-on-computer.js +4 -0
- package/dist/worktree-twin.d.ts +2 -6
- package/dist/worktree-twin.js +16 -51
- package/package.json +5 -5
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
1
4
|
import { homedir } from 'node:os';
|
|
2
5
|
import { join } from 'node:path';
|
|
3
6
|
import { parse, stringify } from 'smol-toml';
|
|
4
|
-
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
7
|
+
import { ShardfluxApiError, tarEnd, tarHeader, tarPadding } from '@shardflux/sdk';
|
|
5
8
|
import { AgentStateStore } from "./agent-state.js";
|
|
6
9
|
import { UsageError } from "./errors.js";
|
|
7
10
|
import { collectPlugins, collectUserFiles, object, readObject, readOptional, rewritePaths } from "./agent-config-files.js";
|
|
8
11
|
import { digest, prepareMcp } from "./agent-config-mcp.js";
|
|
9
12
|
import { configIssueLines } from "./agent-config-report.js";
|
|
10
13
|
const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
|
|
14
|
+
const parseFailure = (e) => e instanceof SyntaxError || (e instanceof Error && /^expected an object in /.test(e.message));
|
|
11
15
|
const markerPath = '.config/shardflux/agent-config.json';
|
|
12
16
|
const safePath = (path) => !path.split('/').some(p => !p || p === '.' || p === '..') &&
|
|
13
17
|
(/^(?:\.claude\/(?:settings\.json|CLAUDE\.md)|\.codex\/(?:config\.toml|AGENTS\.md))$/.test(path) ||
|
|
@@ -96,19 +100,129 @@ async function bindMcpSecret(ctx, ws, secrets, spec) {
|
|
|
96
100
|
ctx.io.stderr.write(`MCP secret bound to ${ws.key}; an already running agent picks it up when restarted.\n`);
|
|
97
101
|
return true;
|
|
98
102
|
}
|
|
99
|
-
|
|
103
|
+
/** At least this many files to check go into the VM as one archive and one command instead of a stat and a write each. */
|
|
104
|
+
export const BULK_MIN_FILES = 8;
|
|
105
|
+
/**
|
|
106
|
+
* Applies a whole pass in two requests (0.19.0+): one tar archive written into the VM, and one command that compares
|
|
107
|
+
* each member with what is there (a regular file with the same SHA-256 and mode is left alone; anything else that is
|
|
108
|
+
* not a regular file is reported and kept), replaces the rest atomically, and removes the owned files that are gone
|
|
109
|
+
* locally while they still hold what this sync wrote. A 3,000-file config took ~6,000 sequential requests (5 minutes)
|
|
110
|
+
* file by file. Undefined when the VM cannot run it (no python3): the caller applies the pass file by file.
|
|
111
|
+
*/
|
|
112
|
+
const BULK_APPLY = String.raw `
|
|
113
|
+
import hashlib, json, os, sys, tarfile
|
|
114
|
+
archive, home = sys.argv[1], sys.argv[2]
|
|
115
|
+
remove = json.loads(sys.stdin.read() or '[]')
|
|
116
|
+
out = {'existed': [], 'written': [], 'not_file': [], 'removed': [], 'changed': []}
|
|
117
|
+
def digest(path):
|
|
118
|
+
h = hashlib.sha256()
|
|
119
|
+
with open(path, 'rb') as f:
|
|
120
|
+
for chunk in iter(lambda: f.read(1 << 20), b''): h.update(chunk)
|
|
121
|
+
return h.hexdigest()
|
|
122
|
+
try:
|
|
123
|
+
with tarfile.open(archive) as tar:
|
|
124
|
+
for m in tar:
|
|
125
|
+
if not m.isfile(): continue
|
|
126
|
+
dest = os.path.join(home, m.name)
|
|
127
|
+
data = tar.extractfile(m).read()
|
|
128
|
+
mode = m.mode & 0o7777
|
|
129
|
+
if os.path.lexists(dest):
|
|
130
|
+
out['existed'].append(m.name)
|
|
131
|
+
if os.path.islink(dest) or not os.path.isfile(dest):
|
|
132
|
+
out['not_file'].append(m.name); continue
|
|
133
|
+
if (os.stat(dest).st_mode & 0o7777) == mode and digest(dest) == hashlib.sha256(data).hexdigest(): continue
|
|
134
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
135
|
+
tmp = dest + '.sf-sync'
|
|
136
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
137
|
+
with os.fdopen(fd, 'wb') as f: f.write(data)
|
|
138
|
+
os.chmod(tmp, mode)
|
|
139
|
+
os.replace(tmp, dest)
|
|
140
|
+
out['written'].append(m.name)
|
|
141
|
+
finally:
|
|
142
|
+
os.unlink(archive)
|
|
143
|
+
for name, expected in remove:
|
|
144
|
+
dest = os.path.join(home, name)
|
|
145
|
+
if not os.path.lexists(dest): continue
|
|
146
|
+
if os.path.islink(dest) or not os.path.isfile(dest) or digest(dest) != expected: out['changed'].append(name); continue
|
|
147
|
+
os.unlink(dest); out['removed'].append(name)
|
|
148
|
+
print(json.dumps(out))
|
|
149
|
+
`;
|
|
150
|
+
async function bulkApply(cell, home, files, remove, signal) {
|
|
151
|
+
const parts = [];
|
|
152
|
+
let size = 0;
|
|
153
|
+
const push = (bytes) => { parts.push(bytes); size += bytes.length; };
|
|
154
|
+
for (const [path, file] of files) {
|
|
155
|
+
push(tarHeader({ path, type: 'file', mode: Number.parseInt(file.mode, 8), size: file.content.length }));
|
|
156
|
+
push(file.content);
|
|
157
|
+
push(tarPadding(file.content.length));
|
|
158
|
+
}
|
|
159
|
+
push(tarEnd(size));
|
|
160
|
+
const archive = Buffer.concat(parts);
|
|
161
|
+
const path = `${home}/.config/shardflux/.config-sync-${randomUUID()}.tar`;
|
|
162
|
+
await cell.files.write(path, archive, { mode: '0600', createParents: true });
|
|
163
|
+
const run = await cell.exec.run(['python3', '-c', BULK_APPLY, path, home], { stdin: JSON.stringify(remove), signal, timeoutMs: 120_000, maxOutputBytes: 16 << 20 });
|
|
164
|
+
if (run.exitCode !== 0) {
|
|
165
|
+
await cell.files.remove(path).catch(() => undefined);
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const result = JSON.parse(run.stdout);
|
|
170
|
+
return Array.isArray(result.written) && Array.isArray(result.existed) ? result : undefined;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export async function applyConfigFiles(cell, home, files, previous, trustManifest, issues, signal, deleteMissing = true, progress = () => { }, trusted = new Set()) {
|
|
100
177
|
const next = {};
|
|
101
178
|
const copied = [], removed = [];
|
|
179
|
+
const pending = [];
|
|
102
180
|
for (const [path, file] of files) {
|
|
103
|
-
signal.throwIfAborted();
|
|
104
181
|
if (!safePath(path))
|
|
105
182
|
throw new Error('agent config attempted to write outside its allowlist');
|
|
106
|
-
const hash = digest(file.content);
|
|
107
183
|
const old = previous[path];
|
|
108
|
-
if (trustManifest && old?.hash ===
|
|
184
|
+
if ((trustManifest || trusted.has(path)) && old?.hash === digest(file.content) && old.mode === file.mode)
|
|
109
185
|
next[path] = old;
|
|
186
|
+
else
|
|
187
|
+
pending.push([path, file]);
|
|
188
|
+
}
|
|
189
|
+
const gone = [];
|
|
190
|
+
for (const [path, old] of Object.entries(deleteMissing ? previous : {})) {
|
|
191
|
+
if (files.has(path))
|
|
110
192
|
continue;
|
|
193
|
+
if (!safePath(path))
|
|
194
|
+
throw new Error('agent config manifest contains a path outside its allowlist');
|
|
195
|
+
if (old.created)
|
|
196
|
+
gone.push([path, old]);
|
|
197
|
+
}
|
|
198
|
+
if (pending.length + gone.length >= BULK_MIN_FILES) {
|
|
199
|
+
signal.throwIfAborted();
|
|
200
|
+
const bulk = await bulkApply(cell, home, pending, gone.map(([path, old]) => [path, old.hash]), signal);
|
|
201
|
+
if (bulk) {
|
|
202
|
+
const existed = new Set(bulk.existed), notFile = new Set(bulk.not_file), written = new Set(bulk.written);
|
|
203
|
+
for (const [path, file] of pending) {
|
|
204
|
+
if (notFile.has(path)) {
|
|
205
|
+
issues.push({ item: path, reason: 'remote path is not a regular file' });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (written.has(path))
|
|
209
|
+
copied.push(path);
|
|
210
|
+
next[path] = { hash: digest(file.content), mode: file.mode, created: previous[path]?.created ?? !existed.has(path) };
|
|
211
|
+
progress(path, next[path]);
|
|
212
|
+
}
|
|
213
|
+
for (const path of bulk.changed)
|
|
214
|
+
issues.push({ item: path, reason: 'remote file was changed; deletion preserved it' });
|
|
215
|
+
for (const path of bulk.removed) {
|
|
216
|
+
removed.push(path);
|
|
217
|
+
progress(path, undefined);
|
|
218
|
+
}
|
|
219
|
+
return { files: inOrder(files, next), copied, removed };
|
|
111
220
|
}
|
|
221
|
+
}
|
|
222
|
+
for (const [path, file] of pending) {
|
|
223
|
+
signal.throwIfAborted();
|
|
224
|
+
const hash = digest(file.content);
|
|
225
|
+
const old = previous[path];
|
|
112
226
|
let exists = false, same = false;
|
|
113
227
|
try {
|
|
114
228
|
const info = await cell.files.stat(`${home}/${path}`, { revision: true });
|
|
@@ -130,14 +244,8 @@ export async function applyConfigFiles(cell, home, files, previous, trustManifes
|
|
|
130
244
|
next[path] = { hash, mode: file.mode, created: old?.created ?? !exists };
|
|
131
245
|
progress(path, next[path]);
|
|
132
246
|
}
|
|
133
|
-
for (const [path, old] of
|
|
247
|
+
for (const [path, old] of gone) {
|
|
134
248
|
signal.throwIfAborted();
|
|
135
|
-
if (files.has(path))
|
|
136
|
-
continue;
|
|
137
|
-
if (!safePath(path))
|
|
138
|
-
throw new Error('agent config manifest contains a path outside its allowlist');
|
|
139
|
-
if (!old.created)
|
|
140
|
-
continue;
|
|
141
249
|
try {
|
|
142
250
|
const info = await cell.files.stat(`${home}/${path}`, { revision: true });
|
|
143
251
|
if (info.type !== 'file' || info.revision !== old.hash) {
|
|
@@ -153,7 +261,29 @@ export async function applyConfigFiles(cell, home, files, previous, trustManifes
|
|
|
153
261
|
throw error;
|
|
154
262
|
}
|
|
155
263
|
}
|
|
156
|
-
return { files: next, copied, removed };
|
|
264
|
+
return { files: inOrder(files, next), copied, removed };
|
|
265
|
+
}
|
|
266
|
+
/** The manifest lists files in the local order, so an unchanged config hashes the same whichever path applied it. */
|
|
267
|
+
function inOrder(files, next) {
|
|
268
|
+
const ordered = {};
|
|
269
|
+
for (const path of files.keys())
|
|
270
|
+
if (next[path])
|
|
271
|
+
ordered[path] = next[path];
|
|
272
|
+
return ordered;
|
|
273
|
+
}
|
|
274
|
+
/** Plugins named in the plan are installed by the VM's own Claude Code, from their marketplaces (one command). */
|
|
275
|
+
async function installPlugins(cell, plan, issues, signal) {
|
|
276
|
+
const script = 'command -v claude >/dev/null 2>&1 || { echo "missing"; exit 0; }\n' +
|
|
277
|
+
'n=$1; shift; while [ "$n" -gt 0 ]; do claude plugin marketplace add "$1" >/dev/null 2>&1 || true; shift; n=$((n-1)); done\n' +
|
|
278
|
+
'for id in "$@"; do claude plugin install "$id" --scope user >/dev/null 2>&1 || echo "failed $id"; done\n';
|
|
279
|
+
const run = await cell.exec.run(['sh', '-c', script, 'shard-plugins', String(plan.marketplaces.length), ...plan.marketplaces.map(m => m.source), ...plan.plugins], { signal, timeoutMs: 300_000, maxOutputBytes: 65536 });
|
|
280
|
+
if (run.stdout.trim() === 'missing') {
|
|
281
|
+
issues.push({ item: 'plugins', reason: 'Claude Code is not installed in this VM' });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
for (const line of run.stdout.split('\n'))
|
|
285
|
+
if (line.startsWith('failed '))
|
|
286
|
+
issues.push({ item: `plugin:${line.slice(7)}`, reason: 'could not be installed in the VM' });
|
|
157
287
|
}
|
|
158
288
|
export async function syncAgentConfig(ctx, ws) {
|
|
159
289
|
const home = ctx.io.env.HOME || homedir();
|
|
@@ -162,8 +292,18 @@ export async function syncAgentConfig(ctx, ws) {
|
|
|
162
292
|
const issues = [];
|
|
163
293
|
const files = await collectUserFiles(home, issues);
|
|
164
294
|
const codexBytes = await readOptional(join(home, '.codex/config.toml'));
|
|
165
|
-
|
|
166
|
-
|
|
295
|
+
// One file that does not parse is skipped with a reason; it never stops the rest of the sync.
|
|
296
|
+
let claude;
|
|
297
|
+
try {
|
|
298
|
+
claude = await readObject(join(home, '.claude.json'));
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (!parseFailure(error))
|
|
302
|
+
throw error;
|
|
303
|
+
issues.push({ item: '.claude.json', reason: 'not valid JSON; its MCP servers were not copied' });
|
|
304
|
+
}
|
|
305
|
+
const plugins = await collectPlugins(home, issues);
|
|
306
|
+
const hasPlugins = plugins.marketplaces.length > 0 || plugins.plugins.length > 0;
|
|
167
307
|
const empty = { workspace_id: ws.id, workspace_key: ws.key, copied: [], removed: [], skipped: issues, mcp: [], synced_at: null };
|
|
168
308
|
if (!files.size && !codexBytes && !hasPlugins && !Object.keys(object(claude?.mcpServers)).length && !previous)
|
|
169
309
|
return empty;
|
|
@@ -172,17 +312,6 @@ export async function syncAgentConfig(ctx, ws) {
|
|
|
172
312
|
const remoteHome = run.stdout.trim();
|
|
173
313
|
if (run.exitCode !== 0 || !/^\/(?:[a-zA-Z0-9._-]+\/)*[a-zA-Z0-9._-]+$/.test(remoteHome) || remoteHome.split('/').some(p => p === '..' || p === '.'))
|
|
174
314
|
throw new Error('agent config could not resolve the VM user home');
|
|
175
|
-
const skippedPlugins = await collectPlugins(home, remoteHome, files, issues);
|
|
176
|
-
if (skippedPlugins.size && files.has('.claude/settings.json')) {
|
|
177
|
-
const file = files.get('.claude/settings.json');
|
|
178
|
-
const settings = object(JSON.parse(file.content.toString('utf8')));
|
|
179
|
-
const enabled = object(settings.enabledPlugins);
|
|
180
|
-
for (const id of skippedPlugins)
|
|
181
|
-
if (id in enabled)
|
|
182
|
-
enabled[id] = false;
|
|
183
|
-
settings.enabledPlugins = enabled;
|
|
184
|
-
file.content = Buffer.from(JSON.stringify(settings) + '\n');
|
|
185
|
-
}
|
|
186
315
|
// Claude settings stay the user's. sf-agent hooks are in /etc/claude-code/managed-settings.json.
|
|
187
316
|
const secrets = { ...previous?.secrets, ...object(object(store.read().agent_config_secrets)[key(ctx, ws.id)]) };
|
|
188
317
|
const commandCache = new Map();
|
|
@@ -200,45 +329,44 @@ export async function syncAgentConfig(ctx, ws) {
|
|
|
200
329
|
const partial = { v: 1, workspace_id: ws.id, workspace_key: ws.key, api_url: ctx.apiUrl, home: remoteHome,
|
|
201
330
|
sync_state: 'applying', remote_hash: previous?.remote_hash, synced_at: previous?.synced_at ?? null, files: { ...previous?.files },
|
|
202
331
|
claude_mcp: previous?.claude_mcp ?? [], claude_mcp_owned: previous?.claude_mcp_owned, secrets, skipped: issues, mcp: previous?.mcp ?? [], tunnels: previous?.tunnels ?? [] };
|
|
332
|
+
// Progress is recorded at most every 250 ms and at the end of each pass, never once per file of thousands.
|
|
333
|
+
let savedAt = 0;
|
|
334
|
+
const flushPartial = () => {
|
|
335
|
+
savedAt = Date.now();
|
|
336
|
+
store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = partial; doc.agent_config = all; });
|
|
337
|
+
};
|
|
203
338
|
const savePartial = (path, file) => {
|
|
204
339
|
if (file)
|
|
205
340
|
partial.files[path] = file;
|
|
206
341
|
else
|
|
207
342
|
delete partial.files[path];
|
|
208
|
-
|
|
343
|
+
if (Date.now() - savedAt >= 250)
|
|
344
|
+
flushPartial();
|
|
209
345
|
};
|
|
210
346
|
const marker = await optionalRemote(cell, `${remoteHome}/${markerPath}`);
|
|
211
347
|
const trust = marker?.workspace_id === ws.id && typeof previous?.remote_hash === 'string' && marker.manifest_hash === previous.remote_hash;
|
|
212
348
|
// Stage executable payloads before checking stdio paths. Structured configuration is prepared before transfer.
|
|
213
349
|
const payload = new Map([...files].filter(([path]) => !/\.(?:json|toml)$/.test(path) && path !== '.claude/settings.json'));
|
|
214
|
-
const staged = await applyConfigFiles(cell, remoteHome, payload, previous?.files ?? {}, trust, issues, ctx.signal, false, savePartial);
|
|
350
|
+
const staged = await applyConfigFiles(cell, remoteHome, payload, previous?.files ?? {}, trust, issues, ctx.signal, false, savePartial).finally(flushPartial);
|
|
215
351
|
const mcpCtx = { home, remoteHome, workspaceId: ws.id, files, issues, env: ctx.io.env, exists, bind: (secret) => bindMcpSecret(ctx, ws, secrets, secret) };
|
|
216
|
-
// Installed plugins can declare MCP servers inline or in .mcp.json. Treat their env exactly like user MCP env.
|
|
217
|
-
for (const [path, file] of files) {
|
|
218
|
-
if (!path.startsWith('.claude/plugins/cache/') || !(path.endsWith('/.mcp.json') || path.endsWith('/.claude-plugin/plugin.json')))
|
|
219
|
-
continue;
|
|
220
|
-
const doc = object(JSON.parse(file.content.toString('utf8')));
|
|
221
|
-
const isManifest = path.endsWith('/plugin.json');
|
|
222
|
-
const inline = doc.mcpServers;
|
|
223
|
-
if (isManifest && (typeof inline === 'string' || Array.isArray(inline)))
|
|
224
|
-
continue; // Referenced .mcp.json files are handled in this same pass.
|
|
225
|
-
const servers = isManifest || inline ? object(inline) : doc;
|
|
226
|
-
if (!Object.keys(servers).length)
|
|
227
|
-
continue;
|
|
228
|
-
const root = `${remoteHome}/${path.slice(0, path.lastIndexOf(isManifest ? '/.claude-plugin/' : '/'))}`;
|
|
229
|
-
const pluginCtx = { ...mcpCtx, namespace: path, exists: (command) => exists(command.replaceAll('${CLAUDE_PLUGIN_ROOT}', root)) };
|
|
230
|
-
const portable = await prepareMcp('claude', servers, pluginCtx, mcp, tunnels);
|
|
231
|
-
file.content = Buffer.from(JSON.stringify(isManifest || inline ? { ...doc, mcpServers: portable } : portable) + '\n');
|
|
232
|
-
}
|
|
233
352
|
const claudeMcp = await prepareMcp('claude', object(claude?.mcpServers), mcpCtx, mcp, tunnels);
|
|
234
353
|
if (codexBytes) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
354
|
+
let config;
|
|
355
|
+
try {
|
|
356
|
+
config = parse(codexBytes.toString('utf8'));
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
issues.push({ item: '.codex/config.toml', reason: 'not valid TOML; not copied' });
|
|
360
|
+
}
|
|
361
|
+
if (config) {
|
|
362
|
+
const declared = 'mcp_servers' in config;
|
|
363
|
+
const servers = await prepareMcp('codex', object(config.mcp_servers), mcpCtx, mcp, tunnels);
|
|
364
|
+
delete config.mcp_servers;
|
|
365
|
+
const portable = object(rewritePaths(config, home, remoteHome, files, issues, 'codex:config'));
|
|
366
|
+
if (Object.keys(servers).length || declared)
|
|
367
|
+
portable.mcp_servers = servers;
|
|
368
|
+
files.set('.codex/config.toml', { content: Buffer.from(stringify(portable)), mode: '0600' });
|
|
369
|
+
}
|
|
242
370
|
}
|
|
243
371
|
// Retain encrypted secret records for later re-use, but stop injecting credentials for removed/skipped servers.
|
|
244
372
|
const ownedSecrets = Object.keys(secrets);
|
|
@@ -252,8 +380,13 @@ export async function syncAgentConfig(ctx, ws) {
|
|
|
252
380
|
await ws.secrets.set(names);
|
|
253
381
|
}
|
|
254
382
|
}
|
|
255
|
-
|
|
383
|
+
// The payload staged above is in the VM already; the second pass checks only what changed since.
|
|
384
|
+
const applied = await applyConfigFiles(cell, remoteHome, files, partial.files, trust, issues, ctx.signal, true, savePartial, new Set(Object.keys(staged.files))).finally(flushPartial);
|
|
256
385
|
applied.copied.unshift(...staged.copied);
|
|
386
|
+
// Plugins are installed by the VM's Claude Code from their marketplaces, once per changed plan.
|
|
387
|
+
const pluginsHash = hasPlugins ? digest(JSON.stringify(plugins)) : undefined;
|
|
388
|
+
if (pluginsHash && !(trust && pluginsHash === previous?.plugins_hash))
|
|
389
|
+
await installPlugins(cell, plugins, issues, ctx.signal);
|
|
257
390
|
// L2 owns oauthAccount and credentials. Patch just mcpServers under a revision check; preserve every other field.
|
|
258
391
|
if (Object.keys(claudeMcp).length || previous?.claude_mcp.length) {
|
|
259
392
|
const path = `${remoteHome}/.claude.json`;
|
|
@@ -296,7 +429,8 @@ export async function syncAgentConfig(ctx, ws) {
|
|
|
296
429
|
store.update(doc => { const all = object(doc.agent_config); all[key(ctx, ws.id)] = partial; doc.agent_config = all; });
|
|
297
430
|
}
|
|
298
431
|
const manifest = { v: 1, workspace_id: ws.id, workspace_key: ws.key, api_url: ctx.apiUrl, home: remoteHome,
|
|
299
|
-
sync_state: 'synced', synced_at, files: applied.files, claude_mcp: Object.keys(claudeMcp), claude_mcp_owned: partial.claude_mcp_owned, secrets, skipped: issues, mcp, tunnels
|
|
432
|
+
sync_state: 'synced', synced_at, files: applied.files, claude_mcp: Object.keys(claudeMcp), claude_mcp_owned: partial.claude_mcp_owned, secrets, skipped: issues, mcp, tunnels,
|
|
433
|
+
...(pluginsHash ? { plugins_hash: pluginsHash } : {}) };
|
|
300
434
|
manifest.remote_hash = digest(JSON.stringify({ files: applied.files, claude_mcp: claudeMcp, mcp, tunnels }));
|
|
301
435
|
if (marker?.workspace_id !== ws.id || marker.manifest_hash !== manifest.remote_hash)
|
|
302
436
|
await cell.files.write(`${remoteHome}/${markerPath}`, JSON.stringify({ workspace_id: ws.id, manifest_hash: manifest.remote_hash }) + '\n', { createParents: true, mode: '0600' });
|
|
@@ -311,6 +445,36 @@ export async function syncAgentConfig(ctx, ws) {
|
|
|
311
445
|
}
|
|
312
446
|
return result;
|
|
313
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Copies the laptop's agent config into a new conversation's VM after its agent started (0.19.0+): the first prompt
|
|
450
|
+
* never waits for it. From an installed shard it runs as its own detached process (`shard agents config-sync`, its
|
|
451
|
+
* output in agent-config-sync.log of the config directory), so the command that started the conversation reports the
|
|
452
|
+
* start at once; without a runtime (tests, an embedding) it runs here and the returned promise settles when it ends.
|
|
453
|
+
*/
|
|
454
|
+
export function syncAgentConfigAfterStart(ctx, ws) {
|
|
455
|
+
if (ctx.runtime?.script) {
|
|
456
|
+
try {
|
|
457
|
+
mkdirSync(ctx.store.dir, { recursive: true, mode: 0o700 });
|
|
458
|
+
const log = openSync(join(ctx.store.dir, 'agent-config-sync.log'), 'a', 0o600);
|
|
459
|
+
try {
|
|
460
|
+
const child = spawn(ctx.runtime.node, [ctx.runtime.script, 'agents', 'config-sync', ws.id], {
|
|
461
|
+
detached: true, stdio: ['ignore', log, log],
|
|
462
|
+
env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, SHARDFLUX_NO_UPDATE_CHECK: '1' }
|
|
463
|
+
});
|
|
464
|
+
child.on('error', () => undefined);
|
|
465
|
+
child.unref();
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
closeSync(log);
|
|
469
|
+
}
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
catch { /* runs in this process below */ }
|
|
473
|
+
}
|
|
474
|
+
return syncAgentConfig(ctx, ws).then(() => undefined, (error) => {
|
|
475
|
+
ctx.io.stderr.write(`Agent config not copied to ${ws.key}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
314
478
|
/** Tunnels are owned by the attachment, and close even if the terminal fails or the user detaches. */
|
|
315
479
|
export async function startAgentConfigTunnels(ctx, ws) {
|
|
316
480
|
const owned = [];
|
|
@@ -336,6 +500,16 @@ export async function startAgentConfigTunnels(ctx, ws) {
|
|
|
336
500
|
}
|
|
337
501
|
}
|
|
338
502
|
export const AGENT_CONFIG_HANDLERS = {
|
|
503
|
+
/** The detached copy syncAgentConfigAfterStart starts; one line per run in agent-config-sync.log. */
|
|
504
|
+
async 'agents config-sync'(ctx, _values, [id]) {
|
|
505
|
+
const ws = await ctx.cloud().workspaces.get(id);
|
|
506
|
+
const started = Date.now();
|
|
507
|
+
const result = await syncAgentConfig({ ...ctx, json: true }, ws);
|
|
508
|
+
ctx.io.stderr.write(`${new Date().toISOString()} ${ws.key}: ${result.copied.length} copied, ${result.removed.length} removed, ${result.skipped.length} skipped in ${Date.now() - started} ms\n`);
|
|
509
|
+
for (const line of configIssueLines(result.skipped))
|
|
510
|
+
ctx.io.stderr.write(` ${line}\n`);
|
|
511
|
+
return 0;
|
|
512
|
+
},
|
|
339
513
|
'agents config status'(ctx, values) {
|
|
340
514
|
const workspace = typeof values.workspace === 'string' ? values.workspace : undefined;
|
|
341
515
|
const manifests = Object.values(configManifests(ctx)).filter(m => m.api_url === ctx.apiUrl && (!workspace || m.workspace_id === workspace || m.workspace_key === workspace));
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Workspace } from '@shardflux/sdk';
|
|
2
2
|
import type { Ctx } from './commands.js';
|
|
3
|
-
import { CodedError } from './errors.js';
|
|
4
3
|
import type { SyncCell } from './project-sync-transfer.js';
|
|
5
4
|
export declare function projectWorkspacePath(project: {
|
|
6
5
|
slug: string;
|
|
@@ -8,20 +7,51 @@ export declare function projectWorkspacePath(project: {
|
|
|
8
7
|
/** New workspaces label this path explicitly; custom remote paths remain available to Git callers. */
|
|
9
8
|
export declare function workspaceRepositoryPath(ws: Pick<Workspace, 'labels' | 'key'>): string;
|
|
10
9
|
export declare function needsGitSeed(ctx: Ctx, ws: Workspace): boolean;
|
|
11
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* The checks a start can fail on, run on this computer before a VM is allocated (0.19.0+). A project's size, file
|
|
12
|
+
* count or history never refuses a start; only a folder inside another repository does.
|
|
13
|
+
*/
|
|
14
|
+
export declare function preflightProjectSeed(folder: string, signal?: AbortSignal): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* One seed for every start (C5, C11). A Git project's files come from Git in the VM: HEAD is checked out there and only
|
|
17
|
+
* what differs from HEAD on this computer (changes, untracked files, deletions, .env files) is copied (0.19.0+). Its
|
|
18
|
+
* size, file count and history length do not change what is sent.
|
|
19
|
+
*/
|
|
12
20
|
export declare function seedAgentRepository(ctx: Ctx, ws: Workspace, folder: string, options?: {
|
|
13
21
|
root?: string;
|
|
14
22
|
populate?: boolean;
|
|
15
23
|
initialFiles?: readonly string[];
|
|
16
24
|
onInitialFiles?: () => void;
|
|
17
25
|
}): Promise<void>;
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Above this (an estimate: the repository's packed and loose objects) the workspace fetches the history from origin as a
|
|
28
|
+
* blobless partial clone, and receives only the commits origin lacks (0.19.0+). Below it, one small bundle is faster.
|
|
29
|
+
*/
|
|
30
|
+
export declare const ORIGIN_FIRST_BYTES: number;
|
|
31
|
+
/** The repository's object store size, an upper bound of any one branch's history bundle. Reads sizes only. */
|
|
32
|
+
export declare function historyEstimate(folder: string, signal?: AbortSignal): Promise<number>;
|
|
33
|
+
export declare const FETCH_ORIGIN: string;
|
|
34
|
+
export declare const BACKFILL: string;
|
|
35
|
+
export declare const CHECKOUT_HEAD: string;
|
|
22
36
|
/**
|
|
23
37
|
* Imports the checked-out branch's history into an empty workspace repository. Only HEAD, its branch and that branch's
|
|
24
|
-
* upstream are
|
|
25
|
-
*
|
|
38
|
+
* upstream are sent: other branches, worktrees and stashes stay on this computer. A history above `originFirst` comes
|
|
39
|
+
* from origin as a blobless partial clone (the workspace's own Git credentials), plus a bundle of the commits origin
|
|
40
|
+
* lacks. Without a reachable origin the whole bundle is streamed in chunks: no history size refuses (0.19.0+).
|
|
41
|
+
*/
|
|
42
|
+
export declare function importProjectHistory(cell: SyncCell, folder: string, root: string, head: string, branch: string, origin: string | null, signal?: AbortSignal, originFirst?: number, report?: (message: string) => void): Promise<{
|
|
43
|
+
partial: string | null;
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* What differs between HEAD and this computer's working tree, from Git's own status (0.19.0+): changed, added and
|
|
47
|
+
* untracked files Git does not ignore, the .env files we always carry, Git LFS files (their content lives outside Git),
|
|
48
|
+
* and deletions. Ignored files, nested repositories and submodule contents stay out.
|
|
26
49
|
*/
|
|
27
|
-
export declare function
|
|
50
|
+
export declare function localChanges(folder: string, signal?: AbortSignal): Promise<{
|
|
51
|
+
copy: string[];
|
|
52
|
+
remove: string[];
|
|
53
|
+
skipped: {
|
|
54
|
+
path: string;
|
|
55
|
+
reason: string;
|
|
56
|
+
}[];
|
|
57
|
+
}>;
|