@shardflux/cli 0.13.6 → 0.14.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 +42 -0
- package/README.md +44 -4
- package/dist/acp-catalog.d.ts +7 -0
- package/dist/acp-catalog.js +40 -0
- package/dist/acp-journal.d.ts +18 -0
- package/dist/acp-journal.js +232 -0
- package/dist/acp-models.d.ts +36 -0
- package/dist/acp-models.js +161 -0
- package/dist/acp-rpc.d.ts +41 -0
- package/dist/acp-rpc.js +115 -0
- package/dist/acp.d.ts +29 -0
- package/dist/acp.js +464 -0
- package/dist/agent-commands.d.ts +10 -2
- package/dist/agent-commands.js +37 -18
- package/dist/agent-machine.d.ts +6 -0
- package/dist/agent-machine.js +93 -0
- package/dist/agent-repository.d.ts +2 -0
- package/dist/agent-repository.js +42 -31
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +33 -12
- package/dist/agent-target-commands.d.ts +4 -1
- package/dist/agent-target-commands.js +110 -40
- package/dist/agent-targets.d.ts +6 -4
- package/dist/agent-targets.js +72 -31
- package/dist/args.d.ts +1 -1
- package/dist/args.js +53 -14
- package/dist/command-center-actions.js +26 -3
- package/dist/command-center.js +20 -4
- package/dist/conversation-commands.d.ts +3 -0
- package/dist/conversation-commands.js +36 -0
- package/dist/conversation-local-resume.d.ts +8 -0
- package/dist/conversation-local-resume.js +56 -0
- package/dist/conversation-local.d.ts +7 -0
- package/dist/conversation-local.js +61 -0
- package/dist/conversation-router.d.ts +12 -0
- package/dist/conversation-router.js +92 -0
- package/dist/conversation-stash.d.ts +11 -0
- package/dist/conversation-stash.js +158 -0
- package/dist/conversation-terminal.d.ts +3 -0
- package/dist/conversation-terminal.js +74 -0
- package/dist/conversation-worktree.d.ts +4 -0
- package/dist/conversation-worktree.js +22 -0
- package/dist/conversations.d.ts +53 -0
- package/dist/conversations.js +119 -0
- package/dist/device-bridge.d.ts +80 -0
- package/dist/device-bridge.js +409 -0
- package/dist/device-helper.d.ts +3 -0
- package/dist/device-helper.js +104 -0
- package/dist/errors.js +5 -0
- package/dist/format.js +3 -2
- package/dist/git-remote-bin.js +3 -1
- package/dist/git-remote.d.ts +3 -1
- package/dist/git-remote.js +2 -1
- package/dist/handlers.js +3 -1
- package/dist/main.js +15 -3
- package/dist/project-sync.d.ts +5 -1
- package/dist/project-sync.js +7 -6
- package/dist/t3-provider.d.ts +6 -0
- package/dist/t3-provider.js +99 -0
- package/dist/t3-runtime.d.ts +6 -0
- package/dist/t3-runtime.js +63 -0
- package/dist/target-lock.d.ts +3 -0
- package/dist/target-lock.js +143 -0
- package/package.json +3 -3
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type RpcId = string | number;
|
|
2
|
+
export type ObjectValue = Record<string, unknown>;
|
|
3
|
+
export interface RpcMessage extends ObjectValue {
|
|
4
|
+
jsonrpc: '2.0';
|
|
5
|
+
id?: RpcId | null;
|
|
6
|
+
method?: string;
|
|
7
|
+
params?: ObjectValue;
|
|
8
|
+
result?: unknown;
|
|
9
|
+
error?: {
|
|
10
|
+
code: number;
|
|
11
|
+
message: string;
|
|
12
|
+
data?: unknown;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export declare class RpcError extends Error {
|
|
16
|
+
readonly code: number;
|
|
17
|
+
readonly data?: unknown;
|
|
18
|
+
constructor(code: number, message: string, data?: unknown);
|
|
19
|
+
}
|
|
20
|
+
export declare const objectValue: (value: unknown) => ObjectValue;
|
|
21
|
+
/** Each peer owns its request namespace; adapter IDs never escape to another conversation. */
|
|
22
|
+
export declare class AcpRpc {
|
|
23
|
+
#private;
|
|
24
|
+
readonly pending: Map<RpcId, {
|
|
25
|
+
resolve(value: unknown): void;
|
|
26
|
+
reject(error: unknown): void;
|
|
27
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
28
|
+
cleanup?(): void;
|
|
29
|
+
}>;
|
|
30
|
+
handler: (method: string, params: ObjectValue, request: boolean) => Promise<unknown>;
|
|
31
|
+
onIdle: () => void;
|
|
32
|
+
onResponse: (message: RpcMessage) => void;
|
|
33
|
+
constructor(write: (message: RpcMessage) => Promise<void>);
|
|
34
|
+
get busy(): boolean;
|
|
35
|
+
get closed(): boolean;
|
|
36
|
+
feed(bytes: Uint8Array): void;
|
|
37
|
+
request(method: string, params: ObjectValue, timeoutMs?: number, signal?: AbortSignal): Promise<unknown>;
|
|
38
|
+
notify(method: string, params: ObjectValue): Promise<void>;
|
|
39
|
+
close(error?: Error): void;
|
|
40
|
+
drain(): Promise<void>;
|
|
41
|
+
}
|
package/dist/acp-rpc.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
export class RpcError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
data;
|
|
6
|
+
constructor(code, message, data) { super(message); this.code = code; this.data = data; }
|
|
7
|
+
}
|
|
8
|
+
export const objectValue = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
9
|
+
/** Each peer owns its request namespace; adapter IDs never escape to another conversation. */
|
|
10
|
+
export class AcpRpc {
|
|
11
|
+
pending = new Map();
|
|
12
|
+
handler = () => Promise.reject(new RpcError(-32601, 'Method not found'));
|
|
13
|
+
onIdle = () => { };
|
|
14
|
+
onResponse = () => { };
|
|
15
|
+
#write;
|
|
16
|
+
#decoder = new StringDecoder('utf8');
|
|
17
|
+
#buffer = '';
|
|
18
|
+
#next = 0;
|
|
19
|
+
#namespace = randomUUID();
|
|
20
|
+
#closed;
|
|
21
|
+
#inbound = 0;
|
|
22
|
+
#receiving = new Set();
|
|
23
|
+
constructor(write) { this.#write = write; }
|
|
24
|
+
get busy() { return this.pending.size > 0 || this.#inbound > 0; }
|
|
25
|
+
get closed() { return this.#closed !== undefined; }
|
|
26
|
+
feed(bytes) {
|
|
27
|
+
this.#buffer += this.#decoder.write(Buffer.from(bytes));
|
|
28
|
+
let newline;
|
|
29
|
+
while ((newline = this.#buffer.indexOf('\n')) >= 0) {
|
|
30
|
+
const line = this.#buffer.slice(0, newline);
|
|
31
|
+
this.#buffer = this.#buffer.slice(newline + 1);
|
|
32
|
+
if (Buffer.byteLength(line) > 16 << 20)
|
|
33
|
+
throw new RpcError(-32700, 'Message exceeds 16 MiB');
|
|
34
|
+
if (!line.trim())
|
|
35
|
+
continue;
|
|
36
|
+
let value;
|
|
37
|
+
try {
|
|
38
|
+
value = JSON.parse(line);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new RpcError(-32700, 'Invalid JSON');
|
|
42
|
+
}
|
|
43
|
+
const message = objectValue(value);
|
|
44
|
+
if (message.jsonrpc !== '2.0')
|
|
45
|
+
throw new RpcError(-32600, 'Invalid JSON-RPC message');
|
|
46
|
+
const receiving = this.#receive(message).catch(error => this.close(error instanceof Error ? error : new Error(String(error))));
|
|
47
|
+
this.#receiving.add(receiving);
|
|
48
|
+
void receiving.finally(() => this.#receiving.delete(receiving));
|
|
49
|
+
}
|
|
50
|
+
if (Buffer.byteLength(this.#buffer) > 16 << 20)
|
|
51
|
+
throw new RpcError(-32700, 'Message exceeds 16 MiB');
|
|
52
|
+
}
|
|
53
|
+
async #receive(message) {
|
|
54
|
+
if (message.method !== undefined) {
|
|
55
|
+
const request = message.id !== undefined;
|
|
56
|
+
this.#inbound++;
|
|
57
|
+
try {
|
|
58
|
+
const result = await this.handler(message.method, message.params ?? {}, request);
|
|
59
|
+
if (request)
|
|
60
|
+
await this.#write({ jsonrpc: '2.0', id: message.id, result: result ?? {} });
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (request) {
|
|
64
|
+
const e = error instanceof RpcError ? error : new RpcError(-32603, error instanceof Error ? error.message : String(error));
|
|
65
|
+
await this.#write({ jsonrpc: '2.0', id: message.id, error: { code: e.code, message: e.message, ...(e.data === undefined ? {} : { data: e.data }) } });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
this.#inbound--;
|
|
70
|
+
this.onIdle();
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (message.id === undefined || message.id === null)
|
|
75
|
+
return;
|
|
76
|
+
this.onResponse(message);
|
|
77
|
+
const pending = this.pending.get(message.id);
|
|
78
|
+
if (!pending)
|
|
79
|
+
return;
|
|
80
|
+
this.pending.delete(message.id);
|
|
81
|
+
clearTimeout(pending.timer);
|
|
82
|
+
pending.cleanup?.();
|
|
83
|
+
if (message.error)
|
|
84
|
+
pending.reject(new RpcError(message.error.code, message.error.message, message.error.data));
|
|
85
|
+
else
|
|
86
|
+
pending.resolve(message.result);
|
|
87
|
+
this.onIdle();
|
|
88
|
+
}
|
|
89
|
+
request(method, params, timeoutMs = 120_000, signal) {
|
|
90
|
+
if (this.#closed)
|
|
91
|
+
return Promise.reject(this.#closed);
|
|
92
|
+
if (signal?.aborted)
|
|
93
|
+
return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('Request interrupted'));
|
|
94
|
+
const id = `shard-${this.#namespace}-${++this.#next}`;
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const cleanup = () => signal?.removeEventListener('abort', abort);
|
|
97
|
+
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 = () => { this.pending.delete(id); clearTimeout(timer); cleanup(); reject(signal?.reason instanceof Error ? signal.reason : new Error('Request interrupted')); this.onIdle(); };
|
|
99
|
+
this.pending.set(id, { resolve, reject, timer, cleanup });
|
|
100
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
101
|
+
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(); });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
notify(method, params) { return this.#write({ jsonrpc: '2.0', method, params }); }
|
|
105
|
+
close(error = new Error('Connection closed')) {
|
|
106
|
+
this.#closed = error;
|
|
107
|
+
for (const pending of this.pending.values()) {
|
|
108
|
+
clearTimeout(pending.timer);
|
|
109
|
+
pending.cleanup?.();
|
|
110
|
+
pending.reject(error);
|
|
111
|
+
}
|
|
112
|
+
this.pending.clear();
|
|
113
|
+
}
|
|
114
|
+
async drain() { await Promise.all([...this.#receiving]); }
|
|
115
|
+
}
|
package/dist/acp.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Ctx } from './commands.js';
|
|
2
|
+
import { AcpRpc, RpcError } from './acp-rpc.js';
|
|
3
|
+
import type { ObjectValue } from './acp-rpc.js';
|
|
4
|
+
import type { AcpCatalog } from './acp-catalog.js';
|
|
5
|
+
import type { AcpLifecycle } from './acp-models.js';
|
|
6
|
+
type App = 'claude' | 'codex';
|
|
7
|
+
type ErrorMapper = (error: unknown) => RpcError;
|
|
8
|
+
export declare function acpModel(app: App, id: string): string;
|
|
9
|
+
export declare function parseAcpModel(value: unknown): {
|
|
10
|
+
app: App;
|
|
11
|
+
id: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function acpModelOptions(catalog: AcpCatalog): ObjectValue[];
|
|
14
|
+
export declare function acpError(error: unknown): RpcError;
|
|
15
|
+
/** Rewrite structural session/file references, never the agent's prose or command output. */
|
|
16
|
+
export declare function acpReferences(value: unknown, record: {
|
|
17
|
+
id: string;
|
|
18
|
+
nativeId?: string;
|
|
19
|
+
folder: string;
|
|
20
|
+
}, root: string, inbound?: boolean): unknown;
|
|
21
|
+
/** One provider; model catalogs belong to their guest/account/session. */
|
|
22
|
+
export declare class ShardfluxAcp {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(ctx: Ctx, client: AcpRpc, errors?: ErrorMapper, lifecycle?: AcpLifecycle);
|
|
25
|
+
handle(method: string, params: ObjectValue, request: boolean): Promise<unknown>;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
export declare function runAcp(ctx: Ctx, errors?: ErrorMapper): Promise<number>;
|
|
29
|
+
export {};
|
package/dist/acp.js
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { CLI_VERSION } from "./args.js";
|
|
4
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
5
|
+
import { AcpRpc, RpcError, objectValue } from "./acp-rpc.js";
|
|
6
|
+
import { ACP_PROBE_CATALOG, nativeCurrentModel, nativeModelOptions, withoutNativeModel } from "./acp-catalog.js";
|
|
7
|
+
import { ACP_LIFECYCLE, AcpModelCache, acpScope } from "./acp-models.js";
|
|
8
|
+
import { AcpJournal } from "./acp-journal.js";
|
|
9
|
+
import { getConversation, conversationWorkspace, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
|
|
10
|
+
import { projectIdentity } from "./project-identity.js";
|
|
11
|
+
import { describeError, ownHint } from "./errors.js";
|
|
12
|
+
import { serveAcpDeviceApprovals } from "./device-bridge.js";
|
|
13
|
+
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
14
|
+
import { cloudClaudeLogin } from "./claude-cloud-login.js";
|
|
15
|
+
import { ensureMenubar, MenubarManager } from "./menubar.js";
|
|
16
|
+
const PREFIX = 'shardflux:';
|
|
17
|
+
export function acpModel(app, id) { return `${PREFIX}${app}:${id}`; }
|
|
18
|
+
export function parseAcpModel(value) {
|
|
19
|
+
if (typeof value !== 'string')
|
|
20
|
+
throw new RpcError(-32602, 'Choose a model from Shardflux.');
|
|
21
|
+
const match = /^shardflux:(claude|codex):(.+)$/.exec(value);
|
|
22
|
+
if (!match)
|
|
23
|
+
throw new RpcError(-32602, 'Choose a model from Shardflux.');
|
|
24
|
+
return { app: match[1], id: match[2] };
|
|
25
|
+
}
|
|
26
|
+
export function acpModelOptions(catalog) {
|
|
27
|
+
return ['claude', 'codex'].flatMap(app => {
|
|
28
|
+
return nativeModelOptions(catalog[app] ?? {}).map((option) => {
|
|
29
|
+
return { ...option, value: acpModel(app, option.value), name: `${app === 'claude' ? 'Claude Code' : 'Codex'} · ${option.name}` };
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function acpError(error) {
|
|
34
|
+
if (error instanceof RpcError) {
|
|
35
|
+
if (error.code === -32000 && /authentication required|oauth.*(?:revoked|expired)|not (?:signed|logged) in/i.test(error.message))
|
|
36
|
+
return new RpcError(-32000, `${error.message} Run shard setup.`, { code: 'unauthenticated', fix: 'Run shard setup.' });
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
const info = describeError(error);
|
|
40
|
+
const fix = info.code === 'unauthenticated' ? 'Run shard setup.' : info.code === 'not_found' || info.details?.reason === 'workspace_deleted' ? 'Open a new conversation.' : ownHint(error) ?? (info.code === 'quota_exceeded' || info.code === 'allowance_exhausted' ? 'Run shard billing plans.' : 'Try the message again.');
|
|
41
|
+
return new RpcError(-32000, `${info.message} ${fix}`, { code: info.code, fix, ...(info.details?.reason ? { reason: info.details.reason } : {}) });
|
|
42
|
+
}
|
|
43
|
+
/** Rewrite structural session/file references, never the agent's prose or command output. */
|
|
44
|
+
export function acpReferences(value, record, root, inbound = false) {
|
|
45
|
+
if (Array.isArray(value))
|
|
46
|
+
return value.map(v => acpReferences(v, record, root, inbound));
|
|
47
|
+
if (!value || typeof value !== 'object')
|
|
48
|
+
return value;
|
|
49
|
+
return Object.fromEntries(Object.entries(value).map(([key, leaf]) => {
|
|
50
|
+
if (key === 'sessionId' && leaf === (inbound ? record.id : record.nativeId))
|
|
51
|
+
return [key, inbound ? record.nativeId : record.id];
|
|
52
|
+
if (typeof leaf === 'string' && ['path', 'cwd', 'uri'].includes(key)) {
|
|
53
|
+
const from = inbound ? record.folder : root, to = inbound ? root : record.folder;
|
|
54
|
+
if (leaf === from || leaf.startsWith(from + '/'))
|
|
55
|
+
return [key, to + leaf.slice(from.length)];
|
|
56
|
+
if (leaf.startsWith(`file://${from}/`))
|
|
57
|
+
return [key, `file://${to}` + leaf.slice(`file://${from}`.length)];
|
|
58
|
+
}
|
|
59
|
+
return [key, acpReferences(leaf, record, root, inbound)];
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
/** One provider; model catalogs belong to their guest/account/session. */
|
|
63
|
+
export class ShardfluxAcp {
|
|
64
|
+
#ctx;
|
|
65
|
+
#client;
|
|
66
|
+
#store;
|
|
67
|
+
#errors;
|
|
68
|
+
#sessions = new Map();
|
|
69
|
+
#lifecycle;
|
|
70
|
+
#cache;
|
|
71
|
+
#initialized = false;
|
|
72
|
+
#probe = false;
|
|
73
|
+
constructor(ctx, client, errors = acpError, lifecycle = ACP_LIFECYCLE) { this.#ctx = ctx; this.#client = client; this.#store = new AgentStateStore(ctx.store.dir); this.#cache = new AcpModelCache(ctx.store.dir); this.#errors = errors; this.#lifecycle = lifecycle; }
|
|
74
|
+
#records() { return this.#store.read().acp_sessions ?? {}; }
|
|
75
|
+
#save(record) { this.#store.update(doc => { doc.acp_sessions = { ...objectValue(doc.acp_sessions), [record.id]: record }; }); }
|
|
76
|
+
#configuration(model, native = {}, catalog = {}) {
|
|
77
|
+
const options = acpModelOptions(catalog);
|
|
78
|
+
const current = options.some(o => o.value === model) ? model : undefined;
|
|
79
|
+
return { ...native, models: { ...(current ? { currentModelId: current } : {}), availableModels: options.map(o => ({ modelId: o.value, name: o.name, ...(o.description ? { description: o.description } : {}) })) },
|
|
80
|
+
configOptions: [{ id: 'model', name: 'Model', category: 'model', type: 'select', ...(current ? { currentValue: current } : {}), options }, ...(native.configOptions ?? []).filter(c => c.category !== 'model' && c.id !== 'model')] };
|
|
81
|
+
}
|
|
82
|
+
async handle(method, params, request) {
|
|
83
|
+
try {
|
|
84
|
+
return await this.#handle(method, params, request);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw this.#errors(error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async #handle(method, params, request) {
|
|
91
|
+
if (method === 'initialize') {
|
|
92
|
+
if (this.#initialized)
|
|
93
|
+
throw new RpcError(-32600, 'Already initialized');
|
|
94
|
+
if (params.protocolVersion !== 1 && params.protocolVersion !== 2)
|
|
95
|
+
throw new RpcError(-32602, 'Unsupported protocol version');
|
|
96
|
+
this.#initialized = true;
|
|
97
|
+
this.#probe = objectValue(params.clientInfo).name === 't3-code-provider-test';
|
|
98
|
+
return { protocolVersion: 1, agentInfo: { name: 'shardflux', title: 'Shardflux', version: CLI_VERSION }, authMethods: [],
|
|
99
|
+
agentCapabilities: { loadSession: true, promptCapabilities: { image: true, embeddedContext: true }, sessionCapabilities: { list: {}, resume: {}, close: {} } } };
|
|
100
|
+
}
|
|
101
|
+
if (!this.#initialized)
|
|
102
|
+
throw new RpcError(-32600, 'Initialize first');
|
|
103
|
+
if (method === 'session/list') {
|
|
104
|
+
const sessions = Object.values(this.#records()).filter(r => r.apiUrl === this.#ctx.apiUrl && (!params.cwd || r.folder === params.cwd));
|
|
105
|
+
return { sessions: sessions.map(r => ({ sessionId: r.id, cwd: r.folder, updatedAt: r.updatedAt })) };
|
|
106
|
+
}
|
|
107
|
+
if (method === 'session/new') {
|
|
108
|
+
if (this.#probe)
|
|
109
|
+
return this.#configuration(acpModel('claude', 'default'), { sessionId: 'shardflux-probe' }, ACP_PROBE_CATALOG);
|
|
110
|
+
if (typeof params.cwd !== 'string' || !isAbsolute(params.cwd))
|
|
111
|
+
throw new RpcError(-32602, 'Open a project folder first.');
|
|
112
|
+
const folder = projectIdentity(params.cwd).folder;
|
|
113
|
+
const cached = this.#cache.lookup(this.#ctx, folder), catalog = cached?.catalog ?? {};
|
|
114
|
+
const app = catalog.claude ? 'claude' : 'codex';
|
|
115
|
+
const record = { id: `sf-${randomUUID()}`, apiUrl: this.#ctx.apiUrl, folder, catalog, scope: cached?.scope,
|
|
116
|
+
model: catalog[app] ? acpModel(app, nativeCurrentModel(catalog[app])) : '', from_seq: 0, updatedAt: new Date().toISOString() };
|
|
117
|
+
// A real session owns one VM from birth. Cached metadata returns without
|
|
118
|
+
// blocking on its boot, seed or adapter discovery; the first use waits.
|
|
119
|
+
const session = { record, stop: new AbortController() };
|
|
120
|
+
this.#save(record);
|
|
121
|
+
this.#sessions.set(record.id, session);
|
|
122
|
+
this.#prepare(session);
|
|
123
|
+
if (!cached) {
|
|
124
|
+
await session.preparing;
|
|
125
|
+
if (session.preparationError)
|
|
126
|
+
throw session.preparationError;
|
|
127
|
+
if (session.discoveryError)
|
|
128
|
+
throw session.discoveryError;
|
|
129
|
+
}
|
|
130
|
+
session.announced = true;
|
|
131
|
+
return this.#configuration(record.model, { sessionId: record.id }, record.catalog);
|
|
132
|
+
}
|
|
133
|
+
if (this.#probe && method === 'session/close')
|
|
134
|
+
return {};
|
|
135
|
+
if (this.#probe)
|
|
136
|
+
throw new RpcError(-32601, 'The provider probe has no conversation.');
|
|
137
|
+
if (typeof params.sessionId !== 'string')
|
|
138
|
+
throw new RpcError(-32602, 'A conversation is required');
|
|
139
|
+
const session = this.#session(params.sessionId, params.cwd);
|
|
140
|
+
const { record } = session;
|
|
141
|
+
if (method === 'session/close') {
|
|
142
|
+
await this.#close(session);
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
if (method === 'session/set_model' || (method === 'session/set_config_option' && params.configId === 'model')) {
|
|
146
|
+
const model = String(method === 'session/set_model' ? params.modelId : params.value), selected = parseAcpModel(model);
|
|
147
|
+
if (!record.catalog && record.conversationId)
|
|
148
|
+
await this.#connect(session);
|
|
149
|
+
if (record.app && record.app !== selected.app)
|
|
150
|
+
throw new RpcError(-32602, 'Choose a new conversation to switch between Claude Code and Codex.');
|
|
151
|
+
if (!acpModelOptions(record.catalog ?? {}).some(o => o.value === model))
|
|
152
|
+
throw new RpcError(-32602, 'Choose a model available in this conversation.');
|
|
153
|
+
// The selection chooses the harness, but is not persisted as accepted
|
|
154
|
+
// until the actual guest session has confirmed it.
|
|
155
|
+
await session.prepared;
|
|
156
|
+
if (session.preparationError)
|
|
157
|
+
throw session.preparationError;
|
|
158
|
+
if (!acpModelOptions(record.catalog ?? {}).some(o => o.value === model))
|
|
159
|
+
throw new RpcError(-32602, 'This model is no longer available. Choose a model from this conversation.');
|
|
160
|
+
record.model = model;
|
|
161
|
+
record.app = selected.app;
|
|
162
|
+
await this.#connect(session);
|
|
163
|
+
if (!acpModelOptions(record.catalog ?? {}).some(o => o.value === model))
|
|
164
|
+
throw new RpcError(-32602, 'This model is no longer available. Choose a model from this conversation.');
|
|
165
|
+
let result;
|
|
166
|
+
try {
|
|
167
|
+
result = objectValue(await session.transport.rpc.request('session/set_config_option', { sessionId: record.nativeId, configId: 'model', value: selected.id }));
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
await this.#rejectModel(session, selected.id, e);
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
record.model = model;
|
|
174
|
+
this.#save(record);
|
|
175
|
+
await this.#modelUpdate(session);
|
|
176
|
+
return method === 'session/set_model' ? {} : this.#configuration(model, result, record.catalog);
|
|
177
|
+
}
|
|
178
|
+
if (!record.model)
|
|
179
|
+
throw new RpcError(-32602, 'Choose an available model before sending a message.');
|
|
180
|
+
await this.#connect(session);
|
|
181
|
+
if (method === 'session/load' || method === 'session/resume')
|
|
182
|
+
return this.#configuration(record.model, { ...record.setup, sessionId: record.id }, record.catalog);
|
|
183
|
+
const mapped = acpReferences(params, record, session.conversation.worktree_path, true);
|
|
184
|
+
if (method === 'session/prompt') {
|
|
185
|
+
record.prompted = true;
|
|
186
|
+
record.updatedAt = new Date().toISOString();
|
|
187
|
+
this.#save(record);
|
|
188
|
+
}
|
|
189
|
+
if (!request)
|
|
190
|
+
return session.transport.rpc.notify(method, mapped);
|
|
191
|
+
let result;
|
|
192
|
+
try {
|
|
193
|
+
result = objectValue(await session.transport.rpc.request(method, mapped, method === 'session/prompt' ? 0 : 120_000));
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
if (method === 'session/prompt')
|
|
197
|
+
await this.#rejectModel(session, parseAcpModel(record.model).id, e);
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
return this.#configuration(record.model, acpReferences(result, record, session.conversation.worktree_path), record.catalog);
|
|
201
|
+
}
|
|
202
|
+
#prepare(session) {
|
|
203
|
+
if (session.preparing)
|
|
204
|
+
return;
|
|
205
|
+
const { record } = session, ctx = { ...this.#ctx, signal: AbortSignal.any([this.#ctx.signal, session.stop.signal]) };
|
|
206
|
+
let prepared;
|
|
207
|
+
// The native selected adapter must not wait for the other harness's model
|
|
208
|
+
// probe. Both are guest processes; only boot/repository preparation gates it.
|
|
209
|
+
session.prepared = new Promise(resolve => { prepared = resolve; });
|
|
210
|
+
session.preparing = (async () => {
|
|
211
|
+
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
212
|
+
const catalog = await this.#lifecycle.prepare(ctx, record.folder, `acp-${record.id.slice(3).replaceAll('-', '')}`, {
|
|
213
|
+
allocated: (c, view) => {
|
|
214
|
+
session.conversation = c;
|
|
215
|
+
record.conversationId = c.id;
|
|
216
|
+
record.workspaceId = c.workspace_id;
|
|
217
|
+
const previous = record.scope;
|
|
218
|
+
record.scope = acpScope(ctx.apiUrl, view);
|
|
219
|
+
this.#cache.remember(ctx, record.folder, record.scope);
|
|
220
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(record.scope)) {
|
|
221
|
+
record.catalog = this.#cache.read(record.scope);
|
|
222
|
+
const app = record.catalog.claude ? 'claude' : 'codex';
|
|
223
|
+
record.model = record.catalog[app] ? acpModel(app, nativeCurrentModel(record.catalog[app])) : '';
|
|
224
|
+
if (session.announced)
|
|
225
|
+
void this.#modelUpdate(session).catch(() => { });
|
|
226
|
+
}
|
|
227
|
+
this.#save(record);
|
|
228
|
+
},
|
|
229
|
+
ready: ws => { session.ws = ws; record.scope = acpScope(ctx.apiUrl, ws.data); this.#cache.remember(ctx, record.folder, record.scope); },
|
|
230
|
+
prepared: () => { session.seedReady = true; prepared(); },
|
|
231
|
+
});
|
|
232
|
+
ctx.signal.throwIfAborted();
|
|
233
|
+
if (!record.scope)
|
|
234
|
+
throw new Error('Conversation preparation omitted its allocation identity.');
|
|
235
|
+
// Lifecycle implementations that finish atomically also satisfy readiness.
|
|
236
|
+
session.seedReady = true;
|
|
237
|
+
prepared();
|
|
238
|
+
this.#cache.write(record.scope, catalog);
|
|
239
|
+
// Once the selected native session is live, its config is authoritative.
|
|
240
|
+
// A background catalog session must never reset its selected/current model.
|
|
241
|
+
if (record.nativeId && session.transport)
|
|
242
|
+
this.#cacheSession(session);
|
|
243
|
+
else {
|
|
244
|
+
record.catalog = record.app ? { [record.app]: catalog[record.app] } : catalog;
|
|
245
|
+
const app = record.app ?? (catalog.claude ? 'claude' : 'codex');
|
|
246
|
+
record.model = acpModel(app, nativeCurrentModel(catalog[app]));
|
|
247
|
+
this.#save(record);
|
|
248
|
+
if (session.announced)
|
|
249
|
+
await this.#modelUpdate(session);
|
|
250
|
+
}
|
|
251
|
+
})().catch(async (error) => {
|
|
252
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
253
|
+
if (!session.seedReady)
|
|
254
|
+
session.preparationError = failure;
|
|
255
|
+
else
|
|
256
|
+
session.discoveryError = failure;
|
|
257
|
+
prepared();
|
|
258
|
+
if (session.stop.signal.aborted || ctx.signal.aborted)
|
|
259
|
+
return;
|
|
260
|
+
if (record.nativeId && session.transport)
|
|
261
|
+
return;
|
|
262
|
+
// Cached choices cannot survive a failed live refresh as if verified.
|
|
263
|
+
record.catalog = {};
|
|
264
|
+
record.model = '';
|
|
265
|
+
this.#save(record);
|
|
266
|
+
if (session.announced)
|
|
267
|
+
await this.#modelUpdate(session);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
#cacheSession(session) {
|
|
271
|
+
const { record } = session;
|
|
272
|
+
if (!record.scope || !record.app || !record.catalog?.[record.app])
|
|
273
|
+
return;
|
|
274
|
+
const catalog = { ...this.#cache.read(record.scope), [record.app]: record.catalog[record.app] };
|
|
275
|
+
this.#cache.write(record.scope, catalog);
|
|
276
|
+
}
|
|
277
|
+
async #modelUpdate(session) {
|
|
278
|
+
const { record } = session;
|
|
279
|
+
await this.#client.notify('session/update', { sessionId: record.id, update: { sessionUpdate: 'config_option_update', ...this.#configuration(record.model, record.setup, record.catalog) } });
|
|
280
|
+
}
|
|
281
|
+
async #rejectModel(session, id, error) {
|
|
282
|
+
if (!(error instanceof RpcError) || !/model.*(?:not supported|unsupported|not found|not available|invalid)|(?:unsupported|invalid).*model/i.test(error.message))
|
|
283
|
+
return;
|
|
284
|
+
const { record } = session, { app } = parseAcpModel(record.model);
|
|
285
|
+
const native = withoutNativeModel(record.catalog?.[app] ?? {}, id);
|
|
286
|
+
record.catalog = { [app]: native };
|
|
287
|
+
// Keep an already accepted selection if a different requested model failed.
|
|
288
|
+
// A rejected current model needs a new choice; never pretend it switched.
|
|
289
|
+
if (!nativeModelOptions(native).some(o => o.value === parseAcpModel(record.model).id))
|
|
290
|
+
record.model = '';
|
|
291
|
+
if (record.scope) {
|
|
292
|
+
const cached = this.#cache.read(record.scope);
|
|
293
|
+
if (record.model)
|
|
294
|
+
cached[app] = native;
|
|
295
|
+
else
|
|
296
|
+
delete cached[app];
|
|
297
|
+
this.#cache.write(record.scope, cached);
|
|
298
|
+
}
|
|
299
|
+
this.#save(record);
|
|
300
|
+
await this.#modelUpdate(session);
|
|
301
|
+
}
|
|
302
|
+
#session(id, cwd) {
|
|
303
|
+
const record = this.#records()[id];
|
|
304
|
+
if (!record || record.apiUrl !== this.#ctx.apiUrl || (cwd !== undefined && (typeof cwd !== 'string' || projectIdentity(cwd).folder !== record.folder)))
|
|
305
|
+
throw new RpcError(-32000, 'Conversation not found.');
|
|
306
|
+
let session = this.#sessions.get(id);
|
|
307
|
+
if (!session) {
|
|
308
|
+
session = { record, stop: new AbortController() };
|
|
309
|
+
this.#sessions.set(id, session);
|
|
310
|
+
}
|
|
311
|
+
return session;
|
|
312
|
+
}
|
|
313
|
+
async #connect(session) {
|
|
314
|
+
await session.prepared;
|
|
315
|
+
if (session.preparationError)
|
|
316
|
+
throw session.preparationError;
|
|
317
|
+
if (session.connecting)
|
|
318
|
+
return session.connecting;
|
|
319
|
+
if (session.transport && !session.transport.rpc.closed)
|
|
320
|
+
return;
|
|
321
|
+
session.connecting = this.#start(session).finally(() => { session.connecting = undefined; });
|
|
322
|
+
return session.connecting;
|
|
323
|
+
}
|
|
324
|
+
async #start(session) {
|
|
325
|
+
const ctx = this.#ctx, { record } = session, app = record.app ?? parseAcpModel(record.model).app;
|
|
326
|
+
record.app = app;
|
|
327
|
+
await session.transport?.close();
|
|
328
|
+
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
329
|
+
if (app === 'claude' && !(await cloudClaudeLogin(ctx)))
|
|
330
|
+
throw new RpcError(-32000, 'Sign in to Claude Code with shard agents credentials login claude.', { code: 'unauthenticated', fix: 'Run shard agents credentials login claude.' });
|
|
331
|
+
if (!record.conversationId) {
|
|
332
|
+
this.#prepare(session);
|
|
333
|
+
await session.prepared;
|
|
334
|
+
if (session.preparationError)
|
|
335
|
+
throw session.preparationError;
|
|
336
|
+
}
|
|
337
|
+
{
|
|
338
|
+
if (!record.conversationId)
|
|
339
|
+
throw new Error('Conversation preparation returned no binding.');
|
|
340
|
+
const current = await getConversation(ctx, record.conversationId);
|
|
341
|
+
session.conversation = await resumeConversation(ctx, current);
|
|
342
|
+
session.ws = await conversationWorkspace(ctx, session.conversation);
|
|
343
|
+
rememberConversation(ctx, session.conversation, record.folder);
|
|
344
|
+
}
|
|
345
|
+
let c = session.conversation;
|
|
346
|
+
const cell = session.ws.cell();
|
|
347
|
+
await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
|
|
348
|
+
await restoreConversationBinding(ctx, c, session.ws);
|
|
349
|
+
session.approvals ??= await serveAcpDeviceApprovals(ctx.store.dir, c.id, async (approval) => {
|
|
350
|
+
const result = objectValue(await this.#client.request('session/request_permission', { sessionId: record.id,
|
|
351
|
+
toolCall: { toolCallId: approval.request_id, title: approval.summary, kind: 'execute', status: 'pending' },
|
|
352
|
+
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])));
|
|
353
|
+
const outcome = objectValue(result.outcome);
|
|
354
|
+
return outcome.outcome === 'selected' && (outcome.optionId === 'once' || outcome.optionId === 'always') ? outcome.optionId : 'deny';
|
|
355
|
+
});
|
|
356
|
+
const launched = await cell.exec.run(['sf-agent', 'acp', app, '--conversation', c.id, '--detach'], { cwd: c.worktree_path, timeoutMs: 30_000, signal: ctx.signal, maxOutputBytes: 1 << 20 });
|
|
357
|
+
if (launched.exitCode !== 0)
|
|
358
|
+
throw new Error(`ACP supervisor could not attach: ${launched.stderr.trim()}`);
|
|
359
|
+
c = await getConversation(ctx, c.id);
|
|
360
|
+
if (c.harness !== app)
|
|
361
|
+
throw new Error('The guest did not claim this conversation harness.');
|
|
362
|
+
session.conversation = c;
|
|
363
|
+
rememberConversation(ctx, c, record.folder);
|
|
364
|
+
const status = JSON.parse(await cell.files.readText(`/home/user/.local/share/shardflux/acp/${c.id}/status.json`, { length: 8192 }));
|
|
365
|
+
if (status.conversation_id !== c.id || status.harness !== app || !Number.isSafeInteger(status.pid) || status.pid <= 0 || !status.start || status.ended)
|
|
366
|
+
throw new Error('ACP supervisor identity does not match this conversation.');
|
|
367
|
+
const owner = `${session.ws.id}:${status.pid}:${status.start}`;
|
|
368
|
+
const initialize = !record.nativeId || record.owner !== owner;
|
|
369
|
+
if (record.workspaceId !== session.ws.id)
|
|
370
|
+
record.from_seq = 0;
|
|
371
|
+
record.workspaceId = session.ws.id;
|
|
372
|
+
const transport = new AcpJournal(cell, c.id, app, c.worktree_path, record, () => this.#save(record), ctx.signal);
|
|
373
|
+
transport.rpc.handler = async (method, params, request) => {
|
|
374
|
+
if (method.startsWith('fs/') || method.startsWith('terminal/'))
|
|
375
|
+
throw new RpcError(-32601, 'Files and commands run in your cloud workspace.');
|
|
376
|
+
const mapped = acpReferences(params, record, c.worktree_path);
|
|
377
|
+
if (method === 'session/update') {
|
|
378
|
+
const update = objectValue(params.update);
|
|
379
|
+
if (update.sessionUpdate === 'config_option_update' && Array.isArray(update.configOptions)) {
|
|
380
|
+
const native = { ...record.catalog?.[app], configOptions: update.configOptions };
|
|
381
|
+
record.catalog = { [app]: native };
|
|
382
|
+
record.model = acpModel(app, nativeCurrentModel(native));
|
|
383
|
+
this.#save(record);
|
|
384
|
+
this.#cacheSession(session);
|
|
385
|
+
mapped.update = { ...update, ...this.#configuration(record.model, native, record.catalog) };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (request) {
|
|
389
|
+
if (method === 'session/request_permission')
|
|
390
|
+
transport.pause();
|
|
391
|
+
try {
|
|
392
|
+
return acpReferences(await this.#client.request(method, mapped, 0, AbortSignal.any([ctx.signal, session.stop.signal])), record, c.worktree_path, true);
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
if (method === 'session/request_permission')
|
|
396
|
+
transport.resume();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
await this.#client.notify(method, mapped);
|
|
400
|
+
};
|
|
401
|
+
session.transport = transport;
|
|
402
|
+
await transport.start();
|
|
403
|
+
if (initialize) {
|
|
404
|
+
const initialized = objectValue(await transport.rpc.request('initialize', { protocolVersion: 1, clientInfo: { name: 'shardflux', version: CLI_VERSION }, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } }));
|
|
405
|
+
if (initialized.protocolVersion !== 1)
|
|
406
|
+
throw new Error('Agent protocol version changed');
|
|
407
|
+
const nativeId = c.harness_session_id ?? record.nativeId;
|
|
408
|
+
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 } : {}) }));
|
|
409
|
+
const id = result.sessionId ?? nativeId;
|
|
410
|
+
if (typeof id !== 'string' || !id)
|
|
411
|
+
throw new Error('Agent returned no native session id');
|
|
412
|
+
record.nativeId = id;
|
|
413
|
+
record.setup = result;
|
|
414
|
+
record.owner = owner;
|
|
415
|
+
record.catalog = { [app]: result };
|
|
416
|
+
record.model = acpModel(app, nativeCurrentModel(result));
|
|
417
|
+
this.#save(record);
|
|
418
|
+
this.#cacheSession(session);
|
|
419
|
+
await this.#modelUpdate(session);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
// Even a reattachment refreshes the live session, rather than using model
|
|
423
|
+
// options persisted under an earlier guest/account identity.
|
|
424
|
+
const result = objectValue(await transport.rpc.request('session/load', { cwd: c.worktree_path, sessionId: record.nativeId, mcpServers: [] }));
|
|
425
|
+
record.setup = result;
|
|
426
|
+
record.catalog = { [app]: result };
|
|
427
|
+
record.model = acpModel(app, nativeCurrentModel(result));
|
|
428
|
+
this.#save(record);
|
|
429
|
+
this.#cacheSession(session);
|
|
430
|
+
await this.#modelUpdate(session);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async #close(session) {
|
|
434
|
+
this.#sessions.delete(session.record.id);
|
|
435
|
+
session.stop.abort();
|
|
436
|
+
await session.preparing;
|
|
437
|
+
await session.transport?.close();
|
|
438
|
+
session.ws?.cell().close();
|
|
439
|
+
await session.approvals?.stop();
|
|
440
|
+
session.approvals = undefined;
|
|
441
|
+
}
|
|
442
|
+
async close() { await Promise.all([...this.#sessions.values()].map(s => this.#close(s))); }
|
|
443
|
+
}
|
|
444
|
+
export async function runAcp(ctx, errors) {
|
|
445
|
+
const controller = new AbortController();
|
|
446
|
+
ctx = { ...ctx, signal: AbortSignal.any([ctx.signal, controller.signal]) };
|
|
447
|
+
const rpc = new AcpRpc(message => { ctx.io.stdout.write(JSON.stringify(message) + '\n'); return Promise.resolve(); });
|
|
448
|
+
const server = new ShardfluxAcp(ctx, rpc, errors);
|
|
449
|
+
rpc.handler = (method, params, request) => server.handle(method, params, request);
|
|
450
|
+
try {
|
|
451
|
+
for await (const chunk of ctx.io.stdin) {
|
|
452
|
+
ctx.signal.throwIfAborted();
|
|
453
|
+
rpc.feed(Buffer.from(chunk));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
controller.abort();
|
|
458
|
+
rpc.close();
|
|
459
|
+
await server.close();
|
|
460
|
+
await rpc.drain();
|
|
461
|
+
await server.close();
|
|
462
|
+
}
|
|
463
|
+
return 0;
|
|
464
|
+
}
|