@shardflux/cli 0.14.0 → 0.14.2

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.
@@ -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: Record<App, ObjectValue>): ObjectValue[];
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, two harness catalogs. The model selection determines the VM's native harness. */
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 { ACP_CATALOG } from "./acp-catalog.js";
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 { createConversation, getConversation, conversationWorkspace, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
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
- const source = catalog[app].configOptions.find(c => c.category === 'model');
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, two harness catalogs. The model selection determines the VM's native harness. */
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
- #catalog = { claude: structuredClone(ACP_CATALOG.claude.session), codex: structuredClone(ACP_CATALOG.codex.session) };
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(this.#catalog);
81
- return { ...native, models: { currentModelId: model, availableModels: options.map(o => ({ modelId: o.value, name: o.name, ...(o.description ? { description: o.description } : {}) })) },
82
- configOptions: [{ id: 'model', name: 'Model', category: 'model', type: 'select', currentValue: model, options }, ...(native.configOptions ?? []).filter(c => c.category !== 'model')] };
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 record = { id: `sf-${randomUUID()}`, apiUrl: this.#ctx.apiUrl, folder, model: acpModel('claude', 'default'), from_seq: 0, updatedAt: new Date().toISOString() };
116
- // T3 selects its model after session/new. Reserve identity now; allocate on the selection/first prompt.
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, { record, stop: new AbortController() });
119
- return this.#configuration(record.model, { sessionId: record.id });
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 (session.conversation && session.conversation.harness !== selected.app)
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
- this.#save(record);
161
+ record.app = selected.app;
137
162
  await this.#connect(session);
138
- const result = await session.transport.rpc.request('session/set_config_option', { sessionId: record.nativeId, configId: 'model', value: selected.id });
139
- return method === 'session/set_model' ? {} : this.#configuration(model, objectValue(result));
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
- const result = objectValue(await session.transport.rpc.request(method, mapped, method === 'session/prompt' ? 0 : 120_000));
153
- return this.#configuration(record.model, acpReferences(result, record, session.conversation.worktree_path));
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, { app } = parseAcpModel(record.model);
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
- const created = await createConversation(ctx, record.folder, app, 'acp');
182
- session.conversation = created.conversation;
183
- session.ws = created.workspace;
184
- record.conversationId = created.conversation.id;
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
- else {
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
- const c = session.conversation, cell = session.ws.cell();
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
- this.#catalog[app].configOptions = update.configOptions;
225
- mapped.update = { ...update, ...this.#configuration(record.model, { configOptions: 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) };
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
- if (Array.isArray(result.configOptions))
256
- this.#catalog[app].configOptions = result.configOptions;
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();