@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/acp-journal.js
CHANGED
|
@@ -14,7 +14,7 @@ function retryableRead(error) {
|
|
|
14
14
|
// start, a stdin frame, an output stream and a cancel (0.23.0+).
|
|
15
15
|
const ONE_SHOT_INPUT = 192 << 10;
|
|
16
16
|
// InputACP's private, same-uid socket receipt protocol. Stdin avoids Linux's 128 KiB per-argument limit.
|
|
17
|
-
export const ACP_INPUT_SCRIPT = `import json, os, socket, sys
|
|
17
|
+
export const ACP_INPUT_SCRIPT = `import json, os, socket, struct, sys
|
|
18
18
|
payload = sys.stdin.buffer.readline((2 << 20) + 1024)
|
|
19
19
|
if not payload.endswith(b'\\n') or len(json.dumps(json.loads(payload)['message'], separators=(',', ':'), ensure_ascii=False).encode()) > 2 << 20:
|
|
20
20
|
raise ValueError('ACP input exceeds 2 MiB')
|
|
@@ -25,6 +25,12 @@ try:
|
|
|
25
25
|
address = os.path.join(sys.argv[1], 'control.sock')
|
|
26
26
|
if len(address.encode()) >= 108: address = '/proc/self/fd/%d/control.sock' % folder
|
|
27
27
|
peer.connect(address)
|
|
28
|
+
expected_pid, expected_start = sys.argv[2].split(':', 1)
|
|
29
|
+
pid, uid, _ = struct.unpack('3i', peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')))
|
|
30
|
+
with open('/proc/%d/stat' % pid) as process:
|
|
31
|
+
start = process.read().rsplit(')', 1)[1].split()[19]
|
|
32
|
+
if uid != os.getuid() or str(pid) != expected_pid or start != expected_start:
|
|
33
|
+
raise ValueError('ACP supervisor changed before input; load this session before continuing')
|
|
28
34
|
peer.sendall(payload)
|
|
29
35
|
response = peer.makefile('rb').readline(65536)
|
|
30
36
|
result = json.loads(response)
|
|
@@ -40,6 +46,7 @@ export class AcpJournal {
|
|
|
40
46
|
#conversation;
|
|
41
47
|
#cwd;
|
|
42
48
|
#cursor;
|
|
49
|
+
#owner;
|
|
43
50
|
#save;
|
|
44
51
|
#controller = new AbortController();
|
|
45
52
|
#signal;
|
|
@@ -50,14 +57,17 @@ export class AcpJournal {
|
|
|
50
57
|
#follower;
|
|
51
58
|
#writing = Promise.resolve();
|
|
52
59
|
#paused = 0;
|
|
53
|
-
constructor(cell, conversation, _harness, cwd, cursor, save, signal) {
|
|
60
|
+
constructor(cell, conversation, _harness, cwd, cursor, save, signal, owner) {
|
|
54
61
|
if (!/^[a-zA-Z0-9_-]{1,96}$/.test(conversation))
|
|
55
62
|
throw new Error('Invalid ACP conversation identity');
|
|
63
|
+
if (!/^[1-9][0-9]*:[0-9]+$/.test(owner))
|
|
64
|
+
throw new Error('Invalid ACP supervisor identity');
|
|
56
65
|
this.#cell = cell;
|
|
57
66
|
this.#conversation = conversation;
|
|
58
67
|
this.#cwd = cwd;
|
|
59
68
|
this.#cursor = cursor;
|
|
60
69
|
this.#save = save;
|
|
70
|
+
this.#owner = owner;
|
|
61
71
|
this.#signal = AbortSignal.any([signal, this.#controller.signal]);
|
|
62
72
|
this.rpc = new AcpRpc(message => {
|
|
63
73
|
const encoded = JSON.stringify(message);
|
|
@@ -83,7 +93,7 @@ export class AcpJournal {
|
|
|
83
93
|
const dir = `/home/user/.local/share/shardflux/acp/${this.#conversation}`;
|
|
84
94
|
const payload = Buffer.from(`{"mode":"input","input_id":${JSON.stringify(inputId)},"message":${message}}\n`);
|
|
85
95
|
if (payload.length <= ONE_SHOT_INPUT) {
|
|
86
|
-
const r = await this.#cell.exec.run(['python3', '-c', ACP_INPUT_SCRIPT, dir], { cwd: this.#cwd, stdin: payload, timeoutMs: 15_000, maxOutputBytes: 65536, signal: this.#signal });
|
|
96
|
+
const r = await this.#cell.exec.run(['python3', '-c', ACP_INPUT_SCRIPT, dir, this.#owner], { cwd: this.#cwd, stdin: payload, timeoutMs: 15_000, maxOutputBytes: 65536, signal: this.#signal });
|
|
87
97
|
let receipt;
|
|
88
98
|
try {
|
|
89
99
|
receipt = JSON.parse(r.stdout);
|
|
@@ -95,7 +105,7 @@ export class AcpJournal {
|
|
|
95
105
|
throw new Error('ACP delivery is unverified; load this session before continuing.');
|
|
96
106
|
return;
|
|
97
107
|
}
|
|
98
|
-
const session = await this.#cell.exec.start({ argv: ['python3', '-c', ACP_INPUT_SCRIPT, dir], cwd: this.#cwd, stdin_open: true, timeout_ms: 15_000 }, this.#signal);
|
|
108
|
+
const session = await this.#cell.exec.start({ argv: ['python3', '-c', ACP_INPUT_SCRIPT, dir, this.#owner], cwd: this.#cwd, stdin_open: true, timeout_ms: 15_000 }, this.#signal);
|
|
99
109
|
let offset = 0, stdout = '';
|
|
100
110
|
try {
|
|
101
111
|
while (offset < payload.length) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ConversationPermission } from '@shardflux/sdk';
|
|
2
|
+
import type { ObjectValue } from './acp-rpc.js';
|
|
3
|
+
/** The part of the SDK client the watch reads: GET /v1/conversations/{id}/permissions (0.28.0+). */
|
|
4
|
+
export interface PermissionsReader {
|
|
5
|
+
conversations: {
|
|
6
|
+
permissions(id: string, p: {
|
|
7
|
+
state?: ConversationPermission['state'];
|
|
8
|
+
limit?: number;
|
|
9
|
+
cursor?: string;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
data: ConversationPermission[];
|
|
12
|
+
nextCursor: string | null;
|
|
13
|
+
}>;
|
|
14
|
+
permission(id: string, permissionId: string): Promise<ConversationPermission>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** How often the watch reads the API while the editor's permission prompt is open (it never touches the VM). */
|
|
18
|
+
export declare const ELSEWHERE_POLL_MS = 1500;
|
|
19
|
+
/** The tool and command, path or URL the guest records for an ACP permission request (services/guest-agent ACPPermissionAsk). */
|
|
20
|
+
export declare function requestIdentity(params: ObjectValue): {
|
|
21
|
+
tool: string | null;
|
|
22
|
+
detail: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* While the editor shows the agent's permission prompt, the journal relay is paused, so nothing else of the turn reaches
|
|
26
|
+
* the editor. The same request is listed through the API, where a person can answer it too (the Shard client, the CLI,
|
|
27
|
+
* MCP). This resolves with the request once it leaves `pending` that way (answered through the API, or closed because
|
|
28
|
+
* the agent no longer waits), so the relay can withdraw the editor's prompt and go on with the turn. The request is the
|
|
29
|
+
* newest one of the conversation with the prompt's tool and command, path or URL that was reported as the prompt opened
|
|
30
|
+
* or still waits. It resolves null on an API without permission requests (404, 503) or when `signal` ends the prompt;
|
|
31
|
+
* other errors are retried.
|
|
32
|
+
*/
|
|
33
|
+
export declare function answeredElsewhere(client: PermissionsReader, conversationId: string, params: ObjectValue, openedAt: number, signal: AbortSignal, pollMs?: number): Promise<ConversationPermission | null>;
|
|
34
|
+
/**
|
|
35
|
+
* The editor-side answer matching a request answered elsewhere: the option of its decision's kind (as the guest picks
|
|
36
|
+
* it), or a cancelled outcome. The agent already has the first answer; this one only completes the relayed request.
|
|
37
|
+
*/
|
|
38
|
+
export declare function outcomeFor(p: ConversationPermission, params: ObjectValue): ObjectValue;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
3
|
+
import { objectValue } from "./acp-rpc.js";
|
|
4
|
+
/** How often the watch reads the API while the editor's permission prompt is open (it never touches the VM). */
|
|
5
|
+
export const ELSEWHERE_POLL_MS = 1500;
|
|
6
|
+
/**
|
|
7
|
+
* The guest reports a request as it journals it, just before the editor's prompt opens: one reported up to this long
|
|
8
|
+
* before (clock difference with the API included) is this prompt's; an older one counts only while it still waits (an
|
|
9
|
+
* editor reconnecting to a request that is still open).
|
|
10
|
+
*/
|
|
11
|
+
const REPORTED_BEFORE_MS = 10_000;
|
|
12
|
+
/** The tool and command, path or URL the guest records for an ACP permission request (services/guest-agent ACPPermissionAsk). */
|
|
13
|
+
export function requestIdentity(params) {
|
|
14
|
+
const call = objectValue(params.toolCall), raw = objectValue(call.rawInput), str = (v) => typeof v === 'string' ? v : '';
|
|
15
|
+
const location = Array.isArray(call.locations) ? str(objectValue(call.locations[0]).path) : '';
|
|
16
|
+
const tool = str(call.name) || str(objectValue(objectValue(call._meta).claudeCode).toolName);
|
|
17
|
+
return { tool: tool || null, detail: str(raw.command) || location || str(raw.file_path) || str(raw.url) };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* While the editor shows the agent's permission prompt, the journal relay is paused, so nothing else of the turn reaches
|
|
21
|
+
* the editor. The same request is listed through the API, where a person can answer it too (the Shard client, the CLI,
|
|
22
|
+
* MCP). This resolves with the request once it leaves `pending` that way (answered through the API, or closed because
|
|
23
|
+
* the agent no longer waits), so the relay can withdraw the editor's prompt and go on with the turn. The request is the
|
|
24
|
+
* newest one of the conversation with the prompt's tool and command, path or URL that was reported as the prompt opened
|
|
25
|
+
* or still waits. It resolves null on an API without permission requests (404, 503) or when `signal` ends the prompt;
|
|
26
|
+
* other errors are retried.
|
|
27
|
+
*/
|
|
28
|
+
export async function answeredElsewhere(client, conversationId, params, openedAt, signal, pollMs = ELSEWHERE_POLL_MS) {
|
|
29
|
+
const want = requestIdentity(params);
|
|
30
|
+
let request;
|
|
31
|
+
for (;;) {
|
|
32
|
+
try {
|
|
33
|
+
await delay(pollMs, undefined, { signal });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
if (!request) {
|
|
40
|
+
// Every state, every page: an answer through the API can land before the first read.
|
|
41
|
+
let mine;
|
|
42
|
+
for (let cursor, pages = 0; pages < 20; pages++) {
|
|
43
|
+
const page = await client.conversations.permissions(conversationId, { limit: 200, ...(cursor ? { cursor } : {}) });
|
|
44
|
+
for (const p of page.data) {
|
|
45
|
+
if (p.detail === want.detail && (p.tool ?? null) === want.tool && (p.state === 'pending' || Date.parse(p.created_at) >= openedAt - REPORTED_BEFORE_MS))
|
|
46
|
+
mine = p;
|
|
47
|
+
}
|
|
48
|
+
if (!page.nextCursor)
|
|
49
|
+
break;
|
|
50
|
+
cursor = page.nextCursor;
|
|
51
|
+
}
|
|
52
|
+
if (!mine)
|
|
53
|
+
continue;
|
|
54
|
+
if (mine.state !== 'pending')
|
|
55
|
+
return mine;
|
|
56
|
+
request = mine.id;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const p = await client.conversations.permission(conversationId, request);
|
|
60
|
+
if (p.state !== 'pending')
|
|
61
|
+
return p;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (signal.aborted)
|
|
65
|
+
return null;
|
|
66
|
+
if (error instanceof ShardfluxApiError && (error.status === 404 || error.status === 503))
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The editor-side answer matching a request answered elsewhere: the option of its decision's kind (as the guest picks
|
|
73
|
+
* it), or a cancelled outcome. The agent already has the first answer; this one only completes the relayed request.
|
|
74
|
+
*/
|
|
75
|
+
export function outcomeFor(p, params) {
|
|
76
|
+
const options = (Array.isArray(params.options) ? params.options : []).map(objectValue);
|
|
77
|
+
const wanted = p.decision === 'allow_once' ? ['allow_once', 'allow_always'] : p.decision === 'allow_always' ? ['allow_always', 'allow_once']
|
|
78
|
+
: p.decision === 'deny' ? ['reject_once', 'reject_always'] : [];
|
|
79
|
+
for (const kind of wanted) {
|
|
80
|
+
const option = options.find(o => o.kind === kind);
|
|
81
|
+
if (typeof option?.optionId === 'string')
|
|
82
|
+
return { outcome: { outcome: 'selected', optionId: option.optionId } };
|
|
83
|
+
}
|
|
84
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
85
|
+
}
|
package/dist/acp-rpc.d.ts
CHANGED
|
@@ -34,7 +34,8 @@ export declare class AcpRpc {
|
|
|
34
34
|
get busy(): boolean;
|
|
35
35
|
get closed(): boolean;
|
|
36
36
|
feed(bytes: Uint8Array): void;
|
|
37
|
-
request
|
|
37
|
+
/** `cancelOnAbort`: an abort also tells the peer the request is withdrawn (`$/cancel_request`), so an editor drops its prompt. */
|
|
38
|
+
request(method: string, params: ObjectValue, timeoutMs?: number, signal?: AbortSignal, cancelOnAbort?: boolean): Promise<unknown>;
|
|
38
39
|
notify(method: string, params: ObjectValue): Promise<void>;
|
|
39
40
|
close(error?: Error): void;
|
|
40
41
|
drain(): Promise<void>;
|
package/dist/acp-rpc.js
CHANGED
|
@@ -86,7 +86,8 @@ export class AcpRpc {
|
|
|
86
86
|
pending.resolve(message.result);
|
|
87
87
|
this.onIdle();
|
|
88
88
|
}
|
|
89
|
-
request(
|
|
89
|
+
/** `cancelOnAbort`: an abort also tells the peer the request is withdrawn (`$/cancel_request`), so an editor drops its prompt. */
|
|
90
|
+
request(method, params, timeoutMs = 120_000, signal, cancelOnAbort = false) {
|
|
90
91
|
if (this.#closed)
|
|
91
92
|
return Promise.reject(this.#closed);
|
|
92
93
|
if (signal?.aborted)
|
|
@@ -95,7 +96,15 @@ export class AcpRpc {
|
|
|
95
96
|
return new Promise((resolve, reject) => {
|
|
96
97
|
const cleanup = () => signal?.removeEventListener('abort', abort);
|
|
97
98
|
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); cleanup(); reject(new RpcError(-32000, 'Request timed out', { code: 'timeout', fix: 'Try the message again.' })); this.onIdle(); }, timeoutMs) : undefined;
|
|
98
|
-
const abort = () => {
|
|
99
|
+
const abort = () => {
|
|
100
|
+
this.pending.delete(id);
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
cleanup();
|
|
103
|
+
if (cancelOnAbort && !this.#closed)
|
|
104
|
+
void this.#write({ jsonrpc: '2.0', method: '$/cancel_request', params: { requestId: id } }).catch(() => undefined);
|
|
105
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error('Request interrupted'));
|
|
106
|
+
this.onIdle();
|
|
107
|
+
};
|
|
99
108
|
this.pending.set(id, { resolve, reject, timer, cleanup });
|
|
100
109
|
signal?.addEventListener('abort', abort, { once: true });
|
|
101
110
|
void this.#write({ jsonrpc: '2.0', id, method, params }).catch((error) => { this.pending.delete(id); clearTimeout(timer); cleanup(); reject(error instanceof Error ? error : new Error(String(error))); this.onIdle(); });
|
package/dist/acp.js
CHANGED
|
@@ -6,6 +6,8 @@ import { AcpRpc, RpcError, objectValue } from "./acp-rpc.js";
|
|
|
6
6
|
import { ACP_PROBE_CATALOG, nativeCurrentModel, nativeModelOptions, withoutNativeModel } from "./acp-catalog.js";
|
|
7
7
|
import { ACP_LIFECYCLE, AcpModelCache, acpScope } from "./acp-models.js";
|
|
8
8
|
import { AcpJournal } from "./acp-journal.js";
|
|
9
|
+
import { answeredElsewhere, outcomeFor } from "./acp-permission-elsewhere.js";
|
|
10
|
+
import { conversationClient } from "./conversation-commands.js";
|
|
9
11
|
import { getConversation, conversationWorkspace, preparedInFlight, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
|
|
10
12
|
import { seedEvent } from "./seed-phases.js";
|
|
11
13
|
import { projectIdentity } from "./project-identity.js";
|
|
@@ -45,6 +47,41 @@ export function acpModelOptions(catalog) {
|
|
|
45
47
|
});
|
|
46
48
|
}
|
|
47
49
|
const SIGN_IN = 'Sign in to Shardflux from your editor (or run shard acp --login).';
|
|
50
|
+
const CONVERSATION_ID = /^conv_[a-z2-7]{26}$/;
|
|
51
|
+
/** A remote session has no local folder to keep in step: its files stay in the conversation's computer. */
|
|
52
|
+
const NO_FILES = { root: '', conflicts: () => [], reconcile: () => Promise.resolve(), settled: () => true };
|
|
53
|
+
/** The last sequence number in a conversation's ACP journal: a session loaded by id follows from there (its history comes
|
|
54
|
+
* from the agent's own session/load replay, not from the journal). */
|
|
55
|
+
async function journalEnd(cell, conversationId) {
|
|
56
|
+
const path = `/home/user/.local/share/shardflux/acp/${conversationId}/journal.ndjson`;
|
|
57
|
+
let size;
|
|
58
|
+
try {
|
|
59
|
+
size = (await cell.files.stat(path)).size;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.status === 404)
|
|
63
|
+
return 0;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
for (let want = 64 << 10;; want *= 4) {
|
|
67
|
+
const start = Math.max(0, size - want), text = Buffer.from(await cell.files.read(path, { offset: start, length: size - start })).toString('utf8');
|
|
68
|
+
const lines = text.split('\n').filter(Boolean), last = start > 0 && lines.length < 2 ? undefined : lines.at(-1);
|
|
69
|
+
if (last !== undefined) {
|
|
70
|
+
const seq = JSON.parse(last).seq;
|
|
71
|
+
if (Number.isSafeInteger(seq))
|
|
72
|
+
return seq;
|
|
73
|
+
throw new Error('Invalid ACP journal entry');
|
|
74
|
+
}
|
|
75
|
+
if (start === 0)
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function supervisorOwner(workspaceId, conversationId, app, text) {
|
|
80
|
+
const status = JSON.parse(text);
|
|
81
|
+
if (status.conversation_id !== conversationId || status.harness !== app || !Number.isSafeInteger(status.pid) || status.pid <= 0 || !status.start)
|
|
82
|
+
throw new Error('ACP supervisor identity does not match this conversation.');
|
|
83
|
+
return status.ended ? null : `${workspaceId}:${status.pid}:${status.start}`;
|
|
84
|
+
}
|
|
48
85
|
export function acpError(error) {
|
|
49
86
|
if (error instanceof RpcError) {
|
|
50
87
|
if (error.code === -32000 && /authentication required|oauth.*(?:revoked|expired)|not (?:signed|logged) in/i.test(error.message))
|
|
@@ -119,7 +156,7 @@ export class ShardfluxAcp {
|
|
|
119
156
|
if (!this.#initialized)
|
|
120
157
|
throw new RpcError(-32600, 'Initialize first');
|
|
121
158
|
if (method === 'session/list') {
|
|
122
|
-
const sessions = Object.values(this.#records()).filter(r => r.apiUrl === this.#ctx.apiUrl && (!params.cwd || r.folder === params.cwd));
|
|
159
|
+
const sessions = Object.values(this.#records()).filter(r => r.apiUrl === this.#ctx.apiUrl && !r.remote && (!params.cwd || r.folder === params.cwd));
|
|
123
160
|
return { sessions: sessions.map(r => ({ sessionId: r.id, cwd: r.folder, updatedAt: r.updatedAt })) };
|
|
124
161
|
}
|
|
125
162
|
if (method === 'session/new') {
|
|
@@ -157,12 +194,14 @@ export class ShardfluxAcp {
|
|
|
157
194
|
throw new RpcError(-32601, 'The provider probe has no conversation.');
|
|
158
195
|
if (typeof params.sessionId !== 'string')
|
|
159
196
|
throw new RpcError(-32602, 'A conversation is required');
|
|
160
|
-
const session = this.#session(params.sessionId, params.cwd);
|
|
197
|
+
const session = this.#session(params.sessionId, params.cwd, method);
|
|
161
198
|
const { record } = session;
|
|
162
199
|
if (method === 'session/close') {
|
|
163
200
|
await this.#close(session);
|
|
164
201
|
return {};
|
|
165
202
|
}
|
|
203
|
+
if (record.remote && !record.model)
|
|
204
|
+
await this.#remoteHarness(record);
|
|
166
205
|
if (method === 'session/set_model' || (method === 'session/set_config_option' && params.configId === 'model')) {
|
|
167
206
|
const model = String(method === 'session/set_model' ? params.modelId : params.value), selected = parseAcpModel(model);
|
|
168
207
|
if (!record.catalog && record.conversationId)
|
|
@@ -266,11 +305,17 @@ export class ShardfluxAcp {
|
|
|
266
305
|
record.scope = acpScope(ctx.apiUrl, view);
|
|
267
306
|
this.#cache.remember(ctx, record.folder, record.scope);
|
|
268
307
|
if (previous && JSON.stringify(previous) !== JSON.stringify(record.scope)) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
308
|
+
const cached = this.#cache.read(record.scope);
|
|
309
|
+
// A newer template of the same account has no cached models yet: the models this session offered stay
|
|
310
|
+
// until its live discovery replaces them, so an editor's first choice is not refused (0.25.0+). Before, the
|
|
311
|
+
// list went empty for those seconds and set_model failed with "no longer available".
|
|
312
|
+
if (Object.keys(cached).length || previous.organizationId !== record.scope.organizationId) {
|
|
313
|
+
record.catalog = cached;
|
|
314
|
+
const app = record.catalog.claude ? 'claude' : 'codex';
|
|
315
|
+
record.model = record.catalog[app] ? acpModel(app, nativeCurrentModel(record.catalog[app])) : '';
|
|
316
|
+
if (session.announced)
|
|
317
|
+
void this.#modelUpdate(session).catch(() => { });
|
|
318
|
+
}
|
|
274
319
|
}
|
|
275
320
|
this.#save(record);
|
|
276
321
|
},
|
|
@@ -348,9 +393,15 @@ export class ShardfluxAcp {
|
|
|
348
393
|
this.#save(record);
|
|
349
394
|
await this.#modelUpdate(session);
|
|
350
395
|
}
|
|
351
|
-
#session(id, cwd) {
|
|
352
|
-
|
|
353
|
-
|
|
396
|
+
#session(id, cwd, method) {
|
|
397
|
+
let record = this.#records()[id];
|
|
398
|
+
// Any client can open a conversation it knows by id (session/load or session/resume with a conv_ id): it relays to
|
|
399
|
+
// the conversation's own ACP session in its computer, with no folder of this computer to sync.
|
|
400
|
+
if (!record && CONVERSATION_ID.test(id) && (method === 'session/load' || method === 'session/resume')) {
|
|
401
|
+
record = { id, apiUrl: this.#ctx.apiUrl, folder: typeof cwd === 'string' ? cwd : '', conversationId: id, model: '', from_seq: 0, prompted: true, remote: true, updatedAt: new Date().toISOString() };
|
|
402
|
+
this.#save(record);
|
|
403
|
+
}
|
|
404
|
+
if (!record || record.apiUrl !== this.#ctx.apiUrl || (cwd !== undefined && (typeof cwd !== 'string' || (record.remote ? cwd !== record.folder : projectIdentity(cwd).folder !== record.folder))))
|
|
354
405
|
throw new RpcError(-32000, 'Conversation not found.');
|
|
355
406
|
let session = this.#sessions.get(id);
|
|
356
407
|
if (!session) {
|
|
@@ -359,6 +410,17 @@ export class ShardfluxAcp {
|
|
|
359
410
|
}
|
|
360
411
|
return session;
|
|
361
412
|
}
|
|
413
|
+
/** A conversation loaded by id: its harness from the API. Only an ACP conversation has a session to relay to. */
|
|
414
|
+
async #remoteHarness(record) {
|
|
415
|
+
const c = await getConversation(this.#ctx, record.conversationId);
|
|
416
|
+
if (c.route !== 'acp')
|
|
417
|
+
throw new RpcError(-32602, `This conversation runs in ${c.route === 'terminal' ? 'a terminal' : c.route}; open it there.`);
|
|
418
|
+
if (!c.harness_session_id)
|
|
419
|
+
throw new RpcError(-32602, 'This conversation has not started its session yet. Try again in a moment.');
|
|
420
|
+
record.app = c.harness;
|
|
421
|
+
record.model = acpModel(c.harness, DEFAULT_MODEL);
|
|
422
|
+
this.#save(record);
|
|
423
|
+
}
|
|
362
424
|
/** True when this call started the connection (and so reconciled the project files just now). */
|
|
363
425
|
async #connect(session) {
|
|
364
426
|
await session.prepared;
|
|
@@ -368,11 +430,28 @@ export class ShardfluxAcp {
|
|
|
368
430
|
await session.connecting;
|
|
369
431
|
return false;
|
|
370
432
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
433
|
+
let started = false;
|
|
434
|
+
session.connecting = (async () => {
|
|
435
|
+
if (session.transport && !session.transport.rpc.closed && session.ws && session.conversation) {
|
|
436
|
+
// An idle journal has no read follower. Its open local RPC cannot
|
|
437
|
+
// prove the supervisor still owns the guest after a cold recovery.
|
|
438
|
+
// Check only on use, before sending any input to that owner.
|
|
439
|
+
let owner = null;
|
|
440
|
+
try {
|
|
441
|
+
owner = supervisorOwner(session.ws.id, session.conversation.id, session.record.app ?? parseAcpModel(session.record.model).app, await session.ws.cell().files.readText(`/home/user/.local/share/shardflux/acp/${session.conversation.id}/status.json`, { length: 8192 }));
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
if (error.status !== 404)
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
if (owner !== null && owner === session.record.owner)
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
started = true;
|
|
451
|
+
await this.#start(session);
|
|
452
|
+
})().finally(() => { session.connecting = undefined; });
|
|
374
453
|
await session.connecting;
|
|
375
|
-
return
|
|
454
|
+
return started;
|
|
376
455
|
}
|
|
377
456
|
async #start(session) {
|
|
378
457
|
const ctx = this.#ctx, { record } = session, app = record.app ?? parseAcpModel(record.model).app;
|
|
@@ -405,20 +484,21 @@ export class ShardfluxAcp {
|
|
|
405
484
|
const current = await getConversation(ctx, record.conversationId);
|
|
406
485
|
session.conversation = await resumeConversation(ctx, current);
|
|
407
486
|
session.ws = await conversationWorkspace(ctx, session.conversation);
|
|
408
|
-
|
|
487
|
+
if (!record.remote)
|
|
488
|
+
rememberConversation(ctx, session.conversation, record.folder);
|
|
409
489
|
mark('conversation');
|
|
410
490
|
}
|
|
411
491
|
await signIn;
|
|
412
492
|
mark('signed_in');
|
|
413
493
|
let c = session.conversation;
|
|
414
494
|
const cell = session.ws.cell();
|
|
415
|
-
const menubar = ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
|
|
495
|
+
const menubar = record.remote ? Promise.resolve() : ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
|
|
416
496
|
await restoreConversationBinding(ctx, c, session.ws);
|
|
417
497
|
mark('binding');
|
|
418
498
|
// The project files are reconciled, device approvals served and the supervisor launched side by side; the native
|
|
419
499
|
// session starts once the supervisor is up, and the connection is done (the prompt goes) once the files are
|
|
420
500
|
// reconciled too (0.21.1+).
|
|
421
|
-
const files = (async () => {
|
|
501
|
+
const files = record.remote ? Promise.resolve(NO_FILES) : (async () => {
|
|
422
502
|
const boundary = await projectSyncBoundary({ ...ctx, signal: AbortSignal.any([ctx.signal, session.stop.signal]) }, session.ws, projectIdentity(record.folder), {
|
|
423
503
|
root: c.worktree_path, agent: record.id.replace(/^sf-/, 'acp-'), seed: false,
|
|
424
504
|
stateKey: `acp:${ctx.apiUrl}:${record.id}:${record.folder}`, ...(session.hashes ? { cache: session.hashes } : {}),
|
|
@@ -441,7 +521,7 @@ export class ShardfluxAcp {
|
|
|
441
521
|
session.approvals ??= await serveAcpDeviceApprovals(ctx.store.dir, c.id, async (approval) => {
|
|
442
522
|
const result = objectValue(await this.#client.request('session/request_permission', { sessionId: record.id,
|
|
443
523
|
toolCall: { toolCallId: approval.request_id, title: approval.summary, kind: 'execute', status: 'pending' },
|
|
444
|
-
options: [{ optionId: 'once', name: 'Allow once', kind: 'allow_once' }, ...(!approval.outside ? [{ optionId: 'always', name: 'Always for this project', kind: 'allow_always' }] : []), { optionId: 'deny', name: 'Deny', kind: 'reject_once' }] }, 0, AbortSignal.any([ctx.signal, session.stop.signal])));
|
|
524
|
+
options: [{ optionId: 'once', name: 'Allow once', kind: 'allow_once' }, ...(approval.scope === 'folder' ? [{ optionId: 'always', name: 'Always for this folder', kind: 'allow_always' }] : !approval.outside ? [{ optionId: 'always', name: 'Always for this project', kind: 'allow_always' }] : []), { optionId: 'deny', name: 'Deny', kind: 'reject_once' }] }, 0, AbortSignal.any([ctx.signal, session.stop.signal])));
|
|
445
525
|
const outcome = objectValue(result.outcome);
|
|
446
526
|
return outcome.outcome === 'selected' && (outcome.optionId === 'once' || outcome.optionId === 'always') ? outcome.optionId : 'deny';
|
|
447
527
|
});
|
|
@@ -457,20 +537,21 @@ export class ShardfluxAcp {
|
|
|
457
537
|
if (c.harness !== app)
|
|
458
538
|
throw new Error('The guest did not claim this conversation harness.');
|
|
459
539
|
session.conversation = c;
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
540
|
+
if (!record.remote)
|
|
541
|
+
rememberConversation(ctx, c, record.folder);
|
|
542
|
+
const owner = supervisorOwner(session.ws.id, c.id, app, statusText);
|
|
543
|
+
if (owner === null)
|
|
463
544
|
throw new Error('ACP supervisor identity does not match this conversation.');
|
|
464
|
-
const owner = `${session.ws.id}:${status.pid}:${status.start}`;
|
|
465
545
|
const initialize = !record.nativeId || record.owner !== owner;
|
|
466
546
|
// A cold restore can replace the journal owner without changing the VM id.
|
|
467
547
|
// Its sequence starts at zero; an old cursor would drop initialize/load replies.
|
|
468
548
|
if (record.workspaceId !== session.ws.id || record.owner !== owner) {
|
|
469
|
-
|
|
549
|
+
// A conversation loaded by id follows its journal from now: the agent's session/load replays its history.
|
|
550
|
+
record.from_seq = record.remote ? await journalEnd(cell, c.id) : 0;
|
|
470
551
|
delete record.prompt_id;
|
|
471
552
|
}
|
|
472
553
|
record.workspaceId = session.ws.id;
|
|
473
|
-
const transport = new AcpJournal(cell, c.id, app, c.worktree_path, record, () => this.#save(record), ctx.signal);
|
|
554
|
+
const transport = new AcpJournal(cell, c.id, app, c.worktree_path, record, () => this.#save(record), ctx.signal, owner.slice(session.ws.id.length + 1));
|
|
474
555
|
transport.rpc.handler = async (method, params, request) => {
|
|
475
556
|
if (method.startsWith('fs/') || method.startsWith('terminal/'))
|
|
476
557
|
throw new RpcError(-32601, 'Files and commands run in your cloud workspace.');
|
|
@@ -486,17 +567,28 @@ export class ShardfluxAcp {
|
|
|
486
567
|
mapped.update = { ...update, ...this.#configuration(record.model, native, record.catalog) };
|
|
487
568
|
}
|
|
488
569
|
}
|
|
489
|
-
if (request) {
|
|
490
|
-
|
|
491
|
-
|
|
570
|
+
if (request && method === 'session/request_permission') {
|
|
571
|
+
// The journal relay pauses while the editor shows the prompt (a parked VM stays parked). The person can answer
|
|
572
|
+
// the same request through the API meanwhile (0.25.0+): then the editor's prompt is withdrawn and the turn goes on.
|
|
573
|
+
transport.pause();
|
|
574
|
+
const stop = new AbortController(), signal = AbortSignal.any([ctx.signal, session.stop.signal]);
|
|
492
575
|
try {
|
|
493
|
-
|
|
576
|
+
const asked = this.#client.request(method, mapped, 0, AbortSignal.any([signal, stop.signal]), true).then(result => ({ result }));
|
|
577
|
+
const elsewhere = answeredElsewhere(conversationClient(ctx), c.id, params, Date.now(), AbortSignal.any([signal, stop.signal]))
|
|
578
|
+
.then(p => p ? { p } : new Promise(() => undefined));
|
|
579
|
+
const first = await Promise.race([asked, elsewhere]);
|
|
580
|
+
if ('result' in first)
|
|
581
|
+
return acpReferences(first.result, record, c.worktree_path, true);
|
|
582
|
+
asked.catch(() => undefined);
|
|
583
|
+
return outcomeFor(first.p, mapped);
|
|
494
584
|
}
|
|
495
585
|
finally {
|
|
496
|
-
|
|
497
|
-
|
|
586
|
+
stop.abort();
|
|
587
|
+
transport.resume();
|
|
498
588
|
}
|
|
499
589
|
}
|
|
590
|
+
if (request)
|
|
591
|
+
return acpReferences(await this.#client.request(method, mapped, 0, AbortSignal.any([ctx.signal, session.stop.signal])), record, c.worktree_path, true);
|
|
500
592
|
await this.#client.notify(method, mapped);
|
|
501
593
|
};
|
|
502
594
|
session.transport = transport;
|
|
@@ -508,6 +600,9 @@ export class ShardfluxAcp {
|
|
|
508
600
|
throw new Error('Agent protocol version changed');
|
|
509
601
|
mark('initialized');
|
|
510
602
|
const nativeId = c.harness_session_id ?? record.nativeId;
|
|
603
|
+
// The history a load replays (session/update notifications) reaches the editor under its own session id.
|
|
604
|
+
if (nativeId)
|
|
605
|
+
record.nativeId = nativeId;
|
|
511
606
|
const result = objectValue(await transport.rpc.request(nativeId ? 'session/load' : 'session/new', { cwd: c.worktree_path, mcpServers: [{ name: 'shardflux', command: '/usr/local/bin/sf-agent', args: ['conversation', 'mcp'], env: [{ name: 'SF_CONVERSATION_ID', value: c.id }] }], ...(nativeId ? { sessionId: nativeId } : {}) }));
|
|
512
607
|
const id = result.sessionId ?? nativeId;
|
|
513
608
|
mark('session');
|
|
@@ -9,6 +9,9 @@ export declare function readAgentCredentials(opts?: CredentialSources): Promise<
|
|
|
9
9
|
app: AgentCredentialApp;
|
|
10
10
|
files: AgentCredentialFile[];
|
|
11
11
|
}>>;
|
|
12
|
+
/** The API's fingerprint of a login as this CLI copies it (the API hashes the same canonical file list), so a login on
|
|
13
|
+
* this computer can be told apart from the copy in the cloud without uploading it. */
|
|
14
|
+
export declare function credentialFingerprint(files: readonly AgentCredentialFile[]): string;
|
|
12
15
|
/** Reusable by the path launcher (L4). Returns metadata only. */
|
|
13
16
|
export declare function syncAgentCredentials(cloud: Shardflux, opts?: CredentialSources): Promise<import("@shardflux/sdk").AgentCredentialMetadata[]>;
|
|
14
17
|
export declare const AGENT_CREDENTIAL_HANDLERS: Record<string, Handler>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
@@ -55,6 +56,12 @@ export async function readAgentCredentials(opts = {}) {
|
|
|
55
56
|
out.push({ app: 'codex', files: [file('~/.codex/auth.json', object(codex, opts.redact), opts.redact)] });
|
|
56
57
|
return out;
|
|
57
58
|
}
|
|
59
|
+
/** The API's fingerprint of a login as this CLI copies it (the API hashes the same canonical file list), so a login on
|
|
60
|
+
* this computer can be told apart from the copy in the cloud without uploading it. */
|
|
61
|
+
export function credentialFingerprint(files) {
|
|
62
|
+
const canonical = [...files].sort((a, b) => a.path.localeCompare(b.path)).map(({ path, content_b64, mode }) => ({ path, content_b64, mode }));
|
|
63
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
|
64
|
+
}
|
|
58
65
|
/** Reusable by the path launcher (L4). Returns metadata only. */
|
|
59
66
|
export async function syncAgentCredentials(cloud, opts = {}) {
|
|
60
67
|
const sessions = await readAgentCredentials(opts);
|
package/dist/args.d.ts
CHANGED