@shardflux/cli 0.14.0 → 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 +14 -0
- package/README.md +9 -5
- package/dist/acp-catalog.d.ts +7 -152
- package/dist/acp-catalog.js +39 -286
- package/dist/acp-models.d.ts +36 -0
- package/dist/acp-models.js +161 -0
- package/dist/acp.d.ts +5 -3
- package/dist/acp.js +216 -42
- package/dist/agent-repository.d.ts +2 -0
- package/dist/agent-repository.js +41 -30
- package/dist/agent-target-commands.d.ts +1 -1
- package/dist/agent-target-commands.js +51 -23
- package/dist/args.d.ts +1 -1
- package/dist/args.js +2 -2
- package/dist/conversation-router.js +3 -0
- package/dist/conversation-stash.js +1 -1
- package/dist/conversations.d.ts +2 -2
- package/dist/conversations.js +3 -2
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +27 -16
- package/dist/git-remote-bin.js +3 -1
- package/dist/git-remote.d.ts +3 -1
- package/dist/git-remote.js +2 -1
- package/package.json +2 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { projectIdentity } from "./project-identity.js";
|
|
3
|
+
import { savedMachine } from "./filesystem-state.js";
|
|
4
|
+
import { syncAgentConfig } from "./agent-config-sync.js";
|
|
5
|
+
import { seedAgentRepository } from "./agent-repository.js";
|
|
6
|
+
import { createConversationRecord, conversationWorkspace } from "./conversations.js";
|
|
7
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
8
|
+
import { objectValue } from "./acp-rpc.js";
|
|
9
|
+
import { nativeCurrentModel, nativeModelOptions } from "./acp-catalog.js";
|
|
10
|
+
/** No prompts, login, logout or credential writes. Every child process group is
|
|
11
|
+
* terminated, including initialization failures and interrupted clients. */
|
|
12
|
+
export const ACP_MODELS_SCRIPT = String.raw `
|
|
13
|
+
const {spawn}=require('node:child_process');
|
|
14
|
+
const {createInterface}=require('node:readline');
|
|
15
|
+
async function catalog(app){
|
|
16
|
+
const child=spawn(app==='codex'?'codex-acp':'claude-agent-acp',[],{
|
|
17
|
+
cwd:process.cwd(),env:{...process.env,SF_ACP_MODEL_DISCOVERY:'1'},detached:true,stdio:['pipe','pipe','pipe']});
|
|
18
|
+
const pending=new Map();let seq=0;
|
|
19
|
+
const kill=signal=>{try{process.kill(-child.pid,signal);}catch{}};
|
|
20
|
+
const interrupt=()=>kill('SIGKILL');
|
|
21
|
+
const terminate=()=>{kill('SIGKILL');process.exit(143);};
|
|
22
|
+
process.on('exit',interrupt);process.on('SIGTERM',terminate);process.on('SIGINT',terminate);
|
|
23
|
+
const closed=new Promise(resolve=>child.once('close',resolve));
|
|
24
|
+
const fail=error=>{for(const p of pending.values())p.reject(error);pending.clear();};
|
|
25
|
+
child.on('error',fail);child.on('exit',()=>fail(Error(app+' adapter exited')));
|
|
26
|
+
child.stdin.on('error',fail);
|
|
27
|
+
child.stderr.resume();
|
|
28
|
+
const lines=createInterface({input:child.stdout});
|
|
29
|
+
lines.on('line',line=>{
|
|
30
|
+
let m;try{m=JSON.parse(line);}catch{return;}
|
|
31
|
+
if(m.method&&m.id!==undefined){child.stdin.write(JSON.stringify({jsonrpc:'2.0',id:m.id,error:{code:-32601,message:'Catalog discovery has no client tools'}})+'\n');return;}
|
|
32
|
+
const p=pending.get(m.id);if(!p)return;pending.delete(m.id);
|
|
33
|
+
if(m.error)p.reject(Error(m.error.message));else p.resolve(m.result);
|
|
34
|
+
});
|
|
35
|
+
function rpc(method,params){return new Promise((resolve,reject)=>{
|
|
36
|
+
const id=++seq;pending.set(id,{resolve,reject});
|
|
37
|
+
child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');
|
|
38
|
+
});}
|
|
39
|
+
const timer=setTimeout(()=>{fail(Error(app+' model discovery timed out'));kill('SIGKILL');},45000);
|
|
40
|
+
try{
|
|
41
|
+
const init=await rpc('initialize',{protocolVersion:1,clientInfo:{name:'shardflux-models',version:'1'},clientCapabilities:{fs:{readTextFile:false,writeTextFile:false},terminal:false}});
|
|
42
|
+
const session=await rpc('session/new',{cwd:process.cwd(),mcpServers:[]});
|
|
43
|
+
return {agentInfo:init.agentInfo,configOptions:session.configOptions,models:session.models};
|
|
44
|
+
}finally{
|
|
45
|
+
clearTimeout(timer);lines.close();kill('SIGTERM');
|
|
46
|
+
const force=setTimeout(()=>kill('SIGKILL'),1000);
|
|
47
|
+
await closed;clearTimeout(force);kill('SIGKILL');
|
|
48
|
+
process.removeListener('exit',interrupt);process.removeListener('SIGTERM',terminate);process.removeListener('SIGINT',terminate);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
(async()=>{
|
|
52
|
+
const results=Object.fromEntries(await Promise.all(['claude','codex'].map(async app=>{
|
|
53
|
+
try{return [app,await catalog(app)];}catch(e){return [app,{error:String(e.message)}];}
|
|
54
|
+
})));
|
|
55
|
+
process.stdout.write(JSON.stringify(results)+'\n');
|
|
56
|
+
})().catch(e=>{process.stderr.write(e.message+'\n');process.exitCode=1;});
|
|
57
|
+
`;
|
|
58
|
+
const modelKey = (scope, app) => JSON.stringify([scope.apiUrl, scope.organizationId, app, scope.template]);
|
|
59
|
+
export function acpScope(apiUrl, ws) {
|
|
60
|
+
if (!ws.organization_id || !ws.template.slug || !Number.isInteger(ws.template.version))
|
|
61
|
+
throw new Error('Conversation allocation omitted its account or template identity.');
|
|
62
|
+
return { apiUrl, organizationId: ws.organization_id, template: `${ws.template.slug}@${ws.template.immutable_version ?? ws.template.version}` };
|
|
63
|
+
}
|
|
64
|
+
/** Model payloads are account/harness/template scoped. The credential fingerprint
|
|
65
|
+
* only locates the last verified binding; it is never stored or logged in clear. */
|
|
66
|
+
export class AcpModelCache {
|
|
67
|
+
#store;
|
|
68
|
+
constructor(dir) { this.#store = new AgentStateStore(dir); }
|
|
69
|
+
#bindingKey(ctx, folder) {
|
|
70
|
+
const p = ctx.store.profile(ctx.apiUrl);
|
|
71
|
+
const credential = ctx.io.env.SHARDFLUX_API_KEY?.trim() || p.api_key?.token || ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim() || p.session?.token;
|
|
72
|
+
const authority = createHash('sha256').update(credential ?? 'no-credential').digest('hex');
|
|
73
|
+
const project = projectIdentity(folder);
|
|
74
|
+
return JSON.stringify([ctx.apiUrl, authority, project.remote ?? `file://${project.folder}`]);
|
|
75
|
+
}
|
|
76
|
+
lookup(ctx, folder) {
|
|
77
|
+
const doc = this.#store.read(), binding = objectValue(doc.acp_model_bindings)[this.#bindingKey(ctx, folder)];
|
|
78
|
+
if (!binding?.scope || binding.scope.apiUrl !== ctx.apiUrl)
|
|
79
|
+
return;
|
|
80
|
+
const catalog = this.read(binding.scope);
|
|
81
|
+
return Object.keys(catalog).length ? { scope: binding.scope, catalog } : undefined;
|
|
82
|
+
}
|
|
83
|
+
remember(ctx, folder, scope) {
|
|
84
|
+
const key = this.#bindingKey(ctx, folder);
|
|
85
|
+
this.#store.update(doc => { doc.acp_model_bindings = { ...objectValue(doc.acp_model_bindings), [key]: { scope } }; });
|
|
86
|
+
}
|
|
87
|
+
read(scope) {
|
|
88
|
+
const entries = objectValue(this.#store.read().acp_model_cache), catalog = {};
|
|
89
|
+
for (const app of ['claude', 'codex']) {
|
|
90
|
+
const session = objectValue(entries[modelKey(scope, app)]);
|
|
91
|
+
if (nativeModelOptions(session).length) {
|
|
92
|
+
nativeCurrentModel(session);
|
|
93
|
+
catalog[app] = session;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return catalog;
|
|
97
|
+
}
|
|
98
|
+
write(scope, catalog) {
|
|
99
|
+
this.#store.update(doc => {
|
|
100
|
+
const entries = { ...objectValue(doc.acp_model_cache) };
|
|
101
|
+
for (const app of ['claude', 'codex']) {
|
|
102
|
+
const session = catalog[app];
|
|
103
|
+
if (!session || !nativeModelOptions(session).length) {
|
|
104
|
+
delete entries[modelKey(scope, app)];
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
nativeCurrentModel(session);
|
|
108
|
+
// Never persist initialize auth status, MCP credentials or a native
|
|
109
|
+
// session identity. Only the model configuration belongs in this cache.
|
|
110
|
+
entries[modelKey(scope, app)] = { configOptions: [{ id: 'model', category: 'model', type: 'select',
|
|
111
|
+
currentValue: nativeCurrentModel(session), options: nativeModelOptions(session) }] };
|
|
112
|
+
}
|
|
113
|
+
doc.acp_model_cache = entries;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export async function discoverAcpCatalog(ctx, ws, root) {
|
|
118
|
+
const result = await ws.cell().exec.run(['node', '-e', ACP_MODELS_SCRIPT], { cwd: root, timeoutMs: 55_000,
|
|
119
|
+
signal: ctx.signal, maxOutputBytes: 1 << 20, env: { SF_ACP_MODEL_DISCOVERY: '1' } });
|
|
120
|
+
if (result.exitCode !== 0)
|
|
121
|
+
throw new Error(`Guest model discovery failed: ${result.stderr.trim()}`);
|
|
122
|
+
const discovered = JSON.parse(result.stdout), catalog = {};
|
|
123
|
+
for (const app of ['claude', 'codex'])
|
|
124
|
+
if (discovered[app] && nativeModelOptions(discovered[app]).length) {
|
|
125
|
+
nativeCurrentModel(discovered[app]);
|
|
126
|
+
catalog[app] = discovered[app];
|
|
127
|
+
}
|
|
128
|
+
if (!Object.keys(catalog).length)
|
|
129
|
+
throw new Error('The guest adapters returned no available models. Check your cloud agent sign-ins.');
|
|
130
|
+
return catalog;
|
|
131
|
+
}
|
|
132
|
+
export const ACP_LIFECYCLE = { async prepare(ctx, folder, name, callbacks) {
|
|
133
|
+
let allocation;
|
|
134
|
+
const c = await createConversationRecord(ctx, folder, 'claude', 'acp', name, view => { allocation = view; }, `acp-session-${name}`);
|
|
135
|
+
callbacks.allocated(c, allocation);
|
|
136
|
+
const ws = await conversationWorkspace(ctx, c);
|
|
137
|
+
callbacks.ready(ws);
|
|
138
|
+
if (!(await savedMachine(ctx))?.template)
|
|
139
|
+
await syncAgentConfig(ctx, ws);
|
|
140
|
+
await ws.cell().files.mkdir(c.worktree_path, { parents: true });
|
|
141
|
+
let initialReady, initialFailed;
|
|
142
|
+
const initialFiles = new Promise((resolve, reject) => { initialReady = resolve; initialFailed = reject; });
|
|
143
|
+
const seeded = (async () => {
|
|
144
|
+
await seedAgentRepository(ctx, ws, folder, { root: c.worktree_path, populate: true,
|
|
145
|
+
initialFiles: ['.claude/settings.json', '.claude/settings.local.json', '.codex/config.toml', '.mcp.json'], onInitialFiles: initialReady });
|
|
146
|
+
const result = await ws.cell().exec.run(['git', '-C', c.worktree_path, 'checkout', '-B', c.branch, c.base_commit], { timeoutMs: 30_000, signal: ctx.signal });
|
|
147
|
+
if (result.exitCode !== 0)
|
|
148
|
+
throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
|
|
149
|
+
callbacks.prepared();
|
|
150
|
+
})();
|
|
151
|
+
void seeded.catch(initialFailed);
|
|
152
|
+
const models = (async () => { await initialFiles; return discoverAcpCatalog(ctx, ws, c.worktree_path); })();
|
|
153
|
+
// Observe both failures immediately. Never start a second VM, and never delete
|
|
154
|
+
// the user's conversation VM as catalog cleanup.
|
|
155
|
+
const results = await Promise.allSettled([seeded, models]);
|
|
156
|
+
if (results[0].status === 'rejected')
|
|
157
|
+
throw results[0].reason;
|
|
158
|
+
if (results[1].status === 'rejected')
|
|
159
|
+
throw results[1].reason;
|
|
160
|
+
return results[1].value;
|
|
161
|
+
} };
|
package/dist/acp.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Ctx } from './commands.js';
|
|
2
2
|
import { AcpRpc, RpcError } from './acp-rpc.js';
|
|
3
3
|
import type { ObjectValue } from './acp-rpc.js';
|
|
4
|
+
import type { AcpCatalog } from './acp-catalog.js';
|
|
5
|
+
import type { AcpLifecycle } from './acp-models.js';
|
|
4
6
|
type App = 'claude' | 'codex';
|
|
5
7
|
type ErrorMapper = (error: unknown) => RpcError;
|
|
6
8
|
export declare function acpModel(app: App, id: string): string;
|
|
@@ -8,7 +10,7 @@ export declare function parseAcpModel(value: unknown): {
|
|
|
8
10
|
app: App;
|
|
9
11
|
id: string;
|
|
10
12
|
};
|
|
11
|
-
export declare function acpModelOptions(catalog:
|
|
13
|
+
export declare function acpModelOptions(catalog: AcpCatalog): ObjectValue[];
|
|
12
14
|
export declare function acpError(error: unknown): RpcError;
|
|
13
15
|
/** Rewrite structural session/file references, never the agent's prose or command output. */
|
|
14
16
|
export declare function acpReferences(value: unknown, record: {
|
|
@@ -16,10 +18,10 @@ export declare function acpReferences(value: unknown, record: {
|
|
|
16
18
|
nativeId?: string;
|
|
17
19
|
folder: string;
|
|
18
20
|
}, root: string, inbound?: boolean): unknown;
|
|
19
|
-
/** One provider
|
|
21
|
+
/** One provider; model catalogs belong to their guest/account/session. */
|
|
20
22
|
export declare class ShardfluxAcp {
|
|
21
23
|
#private;
|
|
22
|
-
constructor(ctx: Ctx, client: AcpRpc, errors?: ErrorMapper);
|
|
24
|
+
constructor(ctx: Ctx, client: AcpRpc, errors?: ErrorMapper, lifecycle?: AcpLifecycle);
|
|
23
25
|
handle(method: string, params: ObjectValue, request: boolean): Promise<unknown>;
|
|
24
26
|
close(): Promise<void>;
|
|
25
27
|
}
|
package/dist/acp.js
CHANGED
|
@@ -3,16 +3,15 @@ import { isAbsolute } from 'node:path';
|
|
|
3
3
|
import { CLI_VERSION } from "./args.js";
|
|
4
4
|
import { AgentStateStore } from "./agent-state.js";
|
|
5
5
|
import { AcpRpc, RpcError, objectValue } from "./acp-rpc.js";
|
|
6
|
-
import {
|
|
6
|
+
import { ACP_PROBE_CATALOG, nativeCurrentModel, nativeModelOptions, withoutNativeModel } from "./acp-catalog.js";
|
|
7
|
+
import { ACP_LIFECYCLE, AcpModelCache, acpScope } from "./acp-models.js";
|
|
7
8
|
import { AcpJournal } from "./acp-journal.js";
|
|
8
|
-
import {
|
|
9
|
+
import { getConversation, conversationWorkspace, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
|
|
9
10
|
import { projectIdentity } from "./project-identity.js";
|
|
10
11
|
import { describeError, ownHint } from "./errors.js";
|
|
11
12
|
import { serveAcpDeviceApprovals } from "./device-bridge.js";
|
|
12
13
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
13
14
|
import { cloudClaudeLogin } from "./claude-cloud-login.js";
|
|
14
|
-
import { syncAgentConfig } from "./agent-config-sync.js";
|
|
15
|
-
import { savedMachine } from "./filesystem-state.js";
|
|
16
15
|
import { ensureMenubar, MenubarManager } from "./menubar.js";
|
|
17
16
|
const PREFIX = 'shardflux:';
|
|
18
17
|
export function acpModel(app, id) { return `${PREFIX}${app}:${id}`; }
|
|
@@ -26,10 +25,7 @@ export function parseAcpModel(value) {
|
|
|
26
25
|
}
|
|
27
26
|
export function acpModelOptions(catalog) {
|
|
28
27
|
return ['claude', 'codex'].flatMap(app => {
|
|
29
|
-
|
|
30
|
-
return (source?.options ?? []).flatMap(option => Array.isArray(option.options) ? option.options : [option]).map((option) => {
|
|
31
|
-
if (typeof option.value !== 'string' || !option.value || typeof option.name !== 'string' || !option.name)
|
|
32
|
-
throw new Error('Agent model metadata omitted its ID or name');
|
|
28
|
+
return nativeModelOptions(catalog[app] ?? {}).map((option) => {
|
|
33
29
|
return { ...option, value: acpModel(app, option.value), name: `${app === 'claude' ? 'Claude Code' : 'Codex'} · ${option.name}` };
|
|
34
30
|
});
|
|
35
31
|
});
|
|
@@ -63,23 +59,25 @@ export function acpReferences(value, record, root, inbound = false) {
|
|
|
63
59
|
return [key, acpReferences(leaf, record, root, inbound)];
|
|
64
60
|
}));
|
|
65
61
|
}
|
|
66
|
-
/** One provider
|
|
62
|
+
/** One provider; model catalogs belong to their guest/account/session. */
|
|
67
63
|
export class ShardfluxAcp {
|
|
68
64
|
#ctx;
|
|
69
65
|
#client;
|
|
70
66
|
#store;
|
|
71
67
|
#errors;
|
|
72
68
|
#sessions = new Map();
|
|
73
|
-
#
|
|
69
|
+
#lifecycle;
|
|
70
|
+
#cache;
|
|
74
71
|
#initialized = false;
|
|
75
72
|
#probe = false;
|
|
76
|
-
constructor(ctx, client, errors = acpError) { this.#ctx = ctx; this.#client = client; this.#store = new AgentStateStore(ctx.store.dir); this.#errors = errors; }
|
|
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; }
|
|
77
74
|
#records() { return this.#store.read().acp_sessions ?? {}; }
|
|
78
75
|
#save(record) { this.#store.update(doc => { doc.acp_sessions = { ...objectValue(doc.acp_sessions), [record.id]: record }; }); }
|
|
79
|
-
#configuration(model, native = {}) {
|
|
80
|
-
const options = acpModelOptions(
|
|
81
|
-
|
|
82
|
-
|
|
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')] };
|
|
83
81
|
}
|
|
84
82
|
async handle(method, params, request) {
|
|
85
83
|
try {
|
|
@@ -108,18 +106,34 @@ export class ShardfluxAcp {
|
|
|
108
106
|
}
|
|
109
107
|
if (method === 'session/new') {
|
|
110
108
|
if (this.#probe)
|
|
111
|
-
return this.#configuration(acpModel('claude', 'default'), { sessionId: 'shardflux-probe' });
|
|
109
|
+
return this.#configuration(acpModel('claude', 'default'), { sessionId: 'shardflux-probe' }, ACP_PROBE_CATALOG);
|
|
112
110
|
if (typeof params.cwd !== 'string' || !isAbsolute(params.cwd))
|
|
113
111
|
throw new RpcError(-32602, 'Open a project folder first.');
|
|
114
112
|
const folder = projectIdentity(params.cwd).folder;
|
|
115
|
-
const
|
|
116
|
-
|
|
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() };
|
|
117
120
|
this.#save(record);
|
|
118
|
-
this.#sessions.set(record.id,
|
|
119
|
-
|
|
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);
|
|
120
132
|
}
|
|
121
133
|
if (this.#probe && method === 'session/close')
|
|
122
134
|
return {};
|
|
135
|
+
if (this.#probe)
|
|
136
|
+
throw new RpcError(-32601, 'The provider probe has no conversation.');
|
|
123
137
|
if (typeof params.sessionId !== 'string')
|
|
124
138
|
throw new RpcError(-32602, 'A conversation is required');
|
|
125
139
|
const session = this.#session(params.sessionId, params.cwd);
|
|
@@ -130,17 +144,42 @@ export class ShardfluxAcp {
|
|
|
130
144
|
}
|
|
131
145
|
if (method === 'session/set_model' || (method === 'session/set_config_option' && params.configId === 'model')) {
|
|
132
146
|
const model = String(method === 'session/set_model' ? params.modelId : params.value), selected = parseAcpModel(model);
|
|
133
|
-
if (
|
|
147
|
+
if (!record.catalog && record.conversationId)
|
|
148
|
+
await this.#connect(session);
|
|
149
|
+
if (record.app && record.app !== selected.app)
|
|
134
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.');
|
|
135
160
|
record.model = model;
|
|
136
|
-
|
|
161
|
+
record.app = selected.app;
|
|
137
162
|
await this.#connect(session);
|
|
138
|
-
|
|
139
|
-
|
|
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);
|
|
140
177
|
}
|
|
178
|
+
if (!record.model)
|
|
179
|
+
throw new RpcError(-32602, 'Choose an available model before sending a message.');
|
|
141
180
|
await this.#connect(session);
|
|
142
181
|
if (method === 'session/load' || method === 'session/resume')
|
|
143
|
-
return this.#configuration(record.model, { ...record.setup, sessionId: record.id });
|
|
182
|
+
return this.#configuration(record.model, { ...record.setup, sessionId: record.id }, record.catalog);
|
|
144
183
|
const mapped = acpReferences(params, record, session.conversation.worktree_path, true);
|
|
145
184
|
if (method === 'session/prompt') {
|
|
146
185
|
record.prompted = true;
|
|
@@ -149,8 +188,116 @@ export class ShardfluxAcp {
|
|
|
149
188
|
}
|
|
150
189
|
if (!request)
|
|
151
190
|
return session.transport.rpc.notify(method, mapped);
|
|
152
|
-
|
|
153
|
-
|
|
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);
|
|
154
301
|
}
|
|
155
302
|
#session(id, cwd) {
|
|
156
303
|
const record = this.#records()[id];
|
|
@@ -164,6 +311,9 @@ export class ShardfluxAcp {
|
|
|
164
311
|
return session;
|
|
165
312
|
}
|
|
166
313
|
async #connect(session) {
|
|
314
|
+
await session.prepared;
|
|
315
|
+
if (session.preparationError)
|
|
316
|
+
throw session.preparationError;
|
|
167
317
|
if (session.connecting)
|
|
168
318
|
return session.connecting;
|
|
169
319
|
if (session.transport && !session.transport.rpc.closed)
|
|
@@ -172,27 +322,28 @@ export class ShardfluxAcp {
|
|
|
172
322
|
return session.connecting;
|
|
173
323
|
}
|
|
174
324
|
async #start(session) {
|
|
175
|
-
const ctx = this.#ctx, { record } = session,
|
|
325
|
+
const ctx = this.#ctx, { record } = session, app = record.app ?? parseAcpModel(record.model).app;
|
|
326
|
+
record.app = app;
|
|
176
327
|
await session.transport?.close();
|
|
177
328
|
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
178
329
|
if (app === 'claude' && !(await cloudClaudeLogin(ctx)))
|
|
179
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.' });
|
|
180
331
|
if (!record.conversationId) {
|
|
181
|
-
|
|
182
|
-
session.
|
|
183
|
-
session.
|
|
184
|
-
|
|
185
|
-
this.#save(record);
|
|
186
|
-
if (!(await savedMachine(ctx))?.template)
|
|
187
|
-
await syncAgentConfig(ctx, created.workspace);
|
|
332
|
+
this.#prepare(session);
|
|
333
|
+
await session.prepared;
|
|
334
|
+
if (session.preparationError)
|
|
335
|
+
throw session.preparationError;
|
|
188
336
|
}
|
|
189
|
-
|
|
337
|
+
{
|
|
338
|
+
if (!record.conversationId)
|
|
339
|
+
throw new Error('Conversation preparation returned no binding.');
|
|
190
340
|
const current = await getConversation(ctx, record.conversationId);
|
|
191
341
|
session.conversation = await resumeConversation(ctx, current);
|
|
192
342
|
session.ws = await conversationWorkspace(ctx, session.conversation);
|
|
193
343
|
rememberConversation(ctx, session.conversation, record.folder);
|
|
194
344
|
}
|
|
195
|
-
|
|
345
|
+
let c = session.conversation;
|
|
346
|
+
const cell = session.ws.cell();
|
|
196
347
|
await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
|
|
197
348
|
await restoreConversationBinding(ctx, c, session.ws);
|
|
198
349
|
session.approvals ??= await serveAcpDeviceApprovals(ctx.store.dir, c.id, async (approval) => {
|
|
@@ -205,6 +356,11 @@ export class ShardfluxAcp {
|
|
|
205
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 });
|
|
206
357
|
if (launched.exitCode !== 0)
|
|
207
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);
|
|
208
364
|
const status = JSON.parse(await cell.files.readText(`/home/user/.local/share/shardflux/acp/${c.id}/status.json`, { length: 8192 }));
|
|
209
365
|
if (status.conversation_id !== c.id || status.harness !== app || !Number.isSafeInteger(status.pid) || status.pid <= 0 || !status.start || status.ended)
|
|
210
366
|
throw new Error('ACP supervisor identity does not match this conversation.');
|
|
@@ -221,8 +377,12 @@ export class ShardfluxAcp {
|
|
|
221
377
|
if (method === 'session/update') {
|
|
222
378
|
const update = objectValue(params.update);
|
|
223
379
|
if (update.sessionUpdate === 'config_option_update' && Array.isArray(update.configOptions)) {
|
|
224
|
-
|
|
225
|
-
|
|
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) };
|
|
226
386
|
}
|
|
227
387
|
}
|
|
228
388
|
if (request) {
|
|
@@ -245,21 +405,35 @@ export class ShardfluxAcp {
|
|
|
245
405
|
if (initialized.protocolVersion !== 1)
|
|
246
406
|
throw new Error('Agent protocol version changed');
|
|
247
407
|
const nativeId = c.harness_session_id ?? record.nativeId;
|
|
248
|
-
const result = objectValue(await transport.rpc.request(nativeId ? 'session/load' : 'session/new', { cwd: c.worktree_path, mcpServers: [], ...(nativeId ? { sessionId: 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 } : {}) }));
|
|
249
409
|
const id = result.sessionId ?? nativeId;
|
|
250
410
|
if (typeof id !== 'string' || !id)
|
|
251
411
|
throw new Error('Agent returned no native session id');
|
|
252
412
|
record.nativeId = id;
|
|
253
413
|
record.setup = result;
|
|
254
414
|
record.owner = owner;
|
|
255
|
-
|
|
256
|
-
|
|
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));
|
|
257
428
|
this.#save(record);
|
|
429
|
+
this.#cacheSession(session);
|
|
430
|
+
await this.#modelUpdate(session);
|
|
258
431
|
}
|
|
259
432
|
}
|
|
260
433
|
async #close(session) {
|
|
261
434
|
this.#sessions.delete(session.record.id);
|
|
262
435
|
session.stop.abort();
|
|
436
|
+
await session.preparing;
|
|
263
437
|
await session.transport?.close();
|
|
264
438
|
session.ws?.cell().close();
|
|
265
439
|
await session.approvals?.stop();
|
|
@@ -12,6 +12,8 @@ export declare function needsGitSeed(ctx: Ctx, ws: Workspace): boolean;
|
|
|
12
12
|
export declare function seedAgentRepository(ctx: Ctx, ws: Workspace, folder: string, options?: {
|
|
13
13
|
root?: string;
|
|
14
14
|
populate?: boolean;
|
|
15
|
+
initialFiles?: readonly string[];
|
|
16
|
+
onInitialFiles?: () => void;
|
|
15
17
|
}): Promise<void>;
|
|
16
18
|
/** History above this goes through the project's origin instead of the bounded files path. */
|
|
17
19
|
export declare const HISTORY_BUNDLE_LIMIT: number;
|