@jc_stack/ez-agents 0.1.0-beta.48 → 0.1.0-beta.50

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.
Files changed (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +9 -5
  4. package/bin/ez +16 -7
  5. package/docker/broker-update-smoke.mjs +16 -2
  6. package/docker/upgrade-smoke.mjs +1 -1
  7. package/docs/architecture/ai-selection.md +9 -1
  8. package/docs/docker-runtime.md +46 -2
  9. package/docs/plugin-guide.md +3 -3
  10. package/docs/plugins.md +13 -10
  11. package/docs/scheduling.md +84 -6
  12. package/docs/setup.md +2 -2
  13. package/docs/standalone-cli.md +4 -4
  14. package/docs/upgrades.md +13 -23
  15. package/package.json +1 -1
  16. package/src/agent-guidance.ts +2 -2
  17. package/src/ai-cli.ts +28 -15
  18. package/src/ai.ts +90 -14
  19. package/src/application-channel.ts +18 -14
  20. package/src/auth-profile.ts +53 -0
  21. package/src/codex-session.ts +14 -3
  22. package/src/control-state.ts +28 -21
  23. package/src/delivery-socket.ts +2 -0
  24. package/src/executor.ts +38 -26
  25. package/src/host-executor-client.ts +19 -7
  26. package/src/host-executor.ts +35 -10
  27. package/src/index.ts +320 -122
  28. package/src/menu.ts +8 -7
  29. package/src/plugin-broker.mjs +4 -11
  30. package/src/plugins/manager.mjs +70 -48
  31. package/src/runs.ts +74 -5
  32. package/src/schedule-cli.ts +39 -7
  33. package/src/scheduled-tasks.ts +42 -8
  34. package/src/scheduler.ts +9 -4
  35. package/src/session-close.ts +17 -0
  36. package/src/setup-fallback.ts +159 -0
  37. package/src/task-executor.ts +3 -2
  38. package/src/tools-line.d.mts +1 -0
  39. package/src/tools-line.mjs +3 -0
  40. package/src/updates/binding.mjs +4 -10
  41. package/src/updates/control.mjs +3 -2
  42. package/src/updates/runtime.mjs +79 -41
  43. package/src/updates/supervisor.mjs +27 -6
  44. package/src/workspace.ts +12 -1
  45. package/test/ai.test.ts +48 -2
  46. package/test/application-channel.test.ts +24 -0
  47. package/test/application-controls.test.ts +4 -1
  48. package/test/auth-profiles.test.ts +187 -0
  49. package/test/intake-relay.test.ts +63 -2
  50. package/test/model-policy.test.ts +1 -1
  51. package/test/plugin-broker.test.mjs +51 -1
  52. package/test/plugin-manager.test.mjs +76 -38
  53. package/test/run-handoff.test.ts +132 -0
  54. package/test/scheduled-tasks.test.ts +24 -2
  55. package/test/session-close.test.ts +17 -0
  56. package/test/setup-fallback.test.ts +423 -0
  57. package/test/updates.test.mjs +77 -15
  58. package/test/workspace.test.ts +6 -0
package/src/ai-cli.ts CHANGED
@@ -1,38 +1,51 @@
1
1
  import { parseArgs } from 'node:util'
2
2
  import { randomBytes } from 'node:crypto'
3
3
  import { join } from 'node:path'
4
- import { readFile } from 'node:fs/promises'
5
- import { readModels, presetProvider, validateSelection, type AiPreset } from './ai.js'
4
+ import { mkdir, readFile } from 'node:fs/promises'
5
+ import { authProfileStatus, readModels, presetProvider, sameChoice, sameEngine, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
6
+ import { cliHome } from './auth-profile.js'
6
7
  import { ControlStore } from './control-state.js'
7
8
 
8
- const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},provider:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
9
+ const usage='Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--auth-profile <name>] [--provider <provider>] [--model <model>] [--effort <effort>] | default | profiles | profile add --cli codex|claude --auth-profile <name>'
10
+ const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},'auth-profile':{type:'string'},provider:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
9
11
  if(values.help){
10
- console.log('Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>] | default\nChoose only values returned by list. Selection affects subsequent messages; default makes the selected AI the choice for new conversations. Queued work is unchanged.')
12
+ console.log(`${usage}\nChoose only values returned by list. Selection affects subsequent messages; default makes the selected AI the choice for new conversations. Queued work is unchanged.\nAuth profiles are separate owner-provisioned Codex/Claude logins with their own configuration and sessions; changing profile starts a new conversation. profile add creates an empty home and prints the native login command for the owner to run; profiles reports read-only login status without secrets.`)
11
13
  process.exit(0)
12
14
  }
13
15
  if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
