@shardflux/cli 0.13.6 → 0.14.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 (58) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +40 -4
  3. package/dist/acp-catalog.d.ts +152 -0
  4. package/dist/acp-catalog.js +287 -0
  5. package/dist/acp-journal.d.ts +18 -0
  6. package/dist/acp-journal.js +232 -0
  7. package/dist/acp-rpc.d.ts +41 -0
  8. package/dist/acp-rpc.js +115 -0
  9. package/dist/acp.d.ts +27 -0
  10. package/dist/acp.js +290 -0
  11. package/dist/agent-commands.d.ts +10 -2
  12. package/dist/agent-commands.js +37 -18
  13. package/dist/agent-machine.d.ts +6 -0
  14. package/dist/agent-machine.js +93 -0
  15. package/dist/agent-repository.js +1 -1
  16. package/dist/agent-state.d.ts +3 -0
  17. package/dist/agent-state.js +33 -12
  18. package/dist/agent-target-commands.d.ts +4 -1
  19. package/dist/agent-target-commands.js +64 -22
  20. package/dist/agent-targets.d.ts +6 -4
  21. package/dist/agent-targets.js +72 -31
  22. package/dist/args.d.ts +1 -1
  23. package/dist/args.js +53 -14
  24. package/dist/command-center-actions.js +26 -3
  25. package/dist/command-center.js +20 -4
  26. package/dist/conversation-commands.d.ts +3 -0
  27. package/dist/conversation-commands.js +36 -0
  28. package/dist/conversation-local-resume.d.ts +8 -0
  29. package/dist/conversation-local-resume.js +56 -0
  30. package/dist/conversation-local.d.ts +7 -0
  31. package/dist/conversation-local.js +61 -0
  32. package/dist/conversation-router.d.ts +12 -0
  33. package/dist/conversation-router.js +89 -0
  34. package/dist/conversation-stash.d.ts +11 -0
  35. package/dist/conversation-stash.js +158 -0
  36. package/dist/conversation-terminal.d.ts +3 -0
  37. package/dist/conversation-terminal.js +74 -0
  38. package/dist/conversation-worktree.d.ts +4 -0
  39. package/dist/conversation-worktree.js +22 -0
  40. package/dist/conversations.d.ts +53 -0
  41. package/dist/conversations.js +118 -0
  42. package/dist/device-bridge.d.ts +80 -0
  43. package/dist/device-bridge.js +398 -0
  44. package/dist/device-helper.d.ts +3 -0
  45. package/dist/device-helper.js +104 -0
  46. package/dist/errors.js +5 -0
  47. package/dist/format.js +3 -2
  48. package/dist/handlers.js +3 -1
  49. package/dist/main.js +15 -3
  50. package/dist/project-sync.d.ts +5 -1
  51. package/dist/project-sync.js +7 -6
  52. package/dist/t3-provider.d.ts +6 -0
  53. package/dist/t3-provider.js +99 -0
  54. package/dist/t3-runtime.d.ts +6 -0
  55. package/dist/t3-runtime.js +63 -0
  56. package/dist/target-lock.d.ts +3 -0
  57. package/dist/target-lock.js +143 -0
  58. package/package.json +3 -3
