@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.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +24 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docker/broker-update-smoke.mjs +16 -2
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/architecture/ai-selection.md +9 -1
- package/docs/docker-runtime.md +46 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +84 -6
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +13 -23
- package/package.json +1 -1
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +90 -14
- package/src/application-channel.ts +18 -14
- package/src/auth-profile.ts +53 -0
- package/src/codex-session.ts +14 -3
- package/src/control-state.ts +28 -21
- package/src/delivery-socket.ts +2 -0
- package/src/executor.ts +38 -26
- package/src/host-executor-client.ts +19 -7
- package/src/host-executor.ts +35 -10
- package/src/index.ts +320 -122
- package/src/menu.ts +8 -7
- package/src/plugin-broker.mjs +4 -11
- package/src/plugins/manager.mjs +70 -48
- package/src/runs.ts +74 -5
- package/src/schedule-cli.ts +39 -7
- package/src/scheduled-tasks.ts +42 -8
- package/src/scheduler.ts +9 -4
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +159 -0
- package/src/task-executor.ts +3 -2
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +79 -41
- package/src/updates/supervisor.mjs +27 -6
- package/src/workspace.ts +12 -1
- package/test/ai.test.ts +48 -2
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/auth-profiles.test.ts +187 -0
- package/test/intake-relay.test.ts +63 -2
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +51 -1
- package/test/plugin-manager.test.mjs +76 -38
- package/test/run-handoff.test.ts +132 -0
- package/test/scheduled-tasks.test.ts +24 -2
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +423 -0
- package/test/updates.test.mjs +77 -15
- 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
|
|
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(
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
13
|
-
export type
|
|
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
|
-
|
|
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)
|
|
256
|
-
if (!p.
|
|
257
|
-
|
|
258
|
-
if (!
|
|
259
|
-
throw new Error('This
|
|
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')
|
|
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())
|
|
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())
|
|
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
|
|
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
|
+
}
|
package/src/codex-session.ts
CHANGED
|
@@ -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',
|
|
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
|
|
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
|
package/src/control-state.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
490
|
-
if (!ai || !preset || preset
|
|
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
|
-
|
|
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
|
|
634
|
+
!(sameEngine(session, settledPreset) && session.hasStarted && session.preset &&
|
|
628
635
|
presetProvider(session.preset) !== presetProvider(settledPreset)) &&
|
|
629
|
-
(session
|
|
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,
|
|
637
|
-
if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession
|
|
638
|
-
if (state.activeSession
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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)
|
package/src/delivery-socket.ts
CHANGED
|
@@ -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': {
|