14
- const catalog=await readFile(join(process.env.EZ_CONTROL_DIR,'host-executor','models.json'),'utf8')
15
- .then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR!,'cli','codex'),undefined,undefined,undefined,process.env.EZ_CONTROL_DIR))
16
- if(positionals[0]==='list')console.log(JSON.stringify(catalog))
16
+ const controlDir=process.env.EZ_CONTROL_DIR
17
+ const authProfile=values['auth-profile']
18
+ const catalog=():Promise<ModelChoice[]>=>readFile(join(controlDir,'host-executor','models.json'),'utf8')
19
+ .then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(controlDir,'cli','codex'),undefined,undefined,undefined,controlDir))
20
+ if(positionals[0]==='list')console.log(JSON.stringify(await catalog()))
21
+ else if(positionals[0]==='profiles')console.log(JSON.stringify(await authProfileStatus(controlDir)))
22
+ else if(positionals[0]==='profile' && positionals[1]==='add'){
23
+ if(!authProfile)throw new Error('profile add requires --auth-profile <name>')
24
+ const home=cliHome(controlDir,values.cli||'',authProfile)
25
+ const created=await mkdir(home,{recursive:true,mode:0o700})
26
+ const login=values.cli==='codex' ? `CODEX_HOME=${home} codex login`
27
+ : `CLAUDE_CONFIG_DIR=${home} claude auth login (or store a \`claude setup-token\` token in ${home}/oauth-token, mode 600)`
28
+ console.log(JSON.stringify({cli:values.cli,authProfile,home,created:created!==undefined,ownerLogin:login}))
29
+ }
17
30
  else if(positionals[0]==='default'){
18
- const control=new ControlStore(process.env.EZ_CONTROL_DIR,900000)
31
+ const control=new ControlStore(controlDir,900000)
19
32
  const state=await control.status()
20
33
  const selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
21
34
  if(!selected)throw new Error('No selected AI')
22
- await validateSelection(selected,catalog)
35
+ await validateSelection(selected,await catalog())
23
36
  await control.defaultPreset(selected.id)
24
37
  console.log(JSON.stringify({default:selected,applies:'new conversations; current and queued work unchanged'}))
25
38
  }
26
39
  else if(positionals[0]==='select'){
27
- const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.provider,values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',provider:values.provider,model:values.model,effort:values.effort}
28
- await validateSelection(preset,catalog)
29
- const control=new ControlStore(process.env.EZ_CONTROL_DIR,900000)
40
+ const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.provider,values.model||values.cli,values.effort].filter(Boolean).join(' · ')+(authProfile ? ` @${authProfile}` : ''),cli:values.cli||'',provider:values.provider,...(authProfile ? {authProfile} : {}),model:values.model,effort:values.effort}
41
+ await validateSelection(preset,await catalog())
42
+ const control=new ControlStore(controlDir,900000)
30
43
  const state=await control.status()
31
44
  if(!state.ai)throw new Error('Agent AI settings are not initialized')
32
- const existing=state.ai.presets.find(p=>p.cli===preset.cli&&p.provider===preset.provider&&p.model===preset.model&&p.effort===preset.effort)
45
+ const existing=state.ai.presets.find(p=>sameChoice(p,preset))
33
46
  const selected=existing||preset
34
47
  if(!existing)await control.savePreset(selected)
35
48
  const current=state.ai.presets.find(p=>p.id===state.ai!.selectedId)!
36
- await control.selectPreset(selected.id,state.activeSession?.sessionId??null,current.cli!==selected.cli||presetProvider(current)!==presetProvider(selected)||Boolean(state.activeSession&&!state.activeSession.cli))
49
+ await control.selectPreset(selected.id,state.activeSession?.sessionId??null,!sameEngine(current,selected)||presetProvider(current)!==presetProvider(selected)||Boolean(state.activeSession&&!state.activeSession.cli))
37
50
  console.log(JSON.stringify({selected,defaultUnchanged:true,applies:'subsequent messages; queued work keeps its captured choice'}))
38
- }else throw new Error('Use list, select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>], or default')
51
+ }else throw new Error(usage)
package/src/ai.ts CHANGED
@@ -5,12 +5,19 @@ import { execFile } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
6
  import { homedir } from 'node:os'
7
7
  import { join, delimiter } from 'node:path'
8
- import { executorEnvironment, executorInvocation, executorKey, resolveExecutor } from './executor.js'
8
+ import { CliUnavailableError, executorEnvironment, executorInvocation, executorKey, resolveExecutor } from './executor.js'
9
9
  import { desktopCodexPath } from './desktop-bridge.js'
10
+ import { AUTH_PROFILE_CLIS, claudeAuthEnvironment, cliHome, isAuthProfile, listAuthProfiles } from './auth-profile.js'
10
11
 
11
- export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
12
- export type ExecutionChoice = { sessionId: string; preset: AiPreset }
13
- export type ModelChoice = { cli: string; provider?: string; model?: string; name: string; efforts: string[] }
12
+ export type AiPreset = { id: string; name: string; cli: string; provider?: string; authProfile?: string; model?: string; effort?: string }
13
+ // Optional ordered fallbacks are tried only for a scheduled occurrence that never started work.
14
+ export type ExecutionChoice = { sessionId: string; preset: AiPreset; fallbacks?: AiPreset[] }
15
+ export type ModelChoice = { cli: string; provider?: string; authProfile?: string; model?: string; name: string; efforts: string[] }
16
+ type Engine = { cli?: string; authProfile?: string }
17
+ // A native conversation belongs to one client and one credential home.
18
+ export const sameEngine = (a: Engine, b: Engine) => a.cli === b.cli && a.authProfile === b.authProfile
19
+ export const sameChoice = (a: Omit<AiPreset, 'id' | 'name'>, b: Omit<AiPreset, 'id' | 'name'>) =>
20
+ sameEngine(a, b) && a.provider === b.provider && a.model === b.model && a.effort === b.effort
14
21
  const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
15
22
  const knownClis = ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'pi']
16
23
  const nativeEffort = (cli: string, effort: string) =>
