@shardflux/cli 0.23.1 → 0.24.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +249 -4
  3. package/dist/account-args.js +8 -3
  4. package/dist/account-commands.js +15 -2
  5. package/dist/account-pool.js +5 -1
  6. package/dist/acp-journal.d.ts +2 -2
  7. package/dist/acp-journal.js +14 -4
  8. package/dist/acp-permission-elsewhere.d.ts +38 -0
  9. package/dist/acp-permission-elsewhere.js +85 -0
  10. package/dist/acp-rpc.d.ts +2 -1
  11. package/dist/acp-rpc.js +11 -2
  12. package/dist/acp.js +125 -30
  13. package/dist/agent-credentials.d.ts +3 -0
  14. package/dist/agent-credentials.js +7 -0
  15. package/dist/args.d.ts +1 -1
  16. package/dist/args.js +154 -8
  17. package/dist/board-args.d.ts +6 -0
  18. package/dist/board-args.js +155 -0
  19. package/dist/board-commands.d.ts +62 -0
  20. package/dist/board-commands.js +395 -0
  21. package/dist/codex-cloud.d.ts +30 -3
  22. package/dist/codex-cloud.js +86 -12
  23. package/dist/conversation-commands.d.ts +30 -0
  24. package/dist/conversation-commands.js +312 -9
  25. package/dist/conversation-transcript.d.ts +44 -0
  26. package/dist/conversation-transcript.js +206 -0
  27. package/dist/conversations.d.ts +5 -2
  28. package/dist/conversations.js +20 -10
  29. package/dist/default-volume-commands.d.ts +3 -0
  30. package/dist/default-volume-commands.js +35 -0
  31. package/dist/device-approvals.js +4 -3
  32. package/dist/device-bridge.d.ts +42 -1
  33. package/dist/device-bridge.js +241 -14
  34. package/dist/device-folders.d.ts +58 -0
  35. package/dist/device-folders.js +228 -0
  36. package/dist/device-helper.d.ts +6 -0
  37. package/dist/device-helper.js +87 -14
  38. package/dist/drive-commands.d.ts +25 -0
  39. package/dist/drive-commands.js +201 -0
  40. package/dist/filesystem-commands.js +2 -1
  41. package/dist/handlers.js +6 -1
  42. package/dist/local-sessions.d.ts +7 -1
  43. package/dist/local-sessions.js +38 -0
  44. package/dist/main.js +3 -0
  45. package/dist/move-files.d.ts +39 -0
  46. package/dist/move-files.js +167 -0
  47. package/dist/move-interrupt.d.ts +18 -0
  48. package/dist/move-interrupt.js +193 -0
  49. package/dist/move.js +28 -17
  50. package/dist/onboarding.d.ts +6 -2
  51. package/dist/onboarding.js +24 -6
  52. package/dist/project-git.d.ts +7 -1
  53. package/dist/project-git.js +16 -3
  54. package/dist/project-preparation.d.ts +7 -0
  55. package/dist/project-preparation.js +14 -0
  56. package/dist/project-template-setup.js +32 -11
  57. package/dist/router-commands.d.ts +22 -0
  58. package/dist/router-commands.js +179 -0
  59. package/dist/worktree-twin-engine.js +16 -6
  60. package/package.json +7 -5
@@ -39,6 +39,9 @@ export interface Conversation {
39
39
  base_branch?: string | null;
40
40
  role?: string;
41
41
  join_mode?: string | null;
42
+ /** A conversation on the machine's home, without a repository (0.25.0+, APIs that return it): its `repo_remote`,
43
+ * `branch` and `base_commit` are empty and its agent runs in `/home/user`. */
44
+ home?: boolean;
42
45
  /** The model and effort its harness starts with; null is the agent's own default (0.17.0+, APIs that keep them). */
43
46
  model?: string | null;
44
47
  effort?: string | null;
@@ -118,8 +121,8 @@ export declare function resolveConversation(ctx: Ctx, ref: string, folder?: stri
118
121
  /** Re-resolve the binding on every attach: a move changes the VM, never the conversation identity. */
119
122
  export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promise<Workspace>;
120
123
  /**
121
- * A thread on an existing computer (a join) is prepared by that computer's guest: its worktree, then its harness. Waits
122
- * until the guest confirms it (the conversation no longer carries restore instructions), within the wake timeout.
124
+ * A start its guest prepares (GUEST_PREPARED): its worktree, then its harness. Waits until the guest confirms it (the
125
+ * conversation no longer carries those restore instructions), within the wake timeout.
123
126
  */
124
127
  export declare function waitThreadReady(ctx: Ctx, c: Conversation, pollMs?: number): Promise<Conversation>;
125
128
  export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws: Workspace): Promise<void>;
@@ -24,7 +24,8 @@ import { recordSetupIntent } from "./project-template-setup.js";
24
24
  export const conversationEnded = (c) => !!c.ended_at || c.state === 'archived' || c.landed?.how === 'discarded' || (c.route === 'codex-cloud' && c.state === 'done');
25
25
  export const conversationName = (c) => {
26
26
  const name = c.key.split('/').at(-1);
27
- if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(name) || c.branch !== `shard/${name}`)
27
+ // A home conversation has no repository, so no branch.
28
+ if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(name) || c.branch !== (c.home === true ? '' : `shard/${name}`))
28
29
  throw new Error('Conversation returned an invalid branch or name');
