@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.
- package/CHANGELOG.md +119 -0
- package/README.md +227 -1
- package/dist/account-args.js +5 -0
- package/dist/account-pool.js +5 -1
- 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 +95 -22
- 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 +143 -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/device-approvals.js +4 -3
- package/dist/device-bridge.d.ts +42 -1
- package/dist/device-bridge.js +223 -12
- 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/handlers.js +6 -1
- package/dist/main.js +3 -0
- package/dist/move-files.d.ts +39 -0
- package/dist/move-files.js +167 -0
- package/dist/move.js +12 -11
- 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/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
|
+
};
|
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()}`);
|
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 {};
|