@@ -20,13 +27,15 @@ export const isPreset = (p: unknown): p is AiPreset => {
20
27
  const v = p as AiPreset
21
28
  return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
22
29
  knownClis.includes(v.cli) &&
23
- (v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
30
+ (v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort)) &&
31
+ (v.authProfile === undefined || (isAuthProfile(v.authProfile) && AUTH_PROFILE_CLIS.includes(v.cli)))
24
32
  }
25
33
  export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
26
34
  const c = v as ExecutionChoice | undefined
27
- return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
35
+ return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset) &&
36
+ (c.fallbacks === undefined || (Array.isArray(c.fallbacks) && c.fallbacks.length > 0 && c.fallbacks.every(isPreset))))
28
37
  }
29
- export const presetLabel = (p: AiPreset) => `${p.cli}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
38
+ export const presetLabel = (p: AiPreset) => `${p.cli}${p.authProfile ? `@${p.authProfile}` : ''}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
30
39
  // OpenCode encodes its provider in the native provider/model identifier.
31
40
  export const presetProvider = (p: AiPreset) => p.cli === 'opencode' ? p.model?.split('/')[0] : p.provider
32
41
  // The seed delegates model selection to the native client. Project its resolved
@@ -206,7 +215,11 @@ export const readModels = async (home = homedir(), available = installed, codexH
206
215
  // establish which models Pi can execute.
207
216
  if (!allow && await available('pi')) models.push({ cli: 'pi', name: 'Pi · client default', efforts: [] })
208
217
  const curated = await readCuratedModels(curationDir)
209
- if (!curated.length) return models
218
+ const profiles = await listAuthProfiles(curationDir)
219
+ // Each provisioned profile offers its client's entries under its own home.
220
+ const withProfiles = (listed: ModelChoice[]) => [...listed, ...profiles.flatMap(({ cli, authProfile }) =>
221
+ listed.filter((model) => model.cli === cli).map((model) => ({ ...model, authProfile, name: `${authProfile} · ${model.name}`.slice(0, 80) })))]
222
+ if (!curated.length) return withProfiles(models)
210
223
  const scoped: ModelChoice[] = []
211
224
  for (const entry of curated) {
212
225
  if (!(await available(entry.cli))) continue
@@ -216,7 +229,7 @@ export const readModels = async (home = homedir(), available = installed, codexH
216
229
  }
217
230
  const key = (m: { cli: string; provider?: string; model?: string }) => `${m.cli}‖${m.provider ?? ''}‖${m.model ?? ''}`
218
231
  const curatedKeys = new Set(scoped.map(key))
219
- return [...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))]
232
+ return withProfiles([...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))])
220
233
  }
221
234
  export const opencodeCatalogModels = async (
222
235
  opencodeRunner?: (args: string[]) => Promise<string>,
@@ -250,11 +263,74 @@ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env):
250
263
  return validateOpencodeProviders([...new Set(raw.split(',').map((p) => p.trim()).filter(Boolean))])
251
264
  }
252
265
 
266
+ // Shared by native menus and application controls; a rejected choice never mutates state.
267
+ export const catalogSelection = (p: {cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown}, catalog: ModelChoice[]): ModelChoice => {
268
+ const candidate = {...p, id: 'selection', name: 'Selection'}
269
+ if (!isPreset(candidate)) throw new Error('Invalid AI selection fields')
270
+ const model = catalog.find(m => sameEngine(m, candidate) && m.provider === candidate.provider && m.model === candidate.model)
271
+ if (!model) {
272
+ if (!catalog.some(m => sameEngine(m, candidate)))
273
+ throw new Error(`No models advertised for ${candidate.cli}${candidate.authProfile ? `@${candidate.authProfile}` : ''} in the installed client catalog; current selection unchanged.`)
274
+ throw new Error(`Model ${candidate.model ?? '(client default)'} is not in the installed client catalog for ${candidate.cli}; current selection unchanged.`)
275
+ }
276
+ if (candidate.effort !== undefined && !model.efforts.includes(candidate.effort))
277
+ throw new Error(`Effort ${candidate.effort} is not supported for ${candidate.cli} ${candidate.model ?? '(client default)'}. Supported efforts: ${model.efforts.join(', ') || 'none (omit effort)'}; current selection unchanged.`)
278
+ return model
279
+ }
280
+
253
281
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
254
282
  assertEffort(p.effort, p.model, p.cli)