29
30
  return name;
30
31
  };
@@ -60,7 +61,8 @@ export async function conversationPtyId(cell, c, signal) {
60
61
  return typeof recorded === 'string' && MANAGED_PTY.test(recorded) && recorded.endsWith(`-${c.harness}`) ? recorded : derived;
61
62
  }
62
63
  export function validateConversation(c) {
63
- if (!c || !/^conv_[a-z0-9]{26}$/.test(c.id) || !c.project_id || !c.workspace_id || !c.worktree_path?.startsWith('/home/user/') || /[\0\r\n]/.test(c.worktree_path) || !['claude', 'codex'].includes(c.harness))
64
+ const worktree = c?.home === true ? c.worktree_path === '/home/user' : c?.worktree_path?.startsWith('/home/user/');
65
+ if (!c || !/^conv_[a-z0-9]{26}$/.test(c.id) || !c.project_id || !c.workspace_id || !worktree || /[\0\r\n]/.test(c.worktree_path) || !['claude', 'codex'].includes(c.harness))
64
66
  throw new Error('Conversation response omitted its identity or binding');
65
67
  conversationName(c);
66
68
  return c;
@@ -156,14 +158,19 @@ export async function conversationWorkspace(ctx, c) {
156
158
  return ws;
157
159
  }
158
160
  /**
159
- * A thread on an existing computer (a join) is prepared by that computer's guest: its worktree, then its harness. Waits
160
- * until the guest confirms it (the conversation no longer carries restore instructions), within the wake timeout.
161
+ * Starts its computer's guest prepares by itself, never this command: a thread on an existing computer (a join), a
162
+ * conversation started from the project hub (0.25.0+) and one on the machine's home (0.25.0+).
163
+ */
164
+ const GUEST_PREPARED = new Set(['join', 'hub', 'home']);
165
+ /**
166
+ * A start its guest prepares (GUEST_PREPARED): its worktree, then its harness. Waits until the guest confirms it (the
167
+ * conversation no longer carries those restore instructions), within the wake timeout.
161
168
  */
162
169
  export async function waitThreadReady(ctx, c, pollMs = 1000) {
163
170
  const deadline = Date.now() + ctx.wakeTimeoutMs;
164
171
  for (;;) {
165
172
  const current = await getConversation(ctx, c.id);
166
- if (!current.restore || current.restore.kind !== 'join')
173
+ if (!current.restore || !GUEST_PREPARED.has(current.restore.kind))
167
174
  return current;
168
175
  if (current.state === 'done' || current.state === 'archived' || current.ended_at)
169
176
  throw new UsageError('This thread ended before it started.');
@@ -181,7 +188,7 @@ export async function restoreConversationBinding(ctx, c, ws) {
181
188
  if (!c.restore || c.restore.kind === 'new')
182
189
  return;
183
190
  // Threads on an existing computer (0.16.0+): its guest prepares them, never this command.
184
- if (c.restore.kind === 'join') {
191
+ if (GUEST_PREPARED.has(c.restore.kind)) {
185
192
  await waitThreadReady(ctx, c);
186
193
  return;
187
194
  }
@@ -192,10 +199,13 @@ export async function restoreConversationBinding(ctx, c, ws) {
192
199
  throw new Error(`Reviewer restore failed: ${review.stderr.trim()}`);
193
200
  return;
194
201
  }
195
- await cell.files.mkdir(c.worktree_path, { parents: true });
196
- const init = await cell.exec.run(['git', '-C', c.worktree_path, 'init'], { timeoutMs: 30_000, signal: ctx.signal });
197
- if (init.exitCode !== 0)
198
- throw new Error(`Conversation restore could not initialize its repository: ${init.stderr.trim()}`);
202
+ // A home conversation runs in /home/user without a repository: its guest restores only its transcript.
203
+ if (c.home !== true && c.restore.home !== true) {
204
+ await cell.files.mkdir(c.worktree_path, { parents: true });
205
+ const init = await cell.exec.run(['git', '-C', c.worktree_path, 'init'], { timeoutMs: 30_000, signal: ctx.signal });
206
+ if (init.exitCode !== 0)
207
+ throw new Error(`Conversation restore could not initialize its repository: ${init.stderr.trim()}`);
208
+ }
199
209
  const result = await cell.exec.run(['sf-agent', 'conversation', 'restore', '--args', '{"start":false}'], { cwd: c.worktree_path, env: { SF_CONVERSATION_ID: c.id }, timeoutMs: 120_000, signal: ctx.signal });
200
210
  if (result.exitCode !== 0)
201
211
  throw new Error(`Conversation restore failed: ${result.stderr.trim()}`);
@@ -0,0 +1,3 @@
1
+ import type { Ctx } from './commands.js';
2
+ import type { Values } from './args.js';
3
+ export declare function defaultVolumeCommand(ctx: Ctx, values: Values, args: string[], action: 'attach' | 'detach'): Promise<number | undefined>;
@@ -0,0 +1,35 @@
1
+ import { NotFoundError, UsageError } from "./errors.js";
2
+ import { projectIdentity, normalizeRemote } from "./project-identity.js";
3
+ export async function defaultVolumeCommand(ctx, values, args, action) {
4
+ if (values.machine !== true && values.template !== true) {
5
+ if (values.path !== undefined)
6
+ throw new UsageError('--path requires --template');
7
+ return undefined;
8
+ }
9
+ if (values.machine === true && values.template === true)
10
+ throw new UsageError('Choose --machine or --template');
11
+ if (args.length !== 1)
12
+ throw new UsageError(`Use shard volumes ${action} --machine <volume> or --template <volume>`);
13
+ if (values.machine === true && values.path !== undefined)
14
+ throw new UsageError('--path requires --template');
15
+ const cloud = ctx.cloud();
16
+ const project = values.template === true ? projectIdentity(typeof values.path === 'string' ? values.path : '.') : undefined;
17
+ const api = project ? cloud.templates.projectVolumes(project.remote ?? normalizeRemote(`file://${project.folder}`)) : cloud.machine.volumes;
18
+ const options = { signal: ctx.signal };
19
+ let result;
20
+ if (action === 'attach') {
21
+ if (typeof values.mount !== 'string')
22
+ throw new UsageError('--mount /mnt/data is required');
23
+ const ref = args[0];
24
+ result = await api.attach({ ...(/^[a-f0-9-]{36}$/i.test(ref) ? { volumeId: ref } : { name: ref }), mountPath: values.mount, mode: values['read-only'] === true ? 'ro' : 'rw' }, options);
25
+ }
26
+ else {
27
+ const list = await api.list(options), v = list.volumes.find(v => v.volume_id === args[0] || v.name === args[0]);
28
+ if (!v)
29
+ throw new NotFoundError(`Default volume ${args[0]} was not found`);
30
+ result = await api.detach(v.volume_id, options);
31
+ }
32
+ ctx.io.stdout.write(ctx.json ? JSON.stringify(result) + '\n' : `${values.template === true ? 'template' : 'machine'}: ${result.volumes.map(v => `${v.name} at ${v.mount_path} (${v.mode})`).join(', ') || 'no default volumes'}\n` +
33
+ result.live.map(v => `${v.workspace_id}: ${v.status}${v.reason ? ` (${v.reason})` : ''}\n`).join(''));
34
+ return 0;
35
+ }
@@ -34,7 +34,8 @@ export function answerDeviceApproval(dir, id, choice) {
34
34
  const pending = pendingDeviceApprovals(dir).find(p => p.request_id === id);
35
35
  if (!pending)
36
36
  throw new UsageError('That request is no longer waiting for approval. Run shard device requests.');
37
- if (choice === 'always' && pending.outside)
37
+ // A folder request's Always is that folder's grant (device-folders.ts); other access outside the conversation folder is once only.
38
+ if (choice === 'always' && pending.outside && pending.scope !== 'folder')
38
39
  throw new UsageError('Access outside the conversation folder must be approved once.');
39
40
  writePrivateFile(join(dir, 'device-answers'), paths(dir, id).answer, JSON.stringify({ nonce: pending.nonce, choice }));
40
41
  }
@@ -64,12 +65,12 @@ export async function askLocalDeviceApproval(ctx, approval) {
64
65
  export const DEVICE_HANDLERS = {
65
66
  'device requests'(ctx) {
66
67
  const requests = pendingDeviceApprovals(ctx.store.dir);
67
- ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}\n shard device approve ${r.request_id} | shard device deny ${r.request_id}\n`).join('') : 'No device requests waiting for approval.\n');
68
+ ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}${r.repo_remote ? `\n Repository: ${r.repo_remote}` : ''}${r.brief ? `\n First message: ${r.brief.split('\n')[0].slice(0, 200)}` : ''}\n shard device approve ${r.request_id}${r.scope === 'folder' ? ` [--always: for ${r.folder}]` : ''} | shard device deny ${r.request_id}\n`).join('') : 'No device requests waiting for approval.\n');
68
69
  return Promise.resolve(0);
69
70
  },
70
71
  ...Object.fromEntries(['approve', 'deny'].map(action => [`device ${action}`, (ctx, values, [id]) => {
71
72
  answerDeviceApproval(ctx.store.dir, id, action === 'approve' ? values.always === true ? 'always' : 'once' : 'deny');
72
- ctx.io.stdout.write(ctx.json ? json({ request_id: id, choice: action === 'approve' && values.always ? 'always' : action }) : `Device request ${id}: ${action === 'approve' ? values.always ? 'allowed for project' : 'approved once' : 'denied'}.\n`);
73
+ ctx.io.stdout.write(ctx.json ? json({ request_id: id, choice: action === 'approve' && values.always ? 'always' : action }) : `Device request ${id}: ${action === 'approve' ? values.always ? 'always allowed' : 'approved once' : 'denied'}.\n`);
73
74
  return Promise.resolve(0);
74
75
  }])),
75
76
  };
@@ -1,6 +1,7 @@
1
1
  import type { CellClient, Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
3
  import type { Conversation } from './conversations.js';
4
+ import type { DirListing, LinkedProject } from './device-folders.js';
4
5
  export type DeviceChoice = 'once' | 'always' | 'deny';
5
6
  export interface DeviceRequest {
6
7
  id: string;
@@ -8,7 +9,7 @@ export interface DeviceRequest {
8
9
  conversation_id: string;
9
10
  state: string;
10
11
  device_id: string | null;
11
- kind: 'exec' | 'files_pull' | 'files_push' | 'port_forward' | 'port_reverse' | 'try_locally';
12
+ kind: 'exec' | 'files_pull' | 'files_push' | 'port_forward' | 'port_reverse' | 'try_locally' | 'dir_list' | 'project_link';
12
13
  exec?: {
13
14
  argv: string[];
14
15
  cwd_relative?: string;
@@ -23,6 +24,17 @@ export interface DeviceRequest {
23
24
  remote_port: number;
24
25
  local_port: number;
25
26
  };
27
+ dir?: {
28
+ path: string;
29
+ depth?: number;
30
+ max_entries?: number;
31
+ include_hidden?: boolean;
32
+ };
33
+ link?: {
34
+ path: string;
35
+ brief?: string;
36
+ harness?: 'claude' | 'codex';
37
+ };
26
38
  }
27
39
  export interface DeviceApproval {
28
40
  type: 'approval';
@@ -39,6 +51,17 @@ export interface DeviceApproval {
39
51
  conversation_title?: string;
40
52
  local_port?: number;
41
53
  remote_port?: number;
54
+ /**
55
+ * 'folder' (0.25.0+): Always is offered and covers `folder` and everything inside it for this kind (device-folders.ts).
56
+ * `outside` stays true, so a surface that predates folder grants offers Allow once only.
57
+ */
58
+ scope?: 'folder';
59
+ folder?: string;
60
+ /** project_link: the repository the folder's Git origin names (null without one) and the first prompt, when one starts. */
61
+ repo_remote?: string | null;
62
+ brief?: string;
63
+ /** dir_list: how many levels below the folder are listed. */
64
+ depth?: number;
42
65
  }
43
66
  type Result = {
44
67
  state: 'approved' | 'denied' | 'running' | 'done' | 'failed';
@@ -46,6 +69,8 @@ type Result = {
46
69
  stdout_b64?: string;
47
70
  stderr_b64?: string;
48
71
  files_artifact?: string;
72
+ dir?: DirListing;
73
+ project?: LinkedProject;
49
74
  };
50
75
  /** Resolve existing ancestors even before the conversation worktree is created. */
51
76
  export declare function devicePath(root: string, requested?: string): {
@@ -61,6 +86,10 @@ export declare class DeviceGrants {
61
86
  kinds: string[];
62
87
  }>;
63
88
  allows(api: string, project: string, root: string, kind: string, outside: boolean): boolean;
89
+ /** A folder grant (0.25.0+): Always for `root` and everything inside it, for one kind of request. */
90
+ folderKey(api: string, root: string): string;
91
+ allowsFolder(api: string, target: string, kind: string): boolean;
92
+ saveFolder(api: string, root: string, kind: string): Promise<void>;
64
93
  save(api: string, project: string, root: string, kind: string): Promise<void>;
65
94
  }
66
95
  export declare function deviceSummary(r: DeviceRequest, platform?: NodeJS.Platform): string;
@@ -69,6 +98,10 @@ export declare function deviceApproval(r: DeviceRequest, c: Conversation, target
69
98
  path: string;
70
99
  outside: boolean;
71
100
  }, folder: string): DeviceApproval;
101
+ /** A request on a folder anywhere on this device: the folder kinds, and file moves named by an absolute or ~/ path. */
102
+ export declare function folderRequest(r: DeviceRequest): boolean;
103
+ /** What a folder request's approval shows: the canonical path, the folder Always would cover, and what will happen. */
104
+ export declare function folderApproval(r: DeviceRequest, c: Conversation, target: string, folder: string, home: string, remote?: string | null, platform?: NodeJS.Platform): DeviceApproval;
72
105
  export declare function tryLocallyAction(r: DeviceRequest): 'start' | 'stop' | null;
73
106
  export declare function ensureConversationWorktree(ctx: Ctx, c: Conversation, folder: string, ws?: Workspace): Promise<string>;
74
107
  export declare function deviceExec(argv: string[], cwd: string, timeoutSeconds?: number, signal?: AbortSignal): Promise<Result>;
@@ -90,4 +123,12 @@ export declare function serveAcpDeviceApprovals(dir: string, id: string, ask: (a
90
123
  stop(): Promise<void>;
91
124
  }>;
92
125
  export declare function askConnectedAcp(dir: string, approval: DeviceApproval, signal: AbortSignal): Promise<DeviceChoice | null>;
126
+ /**
127
+ * The desktop app's approval hop (0.25.0+): while it runs it serves `<config dir>/device-approver.sock` (or, when that
128
+ * path is too long for a socket, `/tmp/sf-device-<uid>/<hash>-approver.sock`) and shows each approval as a Needs-you
129
+ * item. One line in (the DeviceApproval JSON), one line out (`{"choice":"once|always|deny"}`). No app, a socket that is
130
+ * not this user's own, or one that closes without an answer: null, and the next surface asks.
131
+ */
132
+ export declare function approverSocket(dir: string): string;
133
+ export declare function askDeviceApprover(dir: string, approval: DeviceApproval, signal: AbortSignal): Promise<DeviceChoice | null>;
93
134
  export {};
@@ -15,20 +15,16 @@ import { conversationClient } from "./conversation-commands.js";
15
15
  import { withTargetLock } from "./target-lock.js";
16
16
  import { checkConversationWorktree, rememberConversationWorktree } from "./conversation-worktree.js";
17
17
  import { yourComputer } from "./this-computer.js";
18
+ import { canonicalDevicePath, deviceHome, displayPath, expandDevicePath, FOLDER_KINDS, isDevicePath, linkLocalProject, listDeviceDir } from "./device-folders.js";
18
19
  const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
19
20
  const inside = (root, target) => target === root || target.startsWith(root + sep);
21
+ /**
22
+ * A request belongs to its conversation, not to a turn: it is answered while the conversation exists, between turns
23
+ * (done) and as a turn ends too (0.25.0+); only an archived (or deleted) conversation refuses it.
24
+ */
25
+ const archived = (c) => c.state === 'archived';
20
26
  const safeId = (value) => { if (!/^[a-zA-Z0-9_-]{1,96}$/.test(value))
21
27
  throw new Error('Invalid device request identity'); return value; };
22
- function canonicalDevicePath(target) {
23
- let ancestor = target;
24
- while (!existsSync(ancestor)) {
25
- const parent = dirname(ancestor);
26
- if (parent === ancestor)
27
- throw new UsageError('Device path is unavailable.');
28
- ancestor = parent;
29
- }
30
- return resolve(realpathSync(ancestor), relative(ancestor, target));
31
- }
32
28
  /** Resolve existing ancestors even before the conversation worktree is created. */
33
29
  export function devicePath(root, requested = '.') {
34
30
  if (/[\0\r\n]/.test(requested))
@@ -60,6 +56,30 @@ export class DeviceGrants {
60
56
  const grant = this.read()[this.key(api, project)];
61
57
  return !outside && grant?.root === realpathSync(root) && Array.isArray(grant.kinds) && grant.kinds.includes(kind);
62
58
  }
59
+ /** A folder grant (0.25.0+): Always for `root` and everything inside it, for one kind of request. */
60
+ folderKey(api, root) { return JSON.stringify(['folder', api, root]); }
61
+ allowsFolder(api, target, kind) {
62
+ return Object.entries(this.read()).some(([key, grant]) => {
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(key);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ return Array.isArray(parsed) && parsed.length === 3 && parsed[0] === 'folder' && parsed[1] === api && typeof grant?.root === 'string' && parsed[2] === grant.root
71
+ && Array.isArray(grant.kinds) && grant.kinds.includes(kind) && inside(grant.root, target);
72
+ });
73
+ }
74
+ async saveFolder(api, root, kind) {
75
+ if (kind === 'exec')
76
+ throw new UsageError('Commands never run under a folder grant.');
77
+ await withTargetLock(join(dirname(this.file), 'device-grants-lock'), () => {
78
+ const all = this.read(), key = this.folderKey(api, root);
79
+ all[key] = { root, kinds: [...new Set([...(all[key]?.root === root ? all[key].kinds : []), kind])] };
80
+ writePrivateFile(dirname(this.file), this.file, JSON.stringify(all) + '\n');
81
+ });
82
+ }
63
83
  async save(api, project, root, kind) {
64
84
  await withTargetLock(join(dirname(this.file), 'device-grants-lock'), () => {
65
85
  const all = this.read(), key = this.key(api, project), canonical = realpathSync(root);
@@ -70,6 +90,10 @@ export class DeviceGrants {
70
90
  }
71
91
  export function deviceSummary(r, platform = process.platform) {
72
92
  const yours = yourComputer(platform);
93
+ if (r.kind === 'dir_list')
94
+ return `List ${r.dir?.path} on ${yours}: names, sizes and Git remotes, no file contents.`;
95
+ if (r.kind === 'project_link')
96
+ return `Make ${r.link?.path} a project and copy its files to the cloud${r.link?.brief ? ', then start a conversation there' : ''}.`;
73
97
  if (tryLocallyAction(r))
74
98
  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.';
75
99
  if (r.kind === 'exec')
@@ -88,6 +112,23 @@ export function deviceApproval(r, c, target, folder) {
88
112
  ...(r.port ? { local_port: r.port.local_port, remote_port: r.port.remote_port } : {}),
89
113
  };
90
114
  }
115
+ /** A request on a folder anywhere on this device: the folder kinds, and file moves named by an absolute or ~/ path. */
116
+ export function folderRequest(r) {
117
+ return FOLDER_KINDS.has(r.kind) || ((r.kind === 'files_pull' || r.kind === 'files_push') && !!r.files && isDevicePath(r.files.local_path_relative));
118
+ }
119
+ /** What a folder request's approval shows: the canonical path, the folder Always would cover, and what will happen. */
120
+ export function folderApproval(r, c, target, folder, home, remote, platform = process.platform) {
121
+ const shown = displayPath(target, home), yours = yourComputer(platform);
122
+ const summary = r.kind === 'dir_list' ? `List ${shown} on ${yours}: names, sizes and Git remotes, no file contents.`
123
+ : r.kind === 'project_link' ? `Make ${shown} a project and copy its files to the cloud (uncommitted changes too; .gitignore respected)${r.link?.brief ? ', then start a conversation there' : ''}.`
124
+ : r.kind === 'files_pull' ? `Share with conversation: ${shown} → ${r.files?.remote_path}` : `Save on ${yours}: ${r.files?.remote_path} → ${shown}`;
125
+ return {
126
+ type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id, summary, outside: true, scope: 'folder', folder,
127
+ kind: r.kind, local_path: target, project_name: basename(folder), conversation_title: c.title || conversationName(c),
128
+ ...(r.files ? { remote_path: r.files.remote_path } : {}), ...(r.dir ? { depth: r.dir.depth ?? 1 } : {}),
129
+ ...(r.kind === 'project_link' ? { repo_remote: remote ?? null, ...(r.link?.brief ? { brief: r.link.brief } : {}) } : {}),
130
+ };
131
+ }
91
132
  export function tryLocallyAction(r) {
92
133
  if (r.kind === 'try_locally')
93
134
  return 'start';
@@ -171,6 +212,8 @@ export class DeviceExecutor {
171
212
  safeId(r.id);
172
213
  if (r.state !== 'pending' || r.device_id !== this.#deviceId)
173
214
  return;
215
+ if (folderRequest(r))
216
+ return this.#folder(r);
174
217
  const records = localConversations(ctx);
175
218
  const matching = Object.values(records).filter(record => record.project_id === r.project_id);
176
219
  const folders = new Set(matching.map(record => record.folder));
@@ -199,7 +242,7 @@ export class DeviceExecutor {
199
242
  if (devicePath(record.folder, join('.shard', 'worktrees', conversationName(c))).path !== planned.path || devicePath(planned.path, requested).path !== target.path)
200
243
  throw new Error('Device path changed during approval.');
201
244
  const current = await getConversation(ctx, r.conversation_id);
202
- if (current.project_id !== c.project_id || current.branch !== c.branch || current.state === 'done' || current.state === 'archived')
245
+ if (current.project_id !== c.project_id || current.branch !== c.branch || archived(current))
203
246
  throw new Error('The conversation changed or ended during device approval.');
204
247
  c = current;
205
248
  await this.result(r, { state: 'approved' });
@@ -214,7 +257,7 @@ export class DeviceExecutor {
214
257
  if (root !== planned.path || devicePath(root, requested).path !== target.path)
215
258
  throw new Error('Device path changed during preparation.');
216
259
  const prepared = await getConversation(ctx, c.id);
217
- if (prepared.project_id !== c.project_id || prepared.workspace_id !== c.workspace_id || prepared.branch !== c.branch || prepared.state === 'done' || prepared.state === 'archived')
260
+ if (prepared.project_id !== c.project_id || prepared.workspace_id !== c.workspace_id || prepared.branch !== c.branch || archived(prepared))
218
261
  throw new Error('The conversation changed or ended during device preparation.');
219
262
  const cell = ws.cell();
220
263
  let result;
@@ -270,6 +313,8 @@ export class DeviceExecutor {
270
313
  result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : relative(root, target.path) };
271
314
  }
272
315
  else {
316
+ if (r.kind !== 'port_forward' && r.kind !== 'port_reverse')
317
+ throw new UsageError('Unknown device request kind.');
273
318
  if (!r.port || ![r.port.local_port, r.port.remote_port].every(p => Number.isInteger(p) && p > 0 && p <= 65535))
274
319
  throw new UsageError('Device ports must be 1–65535.');
275
320
  const handle = await devicePort(ws, cell, r.kind, r.port, ctx.signal);
@@ -286,6 +331,129 @@ export class DeviceExecutor {
286
331
  ws?.cell().close();
287
332
  }
288
333
  }
334
+ /**
335
+ * A folder anywhere on this device (device-folders.ts). Every outcome is answered, so a request is never asked twice:
336
+ * denied before it was approved, failed after.
337
+ */
338
+ async #folder(r) {
339
+ const ctx = this.#ctx, home = deviceHome(ctx), deny = (message) => this.result(r, { state: 'denied', stderr_b64: Buffer.from(message).toString('base64') });
340
+ let c;
341
+ try {
342
+ c = await getConversation(ctx, r.conversation_id);
343
+ if (c.project_id !== r.project_id)
344
+ throw new Error('Device request project changed.');
345
+ }
346
+ catch (e) {
347
+ if (ctx.signal.aborted)
348
+ return;
349
+ await deny(e instanceof Error ? e.message : String(e));
350
+ return;
351
+ }
352
+ if (archived(c)) {
353
+ await deny('The conversation has ended.');
354
+ return;
355
+ }
356
+ const requested = r.dir?.path ?? r.link?.path ?? r.files?.local_path_relative ?? '';
357
+ const resolveTarget = () => canonicalDevicePath(expandDevicePath(requested, home));
358
+ let target, folder, remote = null;
359
+ try {
360
+ target = resolveTarget();
361
+ folder = r.kind === 'files_pull' || r.kind === 'files_push' ? canonicalDevicePath(dirname(target)) : target;
362
+ if (r.kind === 'project_link') {
363
+ const { projectIdentity } = await import("./project-identity.js");
364
+ remote = projectIdentity(target).remote;
365
+ }
366
+ }
367
+ catch (e) {
368
+ await deny(e instanceof Error ? e.message : String(e));
369
+ return;
370
+ }
371
+ const grants = new DeviceGrants(ctx.store.dir);
372
+ let choice;
373
+ try {
374
+ choice = grants.allowsFolder(ctx.apiUrl, target, r.kind) ? 'once' : await this.#ask(folderApproval(r, c, target, folder, home, remote));
375
+ }
376
+ catch (e) {
377
+ if (ctx.signal.aborted)
378
+ return;
379
+ await deny(e instanceof Error ? e.message : String(e));
380
+ return;
381
+ }
382
+ if (ctx.signal.aborted)
383
+ return;
384
+ if (choice !== 'once' && choice !== 'always') {
385
+ await this.result(r, { state: 'denied' });
386
+ return;
387
+ }
388
+ if (choice === 'always')
389
+ await grants.saveFolder(ctx.apiUrl, folder, r.kind);
390
+ // Re-read after the approval, before claiming or touching data: a symlink swapped meanwhile is a different path.
391
+ let now;
392
+ try {
393
+ now = resolveTarget();
394
+ }
395
+ catch {
396
+ now = '';
397
+ }
398
+ if (now !== target) {
399
+ await deny('Device path changed during approval.');
400
+ return;
401
+ }
402
+ const current = await getConversation(ctx, r.conversation_id).catch(() => undefined);
403
+ if (!current || current.project_id !== c.project_id || archived(current)) {
404
+ await deny('The conversation changed or ended during device approval.');
405
+ return;
406
+ }
407
+ await this.result(r, { state: 'approved' });
408
+ await this.result(r, { state: 'running' });
409
+ let ws;
410
+ try {
411
+ let result;
412
+ if (r.kind === 'dir_list')
413
+ result = { state: 'done', dir: await listDeviceDir(target, r.dir ?? {}, ctx.signal) };
414
+ else if (r.kind === 'project_link')
415
+ result = { state: 'done', project: await linkLocalProject(ctx, target, { ...(r.link?.brief ? { brief: r.link.brief } : {}), harness: r.link?.harness ?? current.harness, projectId: current.project_id }) };
416
+ else {
417
+ const f = r.files;
418
+ if (!f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
419
+ throw new UsageError('Device transfer requires an absolute VM path.');
420
+ const max = f.max_bytes ?? LIMIT;
421
+ if (!Number.isSafeInteger(max) || max < 1 || max > LIMIT)
422
+ throw new UsageError('Device transfer maximum is 256 MiB.');
423
+ ws = await conversationWorkspace({ ...ctx, signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(30_000)]) }, current);
424
+ const cell = ws.cell();
425
+ if (r.kind === 'files_pull') {
426
+ const info = await stat(target);
427
+ if (!info.isFile() || info.size > max)
428
+ throw new UsageError('Local transfer file exceeds the requested size or is not a file.');
429
+ const bytes = await readFile(target);
430
+ if (bytes.length > max)
431
+ throw new UsageError('Local transfer file grew during reading.');
432
+ await writeSyncBlob(cell, f.remote_path, bytes);
433
+ }
434
+ else {
435
+ const info = await cell.files.stat(f.remote_path);
436
+ if (info.type !== 'file' || info.size > max)
437
+ throw new UsageError('VM transfer file exceeds the requested size or is not a file.');
438
+ const bytes = await cell.files.read(f.remote_path, { length: max + 1 });
439
+ if (bytes.length > max || bytes.length !== info.size)
440
+ throw new UsageError('VM transfer file changed during reading.');
441
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
442
+ if (await realpath(dirname(target)) !== folder)
443
+ throw new Error('Transfer folder changed during the transfer.');
444
+ await writeFile(target, bytes, { mode: 0o600, flag: 'wx' }); // An existing file needs a fresh explicit request, never a silent clobber.
445
+ }
446
+ result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : target };
447
+ }
448
+ await this.result(r, result);
449
+ }
450
+ catch (e) {
451
+ await this.result(r, { state: 'failed', stderr_b64: Buffer.from(e instanceof Error ? e.message : String(e)).toString('base64') });
452
+ }
453
+ finally {
454
+ ws?.cell().close();
455
+ }
456
+ }
289
457
  async reap() {
290
458
  for (const [id, handle] of this.#handles) {
291
459
  const c = await getConversation(this.#ctx, handle.conversation);
@@ -323,8 +491,24 @@ export async function devicePort(ws, cell, kind, ports, signal) {
323
491
  await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(ports.local_port, '127.0.0.1', resolveListen); });
324
492
  let stopping;
325
493
  const abort = () => { void stop().catch(() => undefined); };
326
- const stop = () => stopping ??= (async () => { signal.removeEventListener('abort', abort); for (const socket of peers)
327
- socket.destroy(); await Promise.all([...handles].map(h => h.close())); await new Promise(resolveClose => server.close(() => resolveClose())); cell.close(); })();
494
+ const stop = () => stopping ??= (async () => {
495
+ signal.removeEventListener('abort', abort);
496
+ for (const socket of peers)
497
+ socket.destroy();
498
+ let failure;
499
+ await Promise.all([...handles].map(async (handle) => {
500
+ try {
501
+ await handle.close();
502
+ }
503
+ catch (error) {
504
+ failure ??= { error };
505
+ }
506
+ }));
507
+ await new Promise(resolveClose => server.close(() => resolveClose()));
508
+ cell.close();
509
+ if (failure)
510
+ throw failure.error;
511
+ })();
328
512
  signal.addEventListener('abort', abort, { once: true });
329
513
  if (signal.aborted)
330
514
  abort();
@@ -419,3 +603,46 @@ export async function askConnectedAcp(dir, approval, signal) {
419
603
  } });
420
604
  });
421
605
  }
606
+ /**
607
+ * The desktop app's approval hop (0.25.0+): while it runs it serves `<config dir>/device-approver.sock` (or, when that
608
+ * path is too long for a socket, `/tmp/sf-device-<uid>/<hash>-approver.sock`) and shows each approval as a Needs-you
609
+ * item. One line in (the DeviceApproval JSON), one line out (`{"choice":"once|always|deny"}`). No app, a socket that is
610
+ * not this user's own, or one that closes without an answer: null, and the next surface asks.
611
+ */
612
+ export function approverSocket(dir) {
613
+ const path = join(dir, 'device-approver.sock');
614
+ if (Buffer.byteLength(path) < 100)
615
+ return path;
616
+ return join('/tmp', `sf-device-${process.getuid?.() ?? 'user'}`, `${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 20)}-approver.sock`);
617
+ }
618
+ export async function askDeviceApprover(dir, approval, signal) {
619
+ const path = approverSocket(dir), uid = process.getuid?.();
620
+ try {
621
+ const socket = lstatSync(path), parent = lstatSync(dirname(path));
622
+ if (!socket.isSocket() || parent.isSymbolicLink() || !parent.isDirectory() || (parent.mode & 0o077) || (uid !== undefined && (socket.uid !== uid || parent.uid !== uid)))
623
+ return null;
624
+ }
625
+ catch {
626
+ return null;
627
+ }
628
+ return new Promise((resolveChoice, reject) => {
629
+ const socket = createConnection(path);
630
+ let text = '';
631
+ const abort = () => { socket.destroy(); reject(signal.reason instanceof Error ? signal.reason : new Error('Device approval interrupted')); };
632
+ signal.addEventListener('abort', abort, { once: true });
633
+ socket.on('connect', () => socket.write(JSON.stringify(approval) + '\n'));
634
+ socket.on('data', bytes => { text += String(bytes); if (text.length > 1024)
635
+ socket.destroy(); });
636
+ socket.once('error', () => { signal.removeEventListener('abort', abort); resolveChoice(null); });
637
+ socket.once('close', () => {
638
+ signal.removeEventListener('abort', abort);
639
+ try {
640
+ const choice = JSON.parse(text.split('\n')[0]).choice;
641
+ resolveChoice(['once', 'always', 'deny'].includes(choice) ? choice : null);
642
+ }
643
+ catch {
644
+ resolveChoice(null);
645
+ }
646
+ });
647
+ });
648
+ }
@@ -0,0 +1,58 @@
1
+ import type { Ctx, Handler } from './commands.js';
2
+ export declare const FOLDER_KINDS: Set<string>;
3
+ /** An absolute or `~/…` path: one the device resolves on its own, outside any conversation's folder. */
4
+ export declare function isDevicePath(path: string): boolean;
5
+ export declare function deviceHome(ctx: Pick<Ctx, 'io'>): string;
6
+ export declare function expandDevicePath(path: string, home: string): string;
7
+ /** Through every symlink of the part that exists; a path that does not exist yet keeps its missing tail. */
8
+ export declare function canonicalDevicePath(target: string): string;
9
+ /** `/Users/me/code` as `~/code`: how a person reads their own folders. */
10
+ export declare function displayPath(path: string, home: string): string;
11
+ export interface DirListing {
12
+ path: string;
13
+ entries: {
14
+ path: string;
15
+ kind: 'file' | 'dir' | 'symlink' | 'other';
16
+ size?: number;
17
+ git_remote?: string;
18
+ }[];
19
+ truncated: boolean;
20
+ git_remote?: string;
21
+ }
22
+ /** Names, kinds, sizes and Git remotes under `root`, breadth first, bounded in depth, count and bytes. */
23
+ export declare function listDeviceDir(root: string, options?: {
24
+ depth?: number;
25
+ max_entries?: number;
26
+ include_hidden?: boolean;
27
+ }, signal?: AbortSignal): Promise<DirListing>;
28
+ export interface LinkedProject {
29
+ project_id: string;
30
+ repo_remote: string;
31
+ folder: string;
32
+ name: string;
33
+ conversation_id?: string;
34
+ }
35
+ type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: string) => Promise<{
36
+ id: string;
37
+ project_id: string;
38
+ }>;
39
+ /**
40
+ * A new chat in `folder`, started the way `shard agents new --path` starts one, in its own process: the folder's
41
+ * working tree (uncommitted files too, .gitignore respected) seeds it while its VM boots, and the brief is its first
42
+ * prompt. The brief goes in on standard input, never in a command line another user can read.
43
+ */
44
+ export declare const startFromFolder: Starter;
45
+ /**
46
+ * `folder` becomes one of your projects: the repository its Git origin names (else the folder itself), in your
47
+ * project on this API. It is selected for background preparation, so its files are in the cloud before the next chat,
48
+ * and kept as this device's live copy: what conversations started from it finish lands back in it. With `brief`, a
49
+ * conversation starts there at once. A folder without Git gets a repository, as a new chat from it does.
50
+ */
51
+ export declare function linkLocalProject(ctx: Ctx, path: string, options?: {
52
+ brief?: string;
53
+ harness?: 'claude' | 'codex';
54
+ projectId?: string;
55
+ }, start?: Starter): Promise<LinkedProject>;
56
+ /** `shard projects link <path>` (0.25.0+): what the desktop app's "Add a folder…" runs, the same as a device's project_link. */
57
+ export declare const PROJECT_LINK_HANDLERS: Record<string, Handler>;
58
+ export {};