@shardflux/cli 0.24.0 → 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 (49) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +227 -1
  3. package/dist/account-args.js +5 -0
  4. package/dist/account-pool.js +5 -1
  5. package/dist/acp-permission-elsewhere.d.ts +38 -0
  6. package/dist/acp-permission-elsewhere.js +85 -0
  7. package/dist/acp-rpc.d.ts +2 -1
  8. package/dist/acp-rpc.js +11 -2
  9. package/dist/acp.js +95 -22
  10. package/dist/agent-credentials.d.ts +3 -0
  11. package/dist/agent-credentials.js +7 -0
  12. package/dist/args.d.ts +1 -1
  13. package/dist/args.js +143 -8
  14. package/dist/board-args.d.ts +6 -0
  15. package/dist/board-args.js +155 -0
  16. package/dist/board-commands.d.ts +62 -0
  17. package/dist/board-commands.js +395 -0
  18. package/dist/codex-cloud.d.ts +30 -3
  19. package/dist/codex-cloud.js +86 -12
  20. package/dist/conversation-commands.d.ts +30 -0
  21. package/dist/conversation-commands.js +312 -9
  22. package/dist/conversation-transcript.d.ts +44 -0
  23. package/dist/conversation-transcript.js +206 -0
  24. package/dist/conversations.d.ts +5 -2
  25. package/dist/conversations.js +20 -10
  26. package/dist/device-approvals.js +4 -3
  27. package/dist/device-bridge.d.ts +42 -1
  28. package/dist/device-bridge.js +223 -12
  29. package/dist/device-folders.d.ts +58 -0
  30. package/dist/device-folders.js +228 -0
  31. package/dist/device-helper.d.ts +6 -0
  32. package/dist/device-helper.js +87 -14
  33. package/dist/drive-commands.d.ts +25 -0
  34. package/dist/drive-commands.js +201 -0
  35. package/dist/handlers.js +6 -1
  36. package/dist/main.js +3 -0
  37. package/dist/move-files.d.ts +39 -0
  38. package/dist/move-files.js +167 -0
  39. package/dist/move.js +12 -11
  40. package/dist/onboarding.d.ts +6 -2
  41. package/dist/onboarding.js +24 -6
  42. package/dist/project-git.d.ts +7 -1
  43. package/dist/project-git.js +16 -3
  44. package/dist/project-preparation.d.ts +7 -0
  45. package/dist/project-preparation.js +14 -0
  46. package/dist/project-template-setup.js +32 -11
  47. package/dist/router-commands.d.ts +22 -0
  48. package/dist/router-commands.js +179 -0
  49. package/package.json +2 -2
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `shard conversations transcript` and `shard conversations events` (0.25.0+): a conversation's stored transcript (the
3
+ * harness's own JSONL, byte for byte) and its event stream, read through the API without the conversation's computer.
4
+ */
5
+ import type { ConversationEvent, ConversationTranscript, Shardflux } from '@shardflux/sdk';
6
+ import type { Handler } from './commands.js';
7
+ type ConversationsApi = Shardflux['conversations'];
8
+ /** A stretch of transcript: the bytes [offset, next_offset) of a session's file. */
9
+ export type TranscriptPiece = Pick<ConversationTranscript, 'session_id' | 'file' | 'offset' | 'next_offset' | 'size' | 'data'>;
10
+ /**
11
+ * Reads pages from `offset` of a session (the latest when `sessionId` is undefined) until its stored end, or until
12
+ * `until` (a line boundary). Every page goes to `emit`; returns the last one.
13
+ */
14
+ export declare function readTranscript(api: Pick<ConversationsApi, 'transcript'>, id: string, o: {
15
+ offset: number;
16
+ sessionId?: string | undefined;
17
+ until?: number;
18
+ signal: AbortSignal;
19
+ }, emit: (page: ConversationTranscript) => void): Promise<ConversationTranscript>;
20
+ /**
21
+ * Prints a followed transcript once: each event's lines after what was printed, the stored lines a reconnect or a new
22
+ * harness session would otherwise skip read from the API first.
23
+ */
24
+ export declare class TranscriptFollower {
25
+ #private;
26
+ session: string | null;
27
+ offset: number;
28
+ constructor(api: Pick<ConversationsApi, 'transcript'>, id: string, at: {
29
+ session: string | null;
30
+ offset: number;
31
+ }, signal: AbortSignal, write: (p: TranscriptPiece) => void);
32
+ take(t: TranscriptPiece): Promise<void>;
33
+ }
34
+ /**
35
+ * The readable form of `shard conversations events`: a line per state change, transcript lines and tool call, the
36
+ * reply text as it streams. `end()` closes a reply still on its line.
37
+ */
38
+ export declare class EventLines {
39
+ #private;
40
+ render(ev: ConversationEvent): string;
41
+ end(): string;
42
+ }
43
+ export declare const TRANSCRIPT_HANDLERS: Record<string, Handler>;
44
+ export {};
@@ -0,0 +1,206 @@
1
+ import { out, str } from "./commands.js";
2
+ import { conversationClient, questionLine } from "./conversation-commands.js";
3
+ import { EXIT, UsageError } from "./errors.js";
4
+ function byteOffset(text) {
5
+ if (text === undefined)
6
+ return 0;
7
+ if (!/^\d+$/.test(text) || !Number.isSafeInteger(Number(text)))
8
+ throw new UsageError('--offset must be a nonnegative safe integer');
9
+ return Number(text);
10
+ }
11
+ const piece = (p) => ({ session_id: p.session_id, file: p.file, offset: p.offset, next_offset: p.next_offset, size: p.size, data: p.data });
12
+ /**
13
+ * Reads pages from `offset` of a session (the latest when `sessionId` is undefined) until its stored end, or until
14
+ * `until` (a line boundary). Every page goes to `emit`; returns the last one.
15
+ */
16
+ export async function readTranscript(api, id, o, emit) {
17
+ const limit = (at) => (o.until === undefined ? {} : { limitBytes: o.until - at });
18
+ let page = await api.transcript(id, { offset: o.offset, ...(o.sessionId === undefined ? {} : { sessionId: o.sessionId }), ...limit(o.offset), signal: o.signal });
19
+ emit(page);
20
+ for (;;) {
21
+ const end = o.until === undefined ? page.size : Math.min(o.until, page.size);
22
+ if (page.next_offset >= end)
23
+ return page;
24
+ const at = page.next_offset;
25
+ page = await api.transcript(id, { offset: at, sessionId: page.session_id ?? o.sessionId, ...limit(at), signal: o.signal });
26
+ // A page that does not move on would repeat forever.
27
+ if (page.next_offset <= at)
28
+ return page;
29
+ emit(page);
30
+ }
31
+ }
32
+ /**
33
+ * Prints a followed transcript once: each event's lines after what was printed, the stored lines a reconnect or a new
34
+ * harness session would otherwise skip read from the API first.
35
+ */
36
+ export class TranscriptFollower {
37
+ session;
38
+ offset;
39
+ #api;
40
+ #id;
41
+ #signal;
42
+ #write;
43
+ constructor(api, id, at, signal, write) {
44
+ this.#api = api;
45
+ this.#id = id;
46
+ this.session = at.session;
47
+ this.offset = at.offset;
48
+ this.#signal = signal;
49
+ this.#write = write;
50
+ }
51
+ #print(p) {
52
+ if (p.session_id !== this.session || p.next_offset <= this.offset)
53
+ return;
54
+ const bytes = Buffer.from(p.data, 'utf8');
55
+ const skip = Math.max(0, this.offset - p.offset);
56
+ const data = skip === 0 ? p.data : bytes.subarray(skip).toString('utf8');
57
+ this.offset = p.next_offset;
58
+ if (data)
59
+ this.#write({ ...piece(p), offset: p.offset + skip, data });
60
+ }
61
+ async take(t) {
62
+ if (t.session_id === null)
63
+ return;
64
+ if (t.session_id !== this.session) {
65
+ // A new harness session (Claude Code /clear, a new Codex thread): the rest of the earlier one, then this one.
66
+ if (this.session !== null)
67
+ await readTranscript(this.#api, this.#id, { offset: this.offset, sessionId: this.session, signal: this.#signal }, (p) => this.#print(p));
68
+ this.session = t.session_id;
69
+ this.offset = 0;
70
+ }
71
+ if (t.offset > this.offset)
72
+ await readTranscript(this.#api, this.#id, { offset: this.offset, sessionId: t.session_id, until: t.offset, signal: this.#signal }, (p) => this.#print(p));
73
+ this.#print(t);
74
+ }
75
+ }
76
+ const lineCount = (text) => text.split('\n').length - 1;
77
+ const oneLine = (text) => text.replace(/\s+/g, ' ').trim();
78
+ /**
79
+ * The readable form of `shard conversations events`: a line per state change, transcript lines and tool call, the
80
+ * reply text as it streams. `end()` closes a reply still on its line.
81
+ */
82
+ export class EventLines {
83
+ #state;
84
+ #midLine = false;
85
+ #tools = new Map();
86
+ render(ev) {
87
+ if (ev.type === 'conversation') {
88
+ const c = ev.conversation;
89
+ if (c.state === this.#state)
90
+ return '';
91
+ this.#state = c.state;
92
+ const q = questionLine(c);
93
+ return this.#line(`state: ${c.state}${q ? ` — ${q}` : ''}`);
94
+ }
95
+ if (ev.type === 'transcript') {
96
+ const n = lineCount(ev.transcript.data);
97
+ return n ? this.#line(`transcript: +${n} line${n === 1 ? '' : 's'}`) : '';
98
+ }
99
+ return ev.delta.updates.map((u) => this.#update(u)).join('');
100
+ }
101
+ end() {
102
+ const close = this.#midLine ? '\n' : '';
103
+ this.#midLine = false;
104
+ return close;
105
+ }
106
+ #update(u) {
107
+ if (u.sessionUpdate === 'agent_message_chunk') {
108
+ const c = u.content;
109
+ if (c?.type !== 'text' || typeof c.text !== 'string' || !c.text)
110
+ return '';
111
+ this.#midLine = !c.text.endsWith('\n');
112
+ return c.text;
113
+ }
114
+ if (u.sessionUpdate !== 'tool_call' && u.sessionUpdate !== 'tool_call_update')
115
+ return '';
116
+ const id = typeof u.toolCallId === 'string' ? u.toolCallId : '';
117
+ const title = (typeof u.title === 'string' && oneLine(u.title)) || this.#tools.get(id) || 'tool';
118
+ if (id) {
119
+ this.#tools.delete(id);
120
+ this.#tools.set(id, title);
121
+ if (this.#tools.size > 1000)
122
+ this.#tools.delete(this.#tools.keys().next().value);
123
+ }
124
+ const status = typeof u.status === 'string' && u.status ? u.status : undefined;
125
+ // An update that only adds output to a call says nothing new on its line.
126
+ if (u.sessionUpdate === 'tool_call_update' && !status)
127
+ return '';
128
+ return this.#line(`tool: ${title}${status ? ` (${status})` : ''}`);
129
+ }
130
+ #line(text) {
131
+ const lead = this.#midLine ? '\n' : '';
132
+ this.#midLine = false;
133
+ return `${lead}${text}\n`;
134
+ }
135
+ }
136
+ const interrupted = (signal, err) => {
137
+ if (signal.aborted)
138
+ return EXIT.interrupted;
139
+ throw err;
140
+ };
141
+ export const TRANSCRIPT_HANDLERS = {
142
+ async 'conversations transcript'(ctx, values, [id]) {
143
+ const offset = byteOffset(str(values.offset));
144
+ const sessionId = str(values.session);
145
+ if (sessionId !== undefined && !sessionId.trim())
146
+ throw new UsageError('--session needs a session id');
147
+ const follow = values.follow === true;
148
+ const api = conversationClient(ctx).conversations;
149
+ // Stdout carries the transcript itself; with --json, one object (with --follow, one object per line).
150
+ const ndjson = ctx.json && follow;
151
+ const write = (p) => {
152
+ if (ndjson)
153
+ ctx.io.stdout.write(`${JSON.stringify(piece(p))}\n`);
154
+ else if (!ctx.json && p.data)
155
+ ctx.io.stdout.write(p.data);
156
+ };
157
+ const parts = [];
158
+ let last;
159
+ try {
160
+ last = await readTranscript(api, id, { offset, sessionId, signal: ctx.signal }, (p) => {
161
+ if (ctx.json && !follow)
162
+ parts.push(p.data);
163
+ else if (p.data)
164
+ write(p);
165
+ });
166
+ }
167
+ catch (err) {
168
+ return interrupted(ctx.signal, err);
169
+ }
170
+ if (!follow) {
171
+ if (ctx.json)
172
+ out(ctx, '', { ...last, offset, data: parts.join('') });
173
+ return EXIT.ok;
174
+ }
175
+ const follower = new TranscriptFollower(api, id, { session: last.session_id ?? sessionId ?? null, offset: last.next_offset }, ctx.signal, write);
176
+ try {
177
+ const events = api.events(id, { transcriptOffset: follower.offset, ...(sessionId === undefined ? {} : { sessionId }), signal: ctx.signal });
178
+ for await (const ev of events)
179
+ if (ev.type === 'transcript')
180
+ await follower.take(ev.transcript);
181
+ }
182
+ catch (err) {
183
+ return interrupted(ctx.signal, err);
184
+ }
185
+ return ctx.signal.aborted ? EXIT.interrupted : EXIT.ok;
186
+ },
187
+ async 'conversations events'(ctx, _values, [id]) {
188
+ const lines = new EventLines();
189
+ let code = EXIT.ok;
190
+ try {
191
+ for await (const ev of conversationClient(ctx).conversations.events(id, { signal: ctx.signal })) {
192
+ const text = ctx.json ? `${JSON.stringify(ev)}\n` : lines.render(ev);
193
+ if (text)
194
+ ctx.io.stdout.write(text);
195
+ }
196
+ }
197
+ catch (err) {
198
+ code = interrupted(ctx.signal, err);
199
+ }
200
+ finally {
201
+ if (!ctx.json)
202
+ ctx.io.stdout.write(lines.end());
203
+ }
204
+ return ctx.signal.aborted ? EXIT.interrupted : code;
205
+ },
206
+ };
@@ -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()}`);
@@ -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 {};