255
- if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
256
- if (!p.provider && !p.model && !p.effort) return
257
- const model = catalog.find((m) => m.cli === p.cli && m.provider === p.provider && m.model === p.model)
258
- if (!model || (p.effort && !model.efforts.includes(p.effort)))
259
- throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
283
+ if (!isPreset(p)) throw new Error('This CLI is not installed.')
284
+ if (!(await available(p.cli))) throw new CliUnavailableError('This CLI is not installed.')
285
+ if (!p.provider && !p.authProfile && !p.model && !p.effort) return
286
+ if (p.authProfile && !p.provider && !p.model && !p.effort) {
287
+ if (!catalog.some((m) => sameEngine(m, p))) throw new Error('This auth profile is not provisioned for the selected CLI.')
288
+ return
289
+ }
290
+ catalogSelection(p, catalog)
291
+ }
292
+
293
+ export type AuthProfileStatus = { cli: string; authProfile: string | null; provisioned: boolean; sharesHostLogin: boolean
294
+ credentialFiles: string[]; loggedIn: boolean | null; method: string | null }
295
+ type StatusRunner = (command: string, args: string[], env: NodeJS.ProcessEnv, cwd: string) => Promise<{ code: number | null; stdout: string; stderr: string }>
296
+ const nativeStatus: StatusRunner = (command, args, env, cwd) => new Promise((resolve) => {
297
+ const invocation = executorInvocation(command, args)
298
+ execFile(invocation.command, invocation.args, { env, cwd, timeout: 8000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) =>
299
+ resolve({ code: error ? (typeof error.code === 'number' ? error.code : null) : 0, stdout: String(stdout), stderr: String(stderr) }))
300
+ })
301
+ const credentialNames: Record<string, string[]> = { codex: ['auth.json'], claude: ['oauth-token', '.credentials.json'] }
302
+
303
+ // Read-only auth diagnostics per credential home. Native status runs with the
304
+ // same home and credential environment as execution; only whitelisted fields
305
+ // are projected, never tokens, account identifiers or raw native output.
306
+ export const authProfileStatus = async (controlDir: string, available = installed, run = nativeStatus,
307
+ isolated = process.env.EZ_ISOLATION === 'isolated'): Promise<AuthProfileStatus[]> => {
308
+ const homes: { cli: string; authProfile?: string }[] = [...AUTH_PROFILE_CLIS.map((cli) => ({ cli })), ...await listAuthProfiles(controlDir)]
309
+ const statuses: AuthProfileStatus[] = []
310
+ for (const { cli, authProfile } of homes) {
311
+ if (!authProfile && !(await available(cli))) continue
312
+ const home = cliHome(controlDir, cli, authProfile)
313
+ const provisioned = await access(home).then(() => true, () => false)
314
+ const credentialFiles: string[] = []
315
+ for (const name of credentialNames[cli]) if (await access(join(home, name)).then(() => true, () => false)) credentialFiles.push(name)
316
+ let loggedIn: boolean | null = null, method: string | null = null
317
+ if (provisioned) {
318
+ try {
319
+ if (cli === 'codex') {
320
+ const result = await run('codex', ['login', 'status'], { ...executorEnvironment(), CODEX_HOME: home }, home)
321
+ loggedIn = result.code === null ? null : result.code === 0
322
+ // Codex prints its login status on stderr.
323
+ const text = result.stdout + result.stderr
324
+ method = loggedIn ? /chatgpt/i.test(text) ? 'chatgpt' : /api key/i.test(text) ? 'api-key' : 'other' : null
325
+ } else {
326
+ const result = await run('claude', ['auth', 'status', '--json'], { ...executorEnvironment(), ...await claudeAuthEnvironment(home, authProfile, isolated) }, home)
327
+ const status = result.code === null ? undefined : JSON.parse(result.stdout) as { loggedIn?: unknown; authMethod?: unknown }
328
+ loggedIn = status ? status.loggedIn === true : null
329
+ method = loggedIn && safe(status?.authMethod) ? status.authMethod : null
330
+ }
331
+ } catch { loggedIn = false }
332
+ }
333
+ statuses.push({ cli, authProfile: authProfile ?? null, provisioned, sharesHostLogin: !isolated && !authProfile, credentialFiles, loggedIn, method })
334
+ }
335
+ return statuses
260
336
  }
@@ -2,11 +2,11 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse }
2
2
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
3
3
  import { mkdir, readFile, writeFile, rename, open, unlink, stat, chown } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
- import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
5
+ import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, type SessionState, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
6
6
  import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
7
7
  import { ownsRun } from './identity.js'
8
8
  import { applicationId, validApplicationOrigin } from './application-origin.js'
9
- import { isPreset, type AiPreset, type ModelChoice } from './ai.js'
9
+ import { isPreset, sameChoice, catalogSelection, type AiPreset, type ModelChoice } from './ai.js'
10
10
  import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
11
11
  import { assertEffort } from './model-policy.js'
12
12
  import { ApprovalStore } from './approval.js'