@@ -0,0 +1,398 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
4
+ import { readFile, writeFile, mkdir, realpath, stat } from 'node:fs/promises';
5
+ import { createConnection, createServer } from 'node:net';
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { WorkspaceTunnels } from '@shardflux/sdk';
8
+ import { writePrivateFile } from "./credentials.js";
9
+ import { conversationName, conversationWorkspace, getConversation, localConversations } from "./conversations.js";
10
+ import { gitRunner } from "./agent-git.js";
11
+ import { shardRemote } from "./git-remote.js";
12
+ import { writeSyncBlob } from "./project-sync-transfer.js";
13
+ import { UsageError } from "./errors.js";
14
+ import { conversationClient } from "./conversation-commands.js";
15
+ import { withTargetLock } from "./target-lock.js";
16
+ import { checkConversationWorktree, rememberConversationWorktree } from "./conversation-worktree.js";
17
+ const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
18
+ const inside = (root, target) => target === root || target.startsWith(root + sep);
19
+ const safeId = (value) => { if (!/^[a-zA-Z0-9_-]{1,96}$/.test(value))
20
+ throw new Error('Invalid device request identity'); return value; };
21
+ /** Resolve every existing ancestor, including symlinks. Missing leaf files do not hide an outside parent. */
22
+ export function devicePath(root, requested = '.') {
23
+ if (/[\0\r\n]/.test(requested))
24
+ throw new UsageError('Device path contains invalid characters.');
25
+ const base = realpathSync(root), target = isAbsolute(requested) ? resolve(requested) : resolve(base, requested);
26
+ let ancestor = target;
27
+ while (!existsSync(ancestor)) {
28
+ const parent = dirname(ancestor);
29
+ if (parent === ancestor)
30
+ throw new UsageError('Device path is unavailable.');
31
+ ancestor = parent;
32
+ }
33
+ const canonical = resolve(realpathSync(ancestor), relative(ancestor, target));
34
+ return { path: canonical, outside: !inside(base, canonical) };
35
+ }
36
+ export class DeviceGrants {
37
+ file;
38
+ constructor(dir) { this.file = join(dir, 'device-grants.json'); }
39
+ key(api, project) { return JSON.stringify([api, project]); }
40
+ read() {
41
+ try {
42
+ if (lstatSync(this.file).isSymbolicLink() || (statSync(this.file).mode & 0o077))
43
+ throw new UsageError('Device grants must be a private file.');
44
+ const value = JSON.parse(readFileSync(this.file, 'utf8'));
45
+ if (!value || typeof value !== 'object' || Array.isArray(value))
46
+ throw new UsageError('Device grants must be an object.');
47
+ return value;
48
+ }
49
+ catch (e) {
50
+ if (e.code === 'ENOENT')
51
+ return {};
52
+ throw e;
53
+ }
54
+ }
55
+ allows(api, project, root, kind, outside) {
56
+ const grant = this.read()[this.key(api, project)];
57
+ return !outside && grant?.root === realpathSync(root) && Array.isArray(grant.kinds) && grant.kinds.includes(kind);
58
+ }
59
+ async save(api, project, root, kind) {
60
+ await withTargetLock(join(dirname(this.file), 'device-grants-lock'), () => {
61
+ const all = this.read(), key = this.key(api, project), canonical = realpathSync(root);
62
+ all[key] = { root: canonical, kinds: [...new Set([...(all[key]?.root === canonical ? all[key].kinds : []), kind])] };
63
+ writePrivateFile(dirname(this.file), this.file, JSON.stringify(all) + '\n');
64
+ });
65
+ }
66
+ }
67
+ export function deviceSummary(r) {
68
+ if (tryLocallyAction(r))
69
+ return tryLocallyAction(r) === 'stop' ? 'Stop this conversation’s local sync and ports.' : 'Try this conversation in an isolated local worktree with two-way sync and localhost ports.';
70
+ if (r.kind === 'exec')
71
+ return `Run on your Mac: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
72
+ if (r.files)
73
+ return `${r.kind === 'files_pull' ? 'Send to VM' : 'Save on your Mac'}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
74
+ return `${r.kind === 'port_forward' ? 'Open VM port on your Mac' : 'Reach your Mac from VM'}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
75
+ }
76
+ export function tryLocallyAction(r) {
77
+ if (r.kind === 'try_locally')
78
+ return 'start';
79
+ const argv = r.exec?.argv;
80
+ if (r.kind !== 'exec' || !argv || argv[0] !== 'shard' || argv[1] !== 'conversations' || argv[2] !== 'try' || argv[3] !== r.conversation_id)
81
+ return null;
82
+ if (argv.length === 4)
83
+ return 'start';
84
+ return argv.length === 5 && argv[4] === '--stop' ? 'stop' : null;
85
+ }
86
+ export async function ensureConversationWorktree(ctx, c, folder, ws) {
87
+ return withTargetLock(join(ctx.store.dir, 'conversation-worktree-lock'), async () => {
88
+ const run = gitRunner(folder, { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl }, ctx.signal);
89
+ const name = conversationName(c), worktree = join(folder, '.shard', 'worktrees', name);
90
+ if (existsSync(worktree))
91
+ checkConversationWorktree(ctx, c, folder, worktree);
92
+ const source = ws ?? await conversationWorkspace(ctx, c);
93
+ await run(['-c', 'core.hooksPath=/dev/null', 'fetch', '--no-tags', shardRemote(source.key, '/shardflux/project/git/hub.git'), `+refs/heads/${c.branch}:refs/remotes/shardflux/${name}`]);
94
+ if (!existsSync(worktree)) {
95
+ await mkdir(dirname(worktree), { recursive: true });
96
+ await run(['-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', c.branch, worktree, `refs/remotes/shardflux/${name}`]);
97
+ rememberConversationWorktree(ctx, c, folder, worktree);
98
+ }
99
+ else {
100
+ checkConversationWorktree(ctx, c, folder, worktree);
101
+ const work = gitRunner(worktree, { ...process.env, ...ctx.io.env }, ctx.signal);
102
+ if (await work(['symbolic-ref', '--short', 'HEAD']) !== c.branch || await work(['rev-parse', '--show-toplevel']) !== realpathSync(worktree))
103
+ throw new UsageError('Local conversation worktree belongs to other work.');
104
+ }
105
+ return realpathSync(worktree);
106
+ }, ctx.signal);
107
+ }
108
+ export async function deviceExec(argv, cwd, timeoutSeconds = 300, signal) {
109
+ if (!Array.isArray(argv) || argv.length < 1 || argv.length > 128 || argv.some(a => typeof a !== 'string' || a.includes('\0') || a.length > 65536) || !argv[0])
110
+ throw new UsageError('Device exec requires an argument array.');
111
+ if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3600)
112
+ throw new UsageError('Device timeout must be 1–3600 seconds.');
113
+ return new Promise((resolveResult, reject) => {
114
+ const child = spawn(argv[0], argv.slice(1), { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', env: process.env });
115
+ const stdout = [], stderr = [];
116
+ let failure;
117
+ const stop = () => { try {
118
+ if (child.pid && process.platform !== 'win32')
119
+ process.kill(-child.pid, 'SIGKILL');
120
+ else
121
+ child.kill('SIGKILL');
122
+ }
123
+ catch { /* exited */ } };
124
+ const timeout = setTimeout(() => { failure = new Error('Device command timed out.'); stop(); }, timeoutSeconds * 1000);
125
+ const abort = () => { failure = new Error('Device command interrupted.'); stop(); };
126
+ signal?.addEventListener('abort', abort, { once: true });
127
+ const collect = (target) => { let bytes = 0; return (data) => { bytes += data.length; if (bytes > OUTPUT) {
128
+ failure = new Error('Device output exceeds 192 KiB per stream.');
129
+ stop();
130
+ }
131
+ else
132
+ target.push(data); }; };
133
+ child.stdout.on('data', collect(stdout));
134
+ child.stderr.on('data', collect(stderr));
135
+ child.once('error', error => { clearTimeout(timeout); signal?.removeEventListener('abort', abort); reject(error); });
136
+ child.once('close', code => {
137
+ clearTimeout(timeout);
138
+ signal?.removeEventListener('abort', abort);
139
+ resolveResult({ state: failure ? 'failed' : 'done', exit_code: code ?? 1, stdout_b64: Buffer.concat(stdout).toString('base64'), stderr_b64: Buffer.concat([...stderr, ...(failure ? [Buffer.from(failure.message)] : [])]).toString('base64') });
140
+ });
141
+ if (signal?.aborted)
142
+ abort();
143
+ });
144
+ }
145
+ export class DeviceExecutor {
146
+ #ctx;
147
+ #ask;
148
+ #deviceId;
149
+ #handles = new Map();
150
+ constructor(ctx, ask, deviceId) { this.#ctx = ctx; this.#ask = ask; this.#deviceId = deviceId; }
151
+ async result(r, result) {
152
+ await conversationClient(this.#ctx).deviceRequests.result(safeId(r.id), result, { idempotencyKey: `${this.#deviceId}:${r.id}:${result.state}`, signal: this.#ctx.signal });
153
+ }
154
+ async execute(r) {
155
+ const ctx = this.#ctx;
156
+ safeId(r.id);
157
+ if (r.state !== 'pending' || r.device_id !== this.#deviceId)
158
+ return;
159
+ const records = localConversations(ctx);
160
+ const matching = Object.values(records).filter(record => record.project_id === r.project_id);
161
+ const folders = new Set(matching.map(record => record.folder));
162
+ const record = records[r.conversation_id] ?? (folders.size === 1 ? matching[0] : undefined);
163
+ if (!record || record.project_id !== r.project_id)
164
+ return; // No inferred local path for unknown projects.
165
+ let c = await getConversation(ctx, r.conversation_id), ws = await conversationWorkspace(ctx, c);
166
+ if (c.project_id !== r.project_id)
167
+ throw new Error('Device request project changed.');
168
+ const root = await ensureConversationWorktree(ctx, c, record.folder, ws);
169
+ const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
170
+ const target = devicePath(root, requested), grants = new DeviceGrants(ctx.store.dir);
171
+ const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask({ type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id, summary: deviceSummary(r), outside: target.outside });
172
+ if (choice === 'deny') {
173
+ await this.result(r, { state: 'denied' });
174
+ return;
175
+ }
176
+ if (choice !== 'once' && choice !== 'always')
177
+ throw new Error('Device request was not approved.');
178
+ if (target.outside && choice === 'always')
179
+ throw new UsageError('An outside path requires Allow once for this request.');
180
+ if (choice === 'always')
181
+ await grants.save(ctx.apiUrl, r.project_id, record.folder, r.kind);
182
+ // Re-read after the approval, before claiming or touching data.
183
+ if (devicePath(root, requested).path !== target.path)
184
+ throw new Error('Device path changed during approval.');
185
+ const current = await getConversation(ctx, r.conversation_id);
186
+ if (current.project_id !== c.project_id || current.branch !== c.branch || current.state === 'done' || current.state === 'archived')
187
+ throw new Error('The conversation changed or ended during device approval.');
188
+ if (current.workspace_id !== c.workspace_id) {
189
+ ws.cell().close();
190
+ ws = await conversationWorkspace(ctx, current);
191
+ }
192
+ c = current;
193
+ await this.result(r, { state: 'approved' });
194
+ await this.result(r, { state: 'running' });
195
+ const cell = ws.cell();
196
+ try {
197
+ let result;
198
+ if (r.kind === 'try_locally' || tryLocallyAction(r)) {
199
+ const action = tryLocallyAction(r);
200
+ for (const [id, handle] of this.#handles)
201
+ if (handle.conversation === c.id) {
202
+ await handle.stop();
203
+ this.#handles.delete(id);
204
+ }
205
+ if (action === 'stop') {
206
+ await this.result(r, { state: 'done', stdout_b64: Buffer.from('Local sync and ports stopped.').toString('base64') });
207
+ return;
208
+ }
209
+ const { tryConversationLocally } = await import("./conversation-local.js");
210
+ const handle = await tryConversationLocally(ctx, c, record.folder);
211
+ this.#handles.set(r.id, { conversation: c.id, workspace: ws.id, async stop() { await handle.stop(); cell.close(); } });
212
+ result = { state: 'done', files_artifact: handle.folder };
213
+ }
214
+ else if (r.kind === 'exec') {
215
+ if (!r.exec)
216
+ throw new UsageError('Device exec has no command.');
217
+ result = await deviceExec(r.exec.argv, target.path, r.exec.timeout_seconds, ctx.signal);
218
+ }
219
+ else if (r.kind === 'files_pull' || r.kind === 'files_push') {
220
+ const f = r.files;
221
+ if (!f || !f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
222
+ throw new UsageError('Device transfer requires an absolute VM path.');
223
+ const max = f.max_bytes ?? LIMIT;
224
+ if (!Number.isSafeInteger(max) || max < 1 || max > LIMIT)
225
+ throw new UsageError('Device transfer maximum is 256 MiB.');
226
+ if (r.kind === 'files_pull') {
227
+ if (!(await stat(target.path)).isFile() || (await stat(target.path)).size > max)
228
+ throw new UsageError('Local transfer file exceeds the requested size or is not a file.');
229
+ const bytes = await readFile(target.path);
230
+ if (bytes.length > max)
231
+ throw new UsageError('Local transfer file grew during reading.');
232
+ await writeSyncBlob(cell, f.remote_path, bytes);
233
+ }
234
+ else {
235
+ const info = await cell.files.stat(f.remote_path);
236
+ if (info.type !== 'file' || info.size > max)
237
+ throw new UsageError('VM transfer file exceeds the requested size or is not a file.');
238
+ const bytes = await cell.files.read(f.remote_path, { length: max + 1 });
239
+ if (bytes.length > max || bytes.length !== info.size)
240
+ throw new UsageError('VM transfer file changed during reading.');
241
+ await mkdir(dirname(target.path), { recursive: true, mode: 0o700 });
242
+ const parent = await realpath(dirname(target.path));
243
+ if (!target.outside && !inside(root, parent))
244
+ throw new Error('Transfer parent escaped the worktree.');
245
+ await writeFile(target.path, bytes, { mode: 0o600, flag: 'wx' }); // Existing files need a fresh explicit request, never silent clobber.
246
+ }
247
+ result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : relative(root, target.path) };
248
+ }
249
+ else {
250
+ if (!r.port || ![r.port.local_port, r.port.remote_port].every(p => Number.isInteger(p) && p > 0 && p <= 65535))
251
+ throw new UsageError('Device ports must be 1–65535.');
252
+ const handle = await devicePort(ws, cell, r.kind, r.port, ctx.signal);
253
+ this.#handles.set(r.id, { conversation: c.id, workspace: ws.id, stop: () => handle.stop() });
254
+ result = { state: 'done' };
255
+ }
256
+ await this.result(r, result);
257
+ }
258
+ catch (e) {
259
+ await this.result(r, { state: 'failed', stderr_b64: Buffer.from(e instanceof Error ? e.message : String(e)).toString('base64') });
260
+ }
261
+ finally {
262
+ if (!this.#handles.has(r.id))
263
+ cell.close();
264
+ }
265
+ }
266
+ async reap() {
267
+ for (const [id, handle] of this.#handles) {
268
+ const c = await getConversation(this.#ctx, handle.conversation);
269
+ const file = join(this.#ctx.store.dir, 'device-stops', `${c.id}.json`);
270
+ let stopped = false;
271
+ if (existsSync(file)) {
272
+ const record = JSON.parse(readFileSync(file, 'utf8'));
273
+ stopped = record.api_url === this.#ctx.apiUrl;
274
+ }
275
+ if (stopped || c.state === 'done' || c.state === 'archived' || c.workspace_id !== handle.workspace) {
276
+ await handle.stop();
277
+ this.#handles.delete(id);
278
+ if (stopped)
279
+ rmSync(file);
280
+ }
281
+ }
282
+ }
283
+ async close() { await Promise.all([...this.#handles.values()].map(h => h.stop())); this.#handles.clear(); }
284
+ }
285
+ export async function devicePort(ws, cell, kind, ports, signal) {
286
+ const tunnels = new WorkspaceTunnels(() => cell, () => ws.grantedTools);
287
+ if (kind === 'port_reverse') {
288
+ const handle = await tunnels.reverse({ remotePort: ports.remote_port, target: `127.0.0.1:${ports.local_port}`, bindAddress: '127.0.0.1', signal });
289
+ let stopping;
290
+ return { stop() { return stopping ??= handle.close().finally(() => cell.close()); } };
291
+ }
292
+ const peers = new Set();
293
+ const handles = new Set();
294
+ const server = createServer(socket => {
295
+ peers.add(socket);
296
+ socket.once('close', () => peers.delete(socket));
297
+ socket.on('error', () => socket.destroy());
298
+ void tunnels.forward({ remotePort: ports.remote_port, stream: socket, signal }).then(handle => { handles.add(handle); void handle.closed.finally(() => handles.delete(handle)).catch(() => socket.destroy()); }).catch(() => socket.destroy());
299
+ });
300
+ await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(ports.local_port, '127.0.0.1', resolveListen); });
301
+ let stopping;
302
+ const abort = () => { void stop().catch(() => undefined); };
303
+ const stop = () => stopping ??= (async () => { signal.removeEventListener('abort', abort); for (const socket of peers)
304
+ socket.destroy(); await Promise.all([...handles].map(h => h.close())); await new Promise(resolveClose => server.close(() => resolveClose())); cell.close(); })();
305
+ signal.addEventListener('abort', abort, { once: true });
306
+ if (signal.aborted)
307
+ abort();
308
+ return { stop };
309
+ }
310
+ const approvalSocket = (dir, id) => {
311
+ const hash = createHash('sha256').update(resolve(dir)).update(safeId(id)).digest('hex').slice(0, 20);
312
+ const path = join(dir, 'acp-device', `${hash}.sock`);
313
+ return Buffer.byteLength(path) < 100 ? path : join('/tmp', `sf-device-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
314
+ };
315
+ export async function serveAcpDeviceApprovals(dir, id, ask) {
316
+ const path = approvalSocket(dir, id);
317
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
318
+ const parent = lstatSync(dirname(path));
319
+ if (parent.isSymbolicLink() || (parent.mode & 0o077) || (process.getuid && parent.uid !== process.getuid()))
320
+ throw new Error('Device approval socket directory must be private and owned by this user.');
321
+ const peers = new Set();
322
+ const server = createServer(socket => {
323
+ peers.add(socket);
324
+ socket.once('close', () => peers.delete(socket));
325
+ let body = '';
326
+ socket.setTimeout(3600_000, () => socket.destroy());
327
+ socket.on('error', () => socket.destroy());
328
+ socket.on('data', chunk => {
329
+ body += String(chunk);
330
+ if (body.length > 64 << 10)
331
+ socket.destroy();
332
+ if (!body.includes('\n'))
333
+ return;
334
+ socket.removeAllListeners('data');
335
+ let approval;
336
+ try {
337
+ approval = JSON.parse(body);
338
+ if (approval.conversation_id !== id || approval.type !== 'approval')
339
+ throw new Error('Invalid approval');
340
+ }
341
+ catch {
342
+ socket.destroy();
343
+ return;
344
+ }
345
+ void ask(approval).then(choice => socket.end(JSON.stringify({ choice }) + '\n')).catch(() => socket.end('{"choice":"deny"}\n'));
346
+ });
347
+ });
348
+ await withTargetLock(join(dirname(path), `${id}-lock`), async () => {
349
+ if (existsSync(path)) {
350
+ const old = lstatSync(path);
351
+ if (!old.isSocket() || (process.getuid && old.uid !== process.getuid()))
352
+ throw new Error('Device approval socket belongs to other work.');
353
+ const active = await new Promise((resolveActive, reject) => {
354
+ const probe = createConnection(path);
355
+ probe.setTimeout(1000, () => { probe.destroy(); reject(new Error('Device approval socket owner is unverified.')); });
356
+ probe.once('connect', () => { probe.destroy(); resolveActive(true); });
357
+ probe.once('error', error => { if (error.code === 'ECONNREFUSED')
358
+ resolveActive(false);
359
+ else
360
+ reject(error); });
361
+ });
362
+ if (active)
363
+ throw new Error('This ACP conversation already has a laptop bridge.');
364
+ if (lstatSync(path).ino !== old.ino)
365
+ throw new Error('Device approval socket owner changed.');
366
+ rmSync(path);
367
+ }
368
+ await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(path, resolveListen); });
369
+ chmodSync(path, 0o600);
370
+ });
371
+ const inode = lstatSync(path).ino;
372
+ let stopping;
373
+ return { stop() { return stopping ??= (async () => { for (const peer of peers)
374
+ peer.destroy(); await new Promise(resolveClose => server.close(() => resolveClose())); if (existsSync(path) && lstatSync(path).ino === inode)
375
+ rmSync(path); })(); } };
376
+ }
377
+ export async function askConnectedAcp(dir, approval, signal) {
378
+ const path = approvalSocket(dir, approval.conversation_id);
379
+ if (!existsSync(path))
380
+ return null;
381
+ return new Promise((resolveChoice, reject) => {
382
+ const socket = createConnection(path);
383
+ let text = '';
384
+ const abort = () => { socket.destroy(); reject(signal.reason instanceof Error ? signal.reason : new Error('Device approval interrupted')); };
385
+ signal.addEventListener('abort', abort, { once: true });
386
+ socket.on('connect', () => socket.write(JSON.stringify(approval) + '\n'));
387
+ socket.on('data', bytes => { text += String(bytes); if (text.length > 1024)
388
+ socket.destroy(); });
389
+ socket.once('error', () => { signal.removeEventListener('abort', abort); resolveChoice(null); });
390
+ socket.once('end', () => { signal.removeEventListener('abort', abort); try {
391
+ const choice = JSON.parse(text).choice;
392
+ resolveChoice(['once', 'always', 'deny'].includes(choice) ? choice : 'deny');
393
+ }
394
+ catch {
395
+ resolveChoice('deny');
396
+ } });
397
+ });
398
+ }
@@ -0,0 +1,3 @@
1
+ import type { Ctx } from './commands.js';
2
+ /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
3
+ export declare function runDeviceHelper(ctx: Ctx): Promise<number>;
@@ -0,0 +1,104 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { DeviceExecutor, askConnectedAcp } from "./device-bridge.js";
3
+ import { mirrorProjectConversations } from "./conversation-stash.js";
4
+ import { withTargetLock } from "./target-lock.js";
5
+ import { join } from 'node:path';
6
+ import { conversationsUnavailable } from "./conversations.js";
7
+ import { localConversations } from "./conversations.js";
8
+ import { randomUUID } from 'node:crypto';
9
+ import { readFileSync } from 'node:fs';
10
+ import { writePrivateFile } from "./credentials.js";
11
+ import { conversationClient } from "./conversation-commands.js";
12
+ import { Readable } from 'node:stream';
13
+ /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
14
+ export async function runDeviceHelper(ctx) {
15
+ const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
16
+ ctx = { ...ctx, signal };
17
+ const pending = new Map();
18
+ const inputSource = Readable.from(ctx.io.stdin, { objectMode: false, signal });
19
+ const input = (async () => {
20
+ let tail = '';
21
+ for await (const bytes of inputSource) {
22
+ tail += Buffer.from(bytes).toString();
23
+ if (tail.length > 64 << 10)
24
+ throw new Error('Device approval input exceeds 64 KiB.');
25
+ for (let newline; (newline = tail.indexOf('\n')) >= 0;) {
26
+ const line = tail.slice(0, newline);
27
+ tail = tail.slice(newline + 1);
28
+ const approval = JSON.parse(line);
29
+ const resolve = pending.get(approval.request_id);
30
+ if (resolve && ['once', 'always', 'deny'].includes(approval.choice)) {
31
+ pending.delete(approval.request_id);
32
+ resolve(approval.choice);
33
+ }
34
+ }
35
+ }
36
+ })().finally(() => abort.abort());
37
+ void input.catch(() => abort.abort());
38
+ const ask = async (approval) => {
39
+ const acp = await askConnectedAcp(ctx.store.dir, approval, signal);
40
+ if (acp)
41
+ return acp;
42
+ return new Promise(resolve => {
43
+ const denied = () => { pending.delete(approval.request_id); resolve('deny'); };
44
+ signal.addEventListener('abort', denied, { once: true });
45
+ pending.set(approval.request_id, choice => { signal.removeEventListener('abort', denied); resolve(choice); });
46
+ ctx.io.stdout.write(JSON.stringify(approval) + '\n');
47
+ if (signal.aborted)
48
+ denied();
49
+ });
50
+ };
51
+ const working = new Map();
52
+ const mirrored = new Map();
53
+ const deviceFile = join(ctx.store.dir, 'device-id');
54
+ let deviceId;
55
+ try {
56
+ deviceId = readFileSync(deviceFile, 'utf8').trim();
57
+ }
58
+ catch {
59
+ deviceId = randomUUID();
60
+ writePrivateFile(ctx.store.dir, deviceFile, deviceId + '\n');
61
+ }
62
+ const executor = new DeviceExecutor(ctx, ask, deviceId);
63
+ const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
64
+ try {
65
+ return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
66
+ while (!signal.aborted) {
67
+ try {
68
+ for (const projectId of new Set(Object.values(localConversations(ctx)).map(r => r.project_id))) {
69
+ await conversationClient(ctx).deviceRequests.heartbeat({ projectId, deviceId, leaseSeconds: 30 });
70
+ let cursor;
71
+ do {
72
+ const page = await conversationClient(ctx).deviceRequests.list({ projectId, state: 'pending', limit: 200, ...(cursor ? { cursor } : {}) });
73
+ for (const r of page.data) {
74
+ if (r.device_id !== deviceId || working.has(r.id) || working.size >= 8)
75
+ continue;
76
+ const work = executor.execute(r).catch(report).finally(() => working.delete(r.id));
77
+ working.set(r.id, work);
78
+ }
79
+ cursor = page.nextCursor ?? undefined;
80
+ } while (cursor && !signal.aborted);
81
+ }
82
+ await executor.reap();
83
+ await mirrorProjectConversations(ctx, mirrored);
84
+ }
85
+ catch (e) {
86
+ if (!signal.aborted && !conversationsUnavailable(e))
87
+ report(e);
88
+ }
89
+ await delay(1000, undefined, { signal }).catch(() => undefined);
90
+ }
91
+ return 0;
92
+ }, signal);
93
+ }
94
+ finally {
95
+ abort.abort();
96
+ inputSource.destroy();
97
+ for (const resolve of pending.values())
98
+ resolve('deny');
99
+ pending.clear();
100
+ await executor.close();
101
+ await Promise.all([...working.values()]);
102
+ await input.catch(() => undefined);
103
+ }
104
+ }
package/dist/errors.js CHANGED
@@ -146,6 +146,11 @@ function isNamed(err, name) {
146
146
  return typeof err === 'object' && err !== null && err.name === name;
147
147
  }
148
148
  export function describeError(err) {
149
+ // Tunnel teardown and cleanup failures aggregate their causes: name them all, and let the first decide the code.
150
+ if (err instanceof AggregateError && err.errors.length) {
151
+ const inner = err.errors.map(e => describeError(e));
152
+ return { ...inner[0], message: `${err.message}: ${inner.map(i => i.message).join('; ')}` };
153
+ }
149
154
  // A processful command that could not start (0.5.1; the SDK's ExecStartError): the workspace's reason, no HTTP status
150
155
  // (the start itself was answered 201) and details.error, details.session_id.
151
156
  if (err instanceof ExecStartError)
package/dist/format.js CHANGED
@@ -960,15 +960,16 @@ export function dataExportText(e, out) {
960
960
  ]);
961
961
  }
962
962
  export function volumeTable(rows) {
963
- return table(['ID', 'NAME', 'STATE', 'QUOTA', 'SHARED', 'ATTACHED', 'CREATED'], rows.map((v) => [v.id, v.name, `${v.state}${v.deleted_at ? ' (deleted)' : ''}`, `${v.quota_gib} GiB`, v.org_shared ? 'organization' : 'project', String(v.live_attachments), time(v.created_at)]));
963
+ return table(['ID', 'NAME', 'KIND', 'STATE', 'QUOTA', 'SHARED', 'ATTACHED', 'CREATED'], rows.map((v) => [v.id, v.name, v.kind ?? 'shared', `${v.state}${v.deleted_at ? ' (deleted)' : ''}`, v.kind === 'project_space' ? 'platform managed' : `${v.quota_gib} GiB`, v.org_shared ? 'organization' : 'project', String(v.live_attachments), time(v.created_at)]));
964
964
  }
965
965
  export function volumeDetail(v) {
966
966
  const err = v.error;
967
967
  return kv([
968
968
  ['id', v.id],
969
969
  ['name', v.name],
970
+ ['kind', v.kind ?? 'shared'],
970
971
  ['state', `${v.state}${v.state_reason ? ` (${v.state_reason})` : ''}`],
971
- ['quota', `${v.quota_gib} GiB`],
972
+ ['quota', v.kind === 'project_space' ? 'platform managed' : `${v.quota_gib} GiB`],
972
973
  ['shared', v.org_shared ? 'every project of the organization' : `project ${v.project_id}`],
973
974
  ['attachments', String(v.live_attachments)],
974
975
  ['usage', pairs(v.usage)],
package/dist/handlers.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CREDENTIAL_HANDLERS } from "./credential-prompts.js";
2
+ import { CONVERSATION_HANDLERS } from "./conversation-commands.js";
2
3
  /**
3
4
  * Every command's handler, keyed by its path ("workspaces open", "auth login"): the project-key commands of
4
5
  * commands.ts and the account plane of account-commands.ts (0.5.0). args.test.ts checks that it matches COMMANDS.
@@ -13,4 +14,5 @@ import { AGENT_GIT_HANDLERS } from "./agent-git-commands.js";
13
14
  import { MENUBAR_HANDLERS } from "./menubar.js";
14
15
  import { UPDATE_HANDLERS } from "./update.js";
15
16
  import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
16
- export const HANDLERS = { ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
17
+ import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversation-local.js";
18
+ export const HANDLERS = { ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
package/dist/main.js CHANGED
@@ -15,6 +15,10 @@ import { statSync } from 'node:fs';
15
15
  import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
16
16
  import { CLI_VERSION, CENTER_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
17
17
  import { commandCenter } from "./command-center.js";
18
+ import { launchConversationProject } from "./conversation-terminal.js";
19
+ import { runAcp } from "./acp.js";
20
+ import { registerInstalledT3 } from "./t3-provider.js";
21
+ import { runDeviceHelper } from "./device-helper.js";
18
22
  import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
19
23
  import { CredentialStore, configDir } from "./credentials.js";
20
24
  import { recordInstall } from "./update.js";
@@ -365,6 +369,7 @@ function feedbackHint(info, credentialHint) {
365
369
  }
366
370
  export async function run(argv, io, opts = {}) {
367
371
  const signal = opts.signal ?? new AbortController().signal;
372
+ await registerInstalledT3(io.env, configDir(io.env), signal);
368
373
  const distribution = opts.distribution ?? CLI_DISTRIBUTION;
369
374
  const rawKey = io.env.SHARDFLUX_API_KEY;
370
375
  // Every credential this command loaded or received: redacted from anything printed on failure.
@@ -626,7 +631,7 @@ export async function run(argv, io, opts = {}) {
626
631
  },
627
632
  };
628
633
  // The update check runs next to the command (not for `version`, which reports it itself).
629
- if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy'].includes(parsed.command.path[0])) {
634
+ if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0])) {
630
635
  try {
631
636
  updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
632
637
  updates.start();
@@ -639,10 +644,11 @@ export async function run(argv, io, opts = {}) {
639
644
  catch (err) {
640
645
  return report(err);
641
646
  }
642
- const handler = parsed.command === PROJECT_COMMAND || parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
647
+ const handler = parsed.command.path[0] === 'acp' ? (ctx) => runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
643
648
  if (!handler)
644
649
  return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
645
- await housekeeping(ctx);
650
+ if (!['acp', 'device-helper'].includes(parsed.command.path[0]))
651
+ await housekeeping(ctx);
646
652
  const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
647
653
  // On Ctrl-C give the command a moment to clean up (exec sends a cancel), then stop regardless.
648
654
  const interrupted = new Promise((resolve) => {
@@ -693,6 +699,12 @@ export async function main(opts = {}) {
693
699
  process.exit(130);
694
700
  controller.abort(new Error('interrupted'));
695
701
  });
702
+ for (const name of ['SIGTERM', 'SIGHUP'])
703
+ process.once(name, () => controller.abort(new Error(name)));
704
+ // A reader that closed its end (ssh after a ProxyCommand session, `| head`) has nothing left to receive.
705
+ for (const stream of [process.stdout, process.stderr])
706
+ stream.on('error', (e) => { if (e.code !== 'EPIPE')
707
+ throw e; });
696
708
  const flush = (stream) => new Promise((resolve) => stream.write('', () => resolve()));
697
709
  const runtime = { node: process.execPath, script: process.argv[1] ?? '' };
698
710
  // The menu bar's update button runs this shard; it finds it here (macOS only, written when it changes).
@@ -35,4 +35,8 @@ export declare function projectReconciler(options: {
35
35
  report: (message: string) => void;
36
36
  signal?: AbortSignal;
37
37
  }): () => Promise<void>;
38
- export declare function startProjectSync(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>): Promise<ProjectSync>;
38
+ export declare function startProjectSync(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>, options?: {
39
+ root?: string;
40
+ agent?: string;
41
+ seed?: boolean;
42
+ }): Promise<ProjectSync>;
@@ -111,13 +111,14 @@ export function projectReconciler(options) {
111
111
  };
112
112
  return () => { const next = serial.then(run); serial = next.catch(() => undefined); return next; };
113
113
  }
114
- export async function startProjectSync(ctx, ws, project) {
115
- if (ws.labels.agent && ws.labels.agent !== 'main')
116
- throw new Error('two-way project sync is only available for the main agent');
117
- const root = projectWorkspacePath(project);
114
+ export async function startProjectSync(ctx, ws, project, options = {}) {
115
+ if (!options.root && ws.labels.agent && ws.labels.agent !== 'main')
116
+ throw new Error('two-way project sync requires a conversation worktree');
117
+ const root = options.root ?? projectWorkspacePath(project);
118
118
  const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
119
119
  await cell.files.mkdir(root, { parents: true });
120
- await seedAgentRepository(ctx, ws, project.folder, { root });
120
+ if (options.seed !== false)
121
+ await seedAgentRepository(ctx, ws, project.folder, { root });
121
122
  const store = new AgentStateStore(ctx.store.dir);
122
123
  const key = `${ws.id}:${project.folder}`;
123
124
  const states = store.read().project_sync;
@@ -130,7 +131,7 @@ export async function startProjectSync(ctx, ws, project) {
130
131
  });
131
132
  };
132
133
  const report = (message) => { ctx.io.stderr.write(`${message}\n`); };
133
- const reconcile = projectReconciler({ folder: project.folder, root, agent: ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal });
134
+ const reconcile = projectReconciler({ folder: project.folder, root, agent: options.agent ?? ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal });
134
135
  const abort = new AbortController();
135
136
  const signal = ctx.signal ? AbortSignal.any([ctx.signal, abort.signal]) : abort.signal;
136
137
  let stopped = false, dirty = false;