@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.
- package/CHANGELOG.md +139 -0
- package/README.md +249 -4
- package/dist/account-args.js +8 -3
- package/dist/account-commands.js +15 -2
- package/dist/account-pool.js +5 -1
- package/dist/acp-journal.d.ts +2 -2
- package/dist/acp-journal.js +14 -4
- package/dist/acp-permission-elsewhere.d.ts +38 -0
- package/dist/acp-permission-elsewhere.js +85 -0
- package/dist/acp-rpc.d.ts +2 -1
- package/dist/acp-rpc.js +11 -2
- package/dist/acp.js +125 -30
- package/dist/agent-credentials.d.ts +3 -0
- package/dist/agent-credentials.js +7 -0
- package/dist/args.d.ts +1 -1
- package/dist/args.js +154 -8
- package/dist/board-args.d.ts +6 -0
- package/dist/board-args.js +155 -0
- package/dist/board-commands.d.ts +62 -0
- package/dist/board-commands.js +395 -0
- package/dist/codex-cloud.d.ts +30 -3
- package/dist/codex-cloud.js +86 -12
- package/dist/conversation-commands.d.ts +30 -0
- package/dist/conversation-commands.js +312 -9
- package/dist/conversation-transcript.d.ts +44 -0
- package/dist/conversation-transcript.js +206 -0
- package/dist/conversations.d.ts +5 -2
- package/dist/conversations.js +20 -10
- package/dist/default-volume-commands.d.ts +3 -0
- package/dist/default-volume-commands.js +35 -0
- package/dist/device-approvals.js +4 -3
- package/dist/device-bridge.d.ts +42 -1
- package/dist/device-bridge.js +241 -14
- package/dist/device-folders.d.ts +58 -0
- package/dist/device-folders.js +228 -0
- package/dist/device-helper.d.ts +6 -0
- package/dist/device-helper.js +87 -14
- package/dist/drive-commands.d.ts +25 -0
- package/dist/drive-commands.js +201 -0
- package/dist/filesystem-commands.js +2 -1
- package/dist/handlers.js +6 -1
- package/dist/local-sessions.d.ts +7 -1
- package/dist/local-sessions.js +38 -0
- package/dist/main.js +3 -0
- package/dist/move-files.d.ts +39 -0
- package/dist/move-files.js +167 -0
- package/dist/move-interrupt.d.ts +18 -0
- package/dist/move-interrupt.js +193 -0
- package/dist/move.js +28 -17
- package/dist/onboarding.d.ts +6 -2
- package/dist/onboarding.js +24 -6
- package/dist/project-git.d.ts +7 -1
- package/dist/project-git.js +16 -3
- package/dist/project-preparation.d.ts +7 -0
- package/dist/project-preparation.js +14 -0
- package/dist/project-template-setup.js +32 -11
- package/dist/router-commands.d.ts +22 -0
- package/dist/router-commands.js +179 -0
- package/dist/worktree-twin-engine.js +16 -6
- package/package.json +7 -5
package/dist/conversations.d.ts
CHANGED
|
@@ -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
|
|
122
|
-
*
|
|
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>;
|
package/dist/conversations.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
160
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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,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
|
+
}
|
package/dist/device-approvals.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
};
|
package/dist/device-bridge.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/device-bridge.js
CHANGED
|
@@ -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
|
|
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
|
|
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 () => {
|
|
327
|
-
|
|
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 {};
|