@@ -97,6 +97,8 @@ export class ApplicationChannel {
97
97
  constructor(private options: {
98
98
  controlDir: string; workspace?: string; isolation?: string; initial: AiPreset
99
99
  wake: () => void
100
+ // Queues the replaced conversation's silent close turn (session-close.ts).
101
+ closeSession?: (session: SessionState | null) => Promise<void>
100
102
  cancel: (id: string) => Promise<void>
101
103
  speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
102
104
  createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
@@ -155,10 +157,10 @@ export class ApplicationChannel {
155
157
  const owner = await this.sharedBinding(bindingId)
156
158
  const controls = this.options.aiControls
157
159
  if (!controls) throw new Error('Application controls unavailable')
158
- const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
160
+ const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown}
159
161
  if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
160
162
  !(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
161
- const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
163
+ const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
162
164
  if (Object.keys(value).some(key => !['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
163
165
  const control = new ControlStore(this.options.controlDir, 900000)
164
166
  const expected = value.expectedSession as string | null
@@ -168,7 +170,11 @@ export class ApplicationChannel {
168
170
  const live = (await this.bindings.list()).find(item=>item.bindingId===bindingId)
169
171
  if (!live?.shareTelegram || !sameOwner(owner, live.owner)) throw new Error('Application authority revoked')
170
172
  }}
171
- if (value.action === 'new') await control.resetSession(expected, guard)
173
+ if (value.action === 'new') {
174
+ const previous = await control.getActiveSession()
175
+ await control.resetSession(expected, guard)
176
+ await this.options.closeSession?.(previous)
177
+ }
172
178
  if (value.action === 'switch') {
173
179
  if (typeof value.sessionId !== 'string') throw new Error('Invalid application conversation')
174
180
  // switchSession enforces the same visibility/engine restrictions as /chats.
@@ -181,8 +187,7 @@ export class ApplicationChannel {
181
187
  await controls.select(preset, expected, guard)
182
188
  }
183
189
  if (value.action === 'model') {
184
- const model = (await controls.catalog()).find(item => item.cli === value.cli && item.provider === value.provider && item.model === value.model)
185
- if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
190
+ const model = catalogSelection(value, await controls.catalog())
186
191
  await this.sharedBinding(bindingId)
187
192
  const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
188
193
  await this.sharedBinding(bindingId)
@@ -211,10 +216,10 @@ export class ApplicationChannel {
211
216
  const owner = await this.sharedBinding(bindingId, false)
212
217
  const controls = this.options.aiControls
213
218
  if (!controls) throw new Error('Application controls unavailable')
214
- const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; model?:unknown; effort?:unknown}
219
+ const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; authProfile?:unknown; model?:unknown; effort?:unknown}
215
220
  if (!value || !['new','select','model'].includes(String(value.action)) ||
216
221
  !(value.expectedSession===null || (typeof value.expectedSession==='string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
217
- const fields = {new:[],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'select'|'model']!
222
+ const fields = {new:[],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'select'|'model']!
218
223
  if (Object.keys(value).some(key=>!['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
219
224
  const hashedScope = applicationScope(bindingId,scope)
220
225
  const guard:ControlGuard = {owner,applicationScope:hashedScope,expectedSession:value.expectedSession as string|null,
@@ -231,8 +236,7 @@ export class ApplicationChannel {
231
236
  await controls.validate(preset)
232
237
  }
233
238
  if (value.action==='model') {
234
- const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.provider===value.provider && item.model===value.model)
235
- if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
239
+ const model = catalogSelection(value, await controls.catalog())
236
240
  preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
237
241
  }
238
242
  await control.changeApplicationSession(hashedScope,guard,preset)
@@ -257,7 +261,7 @@ export class ApplicationChannel {
257
261
  const run=await this.runs.create({id,ownerId:ownerId(access.task.owner),ownerEpoch:ownerEpoch(access.task.owner),taskId:access.task.id,texts:[v.text],execution:access.execution,application:{bindingId,scope,requestId:v.requestId,taskAccess:ticketHash}})
258
262
  this.options.wake();return run
259
263
  }
260
- const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown } }
264
+ const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown } }
261
265
  if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || (!value.text.trim() && value.attachment === undefined) || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','attachment','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
262
266
  if (value.expectedNativeSessionId !== undefined && (typeof value.expectedNativeSessionId !== 'string' || !/^[a-zA-Z0-9_-]{1,160}$/.test(value.expectedNativeSessionId))) throw new Error('Invalid application native session assertion')
263
267
  if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
@@ -266,7 +270,7 @@ export class ApplicationChannel {
266
270
  let requestedPreset: AiPreset | undefined
267
271
  if (value.ai !== undefined) {
268
272
  const candidate = { ...value.ai, id: 'application', name: 'Application selection' }
269
- if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
273
+ if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'authProfile', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
270
274
  assertEffort(candidate.effort, candidate.model, candidate.cli)
271
275
  requestedPreset = candidate
272
276
  }
@@ -289,7 +293,7 @@ export class ApplicationChannel {
289
293
  const existing = await this.runs.get(id)
290
294
  if (existing) {
291
295
  await this.bindings.authorize(existing)
292
- if (requestedPreset && (existing.execution?.preset.cli !== requestedPreset.cli || existing.execution?.preset.provider !== requestedPreset.provider || existing.execution?.preset.model !== requestedPreset.model || existing.execution?.preset.effort !== requestedPreset.effort)) throw new Error('Application request ID conflicts with prior AI selection')
296
+ if (requestedPreset && (!existing.execution || !sameChoice(existing.execution.preset, requestedPreset))) throw new Error('Application request ID conflicts with prior AI selection')
293
297
  if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || (existing.application?.inputText ?? existing.texts[0]) !== value.text || existing.application?.attachmentHash !== attachmentHash) throw new Error('Application request ID conflicts with prior scope or text')
294
298
  return existing // Retried context never replaces already admitted capabilities.
295
299
  }
@@ -0,0 +1,53 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ // Owner-provisioned named credential homes beside the agent default binding.
5
+ // Unset keeps control/cli/<cli>; a name selects control/cli/profiles/<name>/<cli>
6
+ // with its own credentials, configuration and native sessions. Ez never stores,
7
+ // copies or prints credentials; the native CLI logs in inside the home.
8
+ export const AUTH_PROFILE_CLIS: readonly string[] = ['codex', 'claude']
9
+ export const isAuthProfile = (value: unknown): value is string =>
10
+ typeof value === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value)
11
+
12
+ export const cliHome = (controlDir: string, cli: string, authProfile?: string): string => {
13
+ if (authProfile === undefined) return path.join(controlDir, 'cli', cli)
14
+ if (!isAuthProfile(authProfile) || !AUTH_PROFILE_CLIS.includes(cli)) throw new Error('Invalid auth profile selection')
15
+ return path.join(controlDir, 'cli', 'profiles', authProfile, cli)
16
+ }
17
+
18
+ const realDirectory = (directory: string) => lstat(directory).then(stat => stat.isDirectory(), () => false)
19
+
20
+ // A named home exists only once the owner provisioned it as a real directory.
21
+ // Execution never creates, links or falls back from it.
22
+ export const provisionedHome = async (controlDir: string, cli: string, authProfile?: string): Promise<string> => {
23
+ const home = cliHome(controlDir, cli, authProfile)
24
+ if (authProfile !== undefined && !await realDirectory(home)) throw new Error(`Auth profile ${authProfile} is not provisioned for ${cli}`)
25
+ return home
26
+ }
27
+
28
+ export const listAuthProfiles = async (controlDir?: string): Promise<{ cli: string; authProfile: string }[]> => {
29
+ if (!controlDir) return []
30
+ const names = await readdir(path.join(controlDir, 'cli', 'profiles')).catch(() => [] as string[])
31
+ const found: { cli: string; authProfile: string }[] = []
32
+ for (const authProfile of names.filter(isAuthProfile).sort())
33
+ for (const cli of AUTH_PROFILE_CLIS)
34
+ if (await realDirectory(cliHome(controlDir, cli, authProfile))) found.push({ cli, authProfile })
35
+ return found
36
+ }
37
+
38
+ // Claude credentials for one home. Host-capable default homes share the
39
+ // installer login in place; named homes and isolated agents use only their
40
+ // own store, plus an optional owner-provisioned long-lived `oauth-token`.
41
+ export const claudeAuthEnvironment = async (home: string, authProfile?: string, isolated = process.env.EZ_ISOLATION === 'isolated'): Promise<NodeJS.ProcessEnv> => {
42
+ const environment: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: home }
43
+ if (!isolated && authProfile === undefined) environment.CLAUDE_SECURESTORAGE_CONFIG_DIR = ''
44
+ const token = await readFile(path.join(home, 'oauth-token'), 'utf8').then(value => value.trim(), (error: NodeJS.ErrnoException) => {
45
+ if (error.code === 'ENOENT') return undefined
46
+ throw error
47
+ })
48
+ if (token !== undefined) {
49
+ if (!/^[A-Za-z0-9._-]{20,512}$/.test(token)) throw new Error('Invalid Claude token binding')
50
+ environment.CLAUDE_CODE_OAUTH_TOKEN = token
51
+ }
52
+ return environment
53
+ }
@@ -5,6 +5,7 @@ import { createInterface } from 'node:readline'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import path from 'node:path'
7
7
  import { terminateJob, validateCodexProvider, type CodexProviderBinding } from './executor.js'
8
+ import { codexRejection, codexResetAt, startupLine } from './setup-fallback.js'
8
9
 
9
10
  type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external';codexProvider?:CodexProviderBinding}
10
11
  type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
@@ -17,7 +18,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
17
18
  const provider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
18
19
  const child=io.launch?.() ?? spawn('codex',['app-server','--stdio','--disable','memories','--enable','skip_host_skill_discovery'],{cwd:options.workspace,env:process.env,stdio:['pipe','pipe','pipe']})
19
20
  const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
20
- let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false
21
+ let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false,workBegan=false,resetAt:string|undefined
21
22
  let resolveDone!:(code:number)=>void
22
23
  const done=new Promise<number>(resolve=>{resolveDone=resolve})
23
24
  const pending=new Map<number,{resolve:(value:any)=>void;reject:(error:Error)=>void;timer:ReturnType<typeof setTimeout>}>()
@@ -38,7 +39,10 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
38
39
  finish(0)
39
40
  }
40
41
  child.stderr?.pipe(process.stderr)
41
- child.on('error',fail)
42
+ child.on('error',error=>{
43
+ if((error as NodeJS.ErrnoException).code==='ENOENT')emit(startupLine({workBegan:false,rejection:{category:'cli-unavailable'}}))
44
+ fail(error)
45
+ })
42
46
  child.stdin?.on('error',fail)
43
47
  child.once('close',()=>{
44
48
  for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Codex app-server closed'))}
@@ -57,16 +61,23 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
57
61
  // Never turn an unexpected approval/elicitation request into permission.
58
62
  send({id:message.id,error:{code:-32601,message:`Unsupported unattended request: ${message.method}`}});fail(new Error(`Codex requires attention: ${message.method}`));return
59
63
  }
64
+ if(message.method==='account/rateLimits/updated')resetAt=codexResetAt(message.params?.rateLimits) ?? resetAt
60
65
  if(message.params?.threadId!==threadId)return
61
66
  if(message.method==='turn/started'){activeTurn=message.params.turn.id;sawTurn=true}
67
+ // Any item beyond the echoed input means the provider accepted the turn.
68
+ if(message.method==='item/started' && message.params.item?.type!=='userMessage' && !workBegan){workBegan=true;emit(startupLine({workBegan}))}
62
69
  if(message.method==='thread/goal/updated')settled(message.params.goal)
63
70
  if(message.method==='thread/goal/cleared')settled(null)
64
71
  if(message.method==='turn/completed'){
65
72
  if(activeTurn===message.params.turn.id)activeTurn=undefined
66
73
  if(message.params.turn.status!=='completed'){
67
74
  if(message.params.turn.status==='interrupted')finish(130)
68
- else fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
75
+ else {
76
+ const category=workBegan ? undefined : codexRejection(message.params.turn.error?.codexErrorInfo)
77
+ if(category)emit(startupLine({workBegan,rejection:{category,...(category==='quota' && resetAt ? {resetAt} : {})}}))
78
+ fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
69
79
  ? message.params.turn.error.message : 'Native turn failed without error details')))
80
+ }
70
81
  return
71
82
  }
72
83
  // Completion of a turn is not completion of a native goal. The native
@@ -2,7 +2,8 @@ import { assertEffort } from './model-policy.js'
2
2
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
3
3
  import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
- import { isPreset, persistedPreset, presetProvider, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
5
+ import { isPreset, persistedPreset, presetProvider, sameEngine, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
6
+ import { isAuthProfile } from './auth-profile.js'
6
7
 
7
8
  export type Owner = {
8
9
  id?: string
@@ -58,6 +59,8 @@ export type SessionState = {
58
59
  sessionId: string
59
60
  hasStarted: boolean
60
61
  cli?: string
62
+ // Bound with cli at creation: a native session never resumes under another home.
63
+ authProfile?: string
61
64
  nativeSessionId?: string
62
65
  title?: string
63
66
  archived?: boolean
@@ -129,7 +132,8 @@ const isState = (value: unknown): value is ControlState => {
129
132
  (s.archived === undefined || typeof s.archived === 'boolean') &&
130
133
  (s.applicationScope === undefined || /^[a-f0-9]{64}$/.test(s.applicationScope)) &&
131
134
  (s.telegramShared === undefined || typeof s.telegramShared === 'boolean') &&
132
- (s.preset === undefined || (isPreset(s.preset) && s.preset.cli === s.cli))
135
+ (s.authProfile === undefined || (isAuthProfile(s.authProfile) && s.cli !== undefined)) &&
136
+ (s.preset === undefined || (isPreset(s.preset) && sameEngine(s.preset, s)))
133
137
  return (
134
138
  candidate.version === 1 &&
135
139
  Array.isArray(candidate.pending) &&
@@ -150,9 +154,11 @@ const isState = (value: unknown): value is ControlState => {
150
154
  export const sessionTitle = (session: SessionState): string =>
151
155
  session.title || `Conversation ${session.sessionId.slice(0, 8)}`
152
156
 
157
+ const engineOf = (preset: AiPreset) => ({ cli: preset.cli, ...(preset.authProfile ? { authProfile: preset.authProfile } : {}) })
158
+
153
159
  const rememberPreset = (state: ControlState) => {
154
160
  const preset = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
155
- if (state.activeSession && preset && state.activeSession.cli === preset.cli)
161
+ if (state.activeSession && preset && sameEngine(state.activeSession, preset))
156
162
  state.activeSession.preset = preset
157
163
  }
158
164
 
@@ -486,8 +492,8 @@ export class ControlStore {
486
492
  const ai = state.ai
487
493
  // Older sessions did not record their model. Reuse a known preset for the
488
494
  // same CLI; never resume an engine ID through a different client.
489
- const preset = session.preset ?? ai?.presets.find(p => p.cli === session.cli)
490
- if (!ai || !preset || preset.cli !== session.cli)
495
+ const preset = session.preset ?? ai?.presets.find(p => sameEngine(p, session))
496
+ if (!ai || !preset || !sameEngine(preset, session))
491
497
  throw new Error('This older conversation has no saved AI binding. Start a new conversation.')
492
498
  if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli!) && !session.nativeSessionId)
493
499
  throw new Error('This conversation has no native session ID. Start a new conversation.')
@@ -537,10 +543,11 @@ export class ControlStore {
537
543
  const state = await this.readState()
538
544
  await requireControlGuard(state, guard)
539
545
  if (expectedSession !== undefined && (state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
546
+ const fallback = state.ai?.presets.find((p) => p.id === state.ai!.defaultId)
540
547
  const next: SessionState = {
541
548
  sessionId: crypto.randomUUID(),
542
549
  hasStarted: false,
543
- cli: state.ai?.presets.find((p) => p.id === state.ai!.defaultId)?.cli,
550
+ ...(fallback ? engineOf(fallback) : {}),
544
551
  }
545
552
  rememberPreset(state)
546
553
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
@@ -624,25 +631,25 @@ export class ControlStore {
624
631
  const settledPreset = settledAi?.presets.find((p) => p.id === settledAi.selectedId)
625
632
  const session = settled.activeSession
626
633
  if (settledPreset && session && (session.cli || session.hasStarted) &&
627
- !(session.cli === settledPreset.cli && session.hasStarted && session.preset &&
634
+ !(sameEngine(session, settledPreset) && session.hasStarted && session.preset &&
628
635
  presetProvider(session.preset) !== presetProvider(settledPreset)) &&
629
- (session.cli !== settledPreset.cli || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
636
+ (!sameEngine(session, settledPreset) || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
630
637
  Boolean(session.title || session.hasStarted || !title?.trim()))
631
638
  return { sessionId: session.sessionId, preset: settledPreset }
632
639
  return this.withLock(async () => {
633
640
  const state = await this.readState()
634
641
  state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
635
642
  const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
636
- state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
637
- if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
638
- if (state.activeSession.cli === preset.cli) {
643
+ state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset) }
644
+ if (!state.activeSession.cli && !state.activeSession.hasStarted) Object.assign(state.activeSession, engineOf(preset))
645
+ if (sameEngine(state.activeSession, preset)) {
639
646
  const previous = state.activeSession.preset
640
647
  // A provider change starts a separate native context. Model and effort
641
648
  // changes are native turn settings within the existing conversation.
642
649
  if (state.activeSession.hasStarted && previous &&
643
650
  presetProvider(previous) !== presetProvider(preset)) {
644
651
  ;(state.sessions ??= []).push(state.activeSession)
645
- state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset }
652
+ state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset }
646
653
  } else {
647
654
  state.activeSession.preset = preset
648
655
  }
@@ -675,14 +682,14 @@ export class ControlStore {
675
682
  state.ai!.selectedId = session.preset!.id
676
683
  }
677
684
  if (previous?.preset) {
678
- if (requested && requested.cli !== previous.cli) throw new Error('Application request conflicts with existing session engine')
685
+ if (requested && !sameEngine(requested, previous)) throw new Error('Application request conflicts with existing session engine')
679
686
  if (requested) previous.preset = requested
680
687
  activate(previous)
681
688
  if (shareTelegram || requested) await this.writeState(state)
682
689
  return { sessionId: previous.sessionId, preset: previous.preset }
683
690
  }
684
691
  const preset = requested ?? state.ai.presets.find(item => item.id === state.ai!.selectedId)!
685
- const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset, applicationScope: scope }
692
+ const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset, applicationScope: scope }
686
693
  state.sessions.push(session)
687
694
  activate(session)
688
695
  await this.writeState(state)
@@ -705,7 +712,7 @@ export class ControlStore {
705
712
  throw new Error('Application scope is shared; use shared controls')
706
713
  const nextPreset = preset ?? state.ai?.presets.find(item => item.id === state.ai!.defaultId)
707
714
  if (!nextPreset || !isPreset(nextPreset)) throw new Error('Invalid application AI selection')
708
- if (preset && previous?.cli === preset.cli) {
715
+ if (preset && previous && sameEngine(previous, preset)) {
709
716
  previous.preset = persistedPreset(preset)
710
717
  await this.writeState(state)
711
718
  return previous
@@ -714,7 +721,7 @@ export class ControlStore {
714
721
  // the old immutable session ID; private history stays absent from /chats.
715
722
  if (previous) previous.archived = true
716
723
  const next: SessionState = {sessionId:crypto.randomUUID(), hasStarted:false,
717
- cli:nextPreset.cli, preset:persistedPreset(nextPreset), applicationScope:scope}
724
+ ...engineOf(nextPreset), preset:persistedPreset(nextPreset), applicationScope:scope}
718
725
  ;(state.sessions ??= []).push(next)
719
726
  await this.writeState(state)
720
727
  return next
@@ -725,8 +732,8 @@ export class ControlStore {
725
732
  const state = await this.status()
726
733
  const session = state.activeSession?.sessionId === choice.sessionId ? state.activeSession
727
734
  : state.sessions?.find((s) => s.sessionId === choice.sessionId)
728
- if (!session || session.cli !== choice.preset.cli)
729
- throw new Error('Conversation has no matching CLI binding. Use New conversation explicitly; files are preserved.')
735
+ if (!session?.cli || !sameEngine(session, choice.preset))
736
+ throw new Error('Conversation has no matching CLI binding for this client and auth profile. Use New conversation explicitly; files are preserved.')
730
737
  if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli) && !session.nativeSessionId)
731
738
  throw new Error('Native session ID missing. Use New conversation explicitly; no automatic reset.')
732
739
  return session
@@ -774,16 +781,16 @@ export class ControlStore {
774
781
  assertEffort(preset.effort, preset.model, preset.cli)
775
782
  if ((state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Menu expired. Open Choose AI again.')
776
783
  const current = ai.presets.find((p) => p.id === ai.selectedId)!
777
- if (state.activeSession && (current.cli !== preset.cli || presetProvider(current) !== presetProvider(preset) || !state.activeSession.cli) && !fresh) return false
784
+ if (state.activeSession && (!sameEngine(current, preset) || presetProvider(current) !== presetProvider(preset) || !state.activeSession.cli) && !fresh) return false
778
785
  if (fresh || !state.activeSession) {
779
786
  rememberPreset(state)
780
787
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
781
- state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset: persistedPreset(preset) }
788
+ state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, ...engineOf(preset), preset: persistedPreset(preset) }
782
789
  }
783
790
  ai.selectedId = id
784
791
  // Persist the conversation's choice now, even if the owner switches chats
785
792
  // or restarts before sending another message. Queued choices are snapshots.
786
- if (state.activeSession.cli === preset.cli && (!state.activeSession.preset || presetProvider(state.activeSession.preset) === presetProvider(preset)))
793
+ if (sameEngine(state.activeSession, preset) && (!state.activeSession.preset || presetProvider(state.activeSession.preset) === presetProvider(preset)))
787
794
  state.activeSession.preset = persistedPreset(preset)
788
795
  ai.recentIds = [id, ...(ai.recentIds ?? []).filter((recentId) => recentId !== id)].slice(0, 3)
789
796
  await this.writeState(state)
@@ -318,6 +318,8 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
318
318
  const payload = (op.payload ?? {}) as { runId?: unknown; change?: unknown }
319
319
  if (typeof payload.runId !== 'string' || !payload.runId) throw new Error('Run ID is required')
320
320
  if (!payload.change || typeof payload.change !== 'object') throw new Error('Patch change is required')
321
+ // Setup attempts select what the host admits; only the relay records them.
322
+ if ('attempts' in payload.change) throw new Error('Setup attempts are relay-owned')
321
323
  return runs.patch(payload.runId, payload.change as never)
322
324
  }
323
325
  case 'taskApplication': {