@jc_stack/ez-agents 0.1.0-beta.36 → 0.1.0-beta.38

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.
@@ -9,7 +9,7 @@ import { isHostRunId } from './host-executor-protocol.js'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
11
11
  import { parseIsolationClass, type IsolationClass } from './isolation.js'
12
- import { readModels, validateSelection } from './ai.js'
12
+ import { readModels, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
13
13
  import type { ChildProcess } from 'node:child_process'
14
14
  import { taskWorkspace } from './task-workspace.js'
15
15
  import { packageVersion } from './version.js'
@@ -17,12 +17,30 @@ import { installedPluginVersions } from './software-status.js'
17
17
  import { processSnapshot } from './process-tree.js'
18
18
 
19
19
  export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
20
- export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[] }
20
+ export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[]; opencodeProviders?: string[] }
21
21
  export type HostInstallation = { cli: string; isolation?: IsolationClass; agents: HostBinding[] }
22
22
 
23
+ // Periodic refresh body, extracted for testing. Throws on failure; the
24
+ // caller keeps the last-good disk catalog and the host alive.
25
+ export const refreshAgentCatalog = async (agent: HostBinding,
26
+ readFresh: (agent: HostBinding) => Promise<ModelChoice[]>,
27
+ seen: Map<HostBinding, string>): Promise<void> => {
28
+ const catalogModels = await readFresh(agent)
29
+ // Empty discovery is not an authoritative deletion of the installed catalog.
30
+ if (!catalogModels.length) return
31
+ const models = JSON.stringify(catalogModels)
32
+ if (seen.get(agent) === models) return
33
+ await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(catalogModels)
34
+ const file = path.join(agent.controlDir, 'host-executor/models.json')
35
+ await writeFile(file + '.tmp', models, { mode: 0o600 })
36
+ await rename(file + '.tmp', file)
37
+ seen.set(agent, models)
38
+ }
39
+
23
40
  const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
24
41
 
25
- export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
42
+ export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob,
43
+ readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>) => {
26
44
  if (installation.isolation !== undefined) parseIsolationClass(installation.isolation)
27
45
  if (installation.isolation === 'isolated') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
28
46
  resolveExecutor(installation.cli)
@@ -31,11 +49,13 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
31
49
  throw new Error('Each agent requires a separate workspace and control directory')
32
50
  const active = new Map<string, ChildProcess>()
33
51
  const tasks = new Set<Promise<void>>()
52
+ let catalogRefresh: Promise<void> | undefined
34
53
  const locks: string[] = []
35
54
  const sharedWorkspaces = new Map<HostBinding, string>()
36
55
  const additionalWorkspaces = new Map<HostBinding, string[]>()
37
56
  const catalog = async (agent: HostBinding) => {
38
- const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
57
+ const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
58
+ undefined, undefined, validateOpencodeProviders(agent.opencodeProviders), agent.controlDir)
39
59
  const providers = (agent.codexProviders ?? []).map(validateCodexProvider)
40
60
  const declared = providers.flatMap(provider => provider.models.map(model => {
41
61
  const native = discovered.find(candidate => candidate.cli === 'codex' && !candidate.provider && candidate.model === model)
@@ -46,11 +66,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
46
66
  model.model !== undefined && declaredModels.has(model.model) && model.cli === 'codex'
47
67
  return [...discovered.filter(model => !isReplacedByProvider(model)), ...declared]
48
68
  }
69
+ const freshCatalog = readCatalog ?? catalog
70
+ const beat = async (agent: HostBinding) => {
71
+ const directory = path.join(agent.controlDir, 'host-executor')
72
+ await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
73
+ await rename(path.join(directory, 'heartbeat.tmp'), path.join(directory, 'heartbeat.json'))
74
+ }
49
75
  try {
50
76
  for (const agent of installation.agents) {
51
77
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
52
78
  const providers=(agent.codexProviders ?? []).map(validateCodexProvider)
53
79
  if (new Set(providers.map(provider=>provider.id)).size !== providers.length) throw new Error('Codex provider IDs must be unique')
80
+ validateOpencodeProviders(agent.opencodeProviders)
54
81
  if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
55
82
  if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
56
83
  if (agent.additionalWorkspaces) {
@@ -77,7 +104,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
77
104
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
78
105
  await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
79
106
  locks.push(lock)
80
- const models = await catalog(agent)
107
+ const models = await freshCatalog(agent)
81
108
  await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
82
109
  const modelsFile=path.join(directory,'models.json')
83
110
  await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
@@ -100,22 +127,23 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
100
127
  while (!signal.aborted) {
101
128
  const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
102
129
  if(parent) { try { process.kill(parent,0) } catch { break } }
103
- if(Date.now()-catalogAt>30000){
104
- for(const agent of installation.agents){
105
- const catalogModels = await catalog(agent)
106
- const models=JSON.stringify(catalogModels)
107
- if(catalogSeen.get(agent)===models)continue
108
- await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(catalogModels)
109
- const file=path.join(agent.controlDir,'host-executor/models.json')
110
- await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
111
- catalogSeen.set(agent,models)
112
- }
130
+ // Keep transport liveness independent of native catalog I/O.
131
+ for (const agent of installation.agents) await beat(agent)
132
+ if(!catalogRefresh && Date.now()-catalogAt>30000){
113
133
  catalogAt=Date.now()
134
+ catalogRefresh=(async()=>{
135
+ for(const agent of installation.agents){
136
+ if(signal.aborted)break
137
+ try {
138
+ await refreshAgentCatalog(agent, freshCatalog, catalogSeen)
139
+ } catch (error) {
140
+ console.error('Host catalog refresh failed, keeping last-good models', error instanceof Error ? error.message : error)
141
+ }
142
+ }
143
+ })().finally(()=>{catalogRefresh=undefined})
114
144
  }
115
145
  for (const agent of installation.agents) {
116
146
  const directory=path.join(agent.controlDir,'host-executor')
117
- await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,platform:process.platform,arch:process.arch,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
118
- await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
119
147
  for (const file of await readdir(directory)) {
120
148
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
121
149
  const id=file.slice(0,-13)
@@ -161,7 +189,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
161
189
  if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
162
190
  if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
163
191
  throw new Error('Selected Codex provider is required for this model')
164
- if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},await catalog(agent))
192
+ // Admission and execution must use the same catalog snapshot. A
193
+ // transient native-client probe failure must not reject a model
194
+ // that the host just advertised to the relay and application.
195
+ if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
165
196
  const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.controlDir,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
166
197
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
167
198
  sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
@@ -198,6 +229,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
198
229
  } finally {
199
230
  for(const child of active.values())terminateJob(child)
200
231
  await Promise.allSettled(tasks)
232
+ await catalogRefresh
201
233
  for(const lock of locks)await rm(lock,{force:true})
202
234
  }
203
235
  }
package/src/index.ts CHANGED
@@ -26,7 +26,7 @@ import { InboxStore, type IncomingItem } from './inbox.js'
26
26
  import { loadConfig, type Config } from './config.js'
27
27
  import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
28
28
  import { ApprovalStore } from './approval.js'
29
- import { startExecutorJob, terminateJob } from './executor.js'
29
+ import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.js'
30
30
  import { RunStore, type RunRecord } from './runs.js'
31
31
  import { splitTelegramText } from './reply.js'
32
32
  import { markdownToTelegramHtml, escapeHtml } from './format.js'
@@ -71,7 +71,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
71
71
  let drainTimer: ReturnType<typeof setInterval> | undefined
72
72
  const codexHome = join(config.controlDir, 'cli', 'codex')
73
73
  const conversationMenu = createConversationMenu(control)
74
- const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
74
+ const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome,
75
+ undefined, opencodeDataHome(config.controlDir))
75
76
  const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
76
77
  const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
77
78
 
package/src/menu.ts CHANGED
@@ -30,14 +30,14 @@ const matchesModel = (preset: AiPreset, model: ModelChoice) =>
30
30
  // Short-lived opaque button IDs: no model names or executable arguments from callbacks.
31
31
  // These are operational settings, not a second conversational/agent loop.
32
32
  export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
33
- isInstalled = installed) => {
33
+ isInstalled = installed, opencodeDataHome?: string) => {
34
34
  const initial = chatPreset(cli)
35
35
  const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
36
36
  if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
37
37
  // Local execution runs Codex with CODEX_HOME set to the agent-bound control
38
38
  // directory, but the default catalog probed the process home instead. Bind it
39
39
  // to the same agent-bound home so /ai lists the models Codex can actually run.
40
- else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome)
40
+ else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome, undefined, opencodeDataHome, undefined, process.env.EZ_CONTROL_DIR)
41
41
  const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome }))
42
42
  const validate = async (preset: AiPreset) => {
43
43
  assertEffort(preset.effort, preset.model, preset.cli)
@@ -13,7 +13,25 @@ export async function deliveredMessages(controlDir: string, runId: string, optio
13
13
  if (options.messageId !== undefined && (!Number.isSafeInteger(options.messageId) || options.messageId < 1))
14
14
  throw new Error('Message ID must be a positive integer')
15
15
  const caller = await requireOwnerExecution(controlDir, runId)
16
- if (caller.application || caller.delivery) throw new Error('History requires a Telegram owner run')
16
+ const application = caller.application ?? caller.delivery
17
+ if (application) {
18
+ if (options.messageId !== undefined) throw new Error('Message ID filter is only available for Telegram history')
19
+ const owner = (await new ControlStore(controlDir, 900_000).status()).owner
20
+ const runs = new Map((await new RunStore(controlDir).list()).filter(run => {
21
+ const origin = run.application ?? run.delivery
22
+ return origin?.bindingId === application.bindingId && origin.scope === application.scope &&
23
+ !run.external && !run.taskId && ownsRun(owner, run)
24
+ }).map(run => [run.id, run]))
25
+ const messages = sentOutbox(controlDir).flatMap(item => {
26
+ const run = item.runId === undefined ? undefined : runs.get(item.runId)
27
+ return run && (!item.type || item.type === 'message') && typeof item.text === 'string'
28
+ ? [{ runId: run.id, id: item.id, text: item.text, createdAt: item.createdAt }]
29
+ : []
30
+ })
31
+ messages.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
32
+ return { scope: application.scope, messages: messages.slice(-limit), hasMore: messages.length > limit,
33
+ note: 'Delivered messages in the current application binding and scope; relay history is bounded and resets on restart.' }
34
+ }
17
35
  const owner = (await new ControlStore(controlDir, 900_000).status()).owner
18
36
  if (!ownsRun(owner, caller)) throw new Error('Owner binding changed')
19
37
  const pairedAt = Date.parse(owner!.pairedAt)
package/src/message.ts CHANGED
@@ -13,8 +13,8 @@ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
13
13
  console.log(
14
14
  'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
15
15
  )
16
- console.log('History: ezenciel-agents-message history [--limit 1..50] [--message-id ID] (read-only, bound Telegram chat, across sessions)')
17
- console.log("Authenticated channel: sends go directly to the paired owner's Telegram chat. receipt OUTBOX_ID reads delivery status; sends return queued ID then Telegram delivery receipt or unknown outcome. Never resend an uncertain operation.")
16
+ console.log('History: ezenciel-agents-message history [--limit 1..50] [--message-id ID] (read-only; current application binding and scope, or bound Telegram chat). --message-id is Telegram-only.')
17
+ console.log('Authenticated run: sends use its bound channel (application inbox or paired Telegram chat). Delivery receipts confirm that destination; uncertain sends must not be retried without readback.')
18
18
  console.log('Text: --text decodes \\n as a newline and \\\\ as a literal backslash; --text-file preserves file content.')
19
19
  process.exit(0)
20
20
  }
@@ -278,15 +278,14 @@ export async function hostNetworkBindings(config, record, home) {
278
278
  const configured = agent.pluginNetworkBindings;
279
279
  if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
280
280
  throw Error('Invalid host network bindings');
281
+ const route = configured?.[record.manifest?.id];
282
+ if (route === undefined) return [];
281
283
  if (process.env.EZ_DOCKER_COMPOSE === 'standalone') {
282
- if (configured && Object.keys(configured).length) throw Error('Isolated broker cannot use host network bindings');
283
- return [];
284
+ throw Error('Isolated broker cannot use host network bindings');
284
285
  }
285
286
  if (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
286
287
  realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir))
287
288
  throw Error('Invalid host network binding deployment');
288
- const route = configured?.[record.manifest?.id];
289
- if (route === undefined) return [];
290
289
  keys(route, ['revisions', 'bindings']);
291
290
  const trusted = await snapshot(record.source);
292
291
  if (record.revision !== trusted.revision || record.source !== trusted.source ||
package/src/setup.ts CHANGED
@@ -37,7 +37,7 @@ export const readActiveExecutor = async (envFilePath: string): Promise<string> =
37
37
  } catch {
38
38
  // env file doesn't exist yet
39
39
  }
40
- return process.env.EZ_EXECUTOR_CLI?.trim() || 'agy'
40
+ return process.env.EZ_EXECUTOR_CLI?.trim() || 'codex'
41
41
  }
42
42
 
43
43
  export const setExecutorInEnv = async (envFilePath: string, cliName: string): Promise<void> => {
@@ -102,9 +102,10 @@ const printStatus = (status: { active: string; items: ExecutorStatus[] }): void
102
102
  console.log('\nTo switch executor:')
103
103
  console.log(' pnpm run setup <executor-name>')
104
104
  console.log(' Examples: pnpm run setup claude')
105
- console.log(' pnpm run setup agy')
105
+ console.log(' pnpm run setup codex')
106
106
  console.log(' pnpm run setup grok')
107
107
  console.log(' pnpm run setup opencode')
108
+ console.log(' pnpm run setup pi')
108
109
  console.log(' pnpm run setup codex-gui\n')
109
110
  }
110
111
 
package/test/ai.test.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { ControlStore } from '../src/control-state.js'
7
- import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
7
+ import { initialPreset, chatPreset, readModels, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
8
8
  import { createAiMenu } from '../src/menu.js'
9
9
  import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
10
10
  import { InboxStore } from '../src/inbox.js'
@@ -287,6 +287,12 @@ test('native executor flags carry the exact model and effort; only structured me
287
287
  const codex = EXECUTOR_REGISTRY.codex.buildArgs(opts, '', 'fixture')
288
288
  assert.ok(codex.includes('model_reasoning_effort="medium"'))
289
289
  assert.deepEqual(codex.slice(-3), ['resume', opts.sessionId, '-'])
290
+ // Opencode variants pass through verbatim on every platform; there is no
291
+ // OS-specific effort mapping between the catalog and `run --variant`.
292
+ const opencode = EXECUTOR_REGISTRY.opencode.buildArgs(
293
+ { workspace: '/tmp/fixture', model: 'opencode/muse-spark-1.3-contributor-free', effort: 'xhigh' }, '', 'fixture')
294
+ assert.equal(opencode[opencode.indexOf('-m') + 1], 'opencode/muse-spark-1.3-contributor-free')
295
+ assert.equal(opencode[opencode.indexOf('--variant') + 1], 'xhigh')
290
296
  assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'thread.started', thread_id: opts.sessionId })), opts.sessionId)
291
297
  assert.equal(nativeSessionId('opencode', JSON.stringify({ type: 'step_start', sessionID: 'ses_fixture' })), 'ses_fixture')
292
298
  assert.equal(nativeSessionId('codex', JSON.stringify({ type: 'text', thread_id: opts.sessionId })), undefined)
@@ -353,3 +359,181 @@ test('upgrades expose responsive chat without replacing an existing default or q
353
359
  assert.ok(state.presets.some(p => p.id === 'chat-default'))
354
360
  } finally { await rm(dir, { recursive: true, force: true }) }
355
361
  })
362
+
363
+ test('opencode catalog projects installed models with variants as efforts', async () => {
364
+ const verbose = [
365
+ 'opencode/big-pickle',
366
+ JSON.stringify({ id: 'big-pickle', providerID: 'opencode', name: 'Big Pickle', variants: {} }),
367
+ 'opencode/ling-free',
368
+ JSON.stringify({ id: 'ling-free', providerID: 'opencode', name: 'Ling Free',
369
+ variants: { low: { reasoningEffort: 'low' }, medium: { reasoningEffort: 'medium' }, 'bad option': {} } }),
370
+ 'openrouter/~aliased/model',
371
+ JSON.stringify({ id: 'model', providerID: 'openrouter', name: 'Aliased', variants: {} }),
372
+ ].join('\n')
373
+ const catalog = await readOpencodeModels(async (args) => {
374
+ assert.deepEqual(args, ['models', '--verbose'])
375
+ return verbose
376
+ })
377
+ assert.deepEqual(catalog, [
378
+ { cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
379
+ { cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
380
+ ])
381
+ })
382
+
383
+ test('opencode catalog falls back to the plain list, then to the client default', async () => {
384
+ const plain = await readOpencodeModels(async (args) => {
385
+ if (args.includes('--verbose')) throw new Error('no verbose metadata')
386
+ return 'opencode/alpha\nopenrouter/~aliased/model\nnot a model line\n'
387
+ })
388
+ assert.deepEqual(plain, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
389
+ assert.deepEqual(await readOpencodeModels(async () => { throw new Error('opencode unavailable') }), [])
390
+ const fallback = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
391
+ async () => { throw new Error('opencode unavailable') })
392
+ assert.deepEqual(fallback, [{ cli: 'opencode', name: 'opencode · client default', efforts: [] }])
393
+ const listed = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never,
394
+ async () => 'opencode/alpha\n')
395
+ assert.deepEqual(listed, [{ cli: 'opencode', model: 'opencode/alpha', name: 'opencode/alpha', efforts: [] }])
396
+ })
397
+
398
+ test('opencode provider allowlist scopes the catalog to Go and never falls back outside it', async () => {
399
+ assert.equal(opencodeProviderAllowlist({}), undefined)
400
+ assert.equal(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' ' }), undefined)
401
+ assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: 'opencode-go' }), ['opencode-go'])
402
+ assert.deepEqual(opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: ' opencode-go ,openrouter,opencode-go ' }), ['opencode-go', 'openrouter'])
403
+ for (const bad of ['Bad Name!', 'has space', 'UPPER', 'a'.repeat(33), ',,,', 'ok,,bad name'])
404
+ assert.throws(() => opencodeProviderAllowlist({ EZ_OPENCODE_PROVIDERS: bad }), /one to sixteen unique provider IDs/)
405
+ const verbose = [
406
+ 'opencode/mimo-free',
407
+ JSON.stringify({ id: 'mimo-free', providerID: 'opencode', name: 'Mimo Free', variants: {} }),
408
+ 'opencode-go/muse-spark-1.3-contributor',
409
+ JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor',
410
+ variants: { high: {}, xhigh: {} } }),
411
+ 'openrouter/some/model',
412
+ JSON.stringify({ id: 'some/model', providerID: 'openrouter', name: 'Some', variants: {} }),
413
+ ].join('\n')
414
+ const runner = async () => verbose
415
+ const prior = process.env.EZ_OPENCODE_PROVIDERS
416
+ try {
417
+ process.env.EZ_OPENCODE_PROVIDERS = 'opencode-go'
418
+ const scoped = await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)
419
+ assert.deepEqual(scoped.map((m) => m.model), ['opencode-go/muse-spark-1.3-contributor'])
420
+ await assert.rejects(
421
+ validateSelection({ id: 'free', name: 'Free', cli: 'opencode', model: 'opencode/mimo-free' }, scoped, async () => true),
422
+ /installed client catalog/)
423
+ process.env.EZ_OPENCODE_PROVIDERS = 'missing-provider'
424
+ assert.deepEqual(await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner), [])
425
+ delete process.env.EZ_OPENCODE_PROVIDERS
426
+ assert.equal((await readModels(undefined, async (cli) => cli === 'opencode', undefined as never, runner)).length, 3)
427
+ } finally {
428
+ if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
429
+ else process.env.EZ_OPENCODE_PROVIDERS = prior
430
+ }
431
+ })
432
+ test('explicit host binding allowlist overrides the process environment', async () => {
433
+ const runner = async () => 'opencode/a\nopencode-go/b\n'
434
+ const only = (models: { cli: string; model?: string }[]) => models.filter((m) => m.cli === 'opencode').map((m) => m.model)
435
+ const prior = process.env.EZ_OPENCODE_PROVIDERS
436
+ try {
437
+ process.env.EZ_OPENCODE_PROVIDERS = 'opencode'
438
+ assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner)), ['opencode/a'])
439
+ assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, ['opencode-go'])), ['opencode-go/b'])
440
+ assert.deepEqual(only(await readModels(undefined, async () => true, undefined as never, runner, undefined, undefined)), ['opencode/a'])
441
+ } finally {
442
+ if (prior === undefined) delete process.env.EZ_OPENCODE_PROVIDERS
443
+ else process.env.EZ_OPENCODE_PROVIDERS = prior
444
+ }
445
+ })
446
+
447
+ test('opencode model and variant selections validate against the installed catalog', async () => {
448
+ const catalog = [
449
+ { cli: 'opencode', model: 'opencode/ling-free', name: 'Ling Free · opencode/ling-free', efforts: ['low', 'medium'] },
450
+ { cli: 'opencode', model: 'opencode/big-pickle', name: 'Big Pickle · opencode/big-pickle', efforts: [] },
451
+ ]
452
+ const available = async (cli: string) => cli === 'opencode'
453
+ await validateSelection({ id: 'choice', name: 'Ling', cli: 'opencode', model: 'opencode/ling-free', effort: 'low' }, catalog, available)
454
+ await validateSelection({ id: 'default', name: 'Default', cli: 'opencode', model: 'opencode/big-pickle' }, catalog, available)
455
+ await assert.rejects(
456
+ validateSelection({ id: 'bad', name: 'Bad', cli: 'opencode', model: 'opencode/ling-free', effort: 'max' }, catalog, available),
457
+ /installed client catalog/)
458
+ await assert.rejects(
459
+ validateSelection({ id: 'missing', name: 'Missing', cli: 'opencode', model: 'opencode/unknown' }, catalog, available),
460
+ /installed client catalog/)
461
+ })
462
+
463
+ test('pi does not borrow an unrelated OpenCode catalog', async () => {
464
+ const runner = async () => [
465
+ 'opencode-go/muse-spark-1.3-contributor',
466
+ JSON.stringify({ id: 'muse-spark-1.3-contributor', providerID: 'opencode-go', name: 'Muse Spark 1.3 Contributor', variants: { high: {}, xhigh: {} } }),
467
+ ].join('\n')
468
+ const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never, runner)
469
+ assert.deepEqual(catalog, [{ cli: 'pi', name: 'Pi · client default', efforts: [] }])
470
+ assert.equal(isPreset({ id: 'x', name: 'Pi', cli: 'pi' }), true)
471
+ for (const cli of ['unreal-agent', 'antigravity', 'agy'])
472
+ assert.equal(isPreset({ id: 'x', name: 'Unavailable', cli }), false)
473
+ await validateSelection({ id: 'choice', name: 'Default', cli: 'pi' }, catalog, async (cli) => cli === 'pi')
474
+ await assert.rejects(
475
+ validateSelection(
476
+ { id: 'bad', name: 'Bad', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'max' },
477
+ catalog, async (cli) => cli === 'pi'),
478
+ /installed client catalog/)
479
+ })
480
+
481
+ test('model catalog merges user-curated entries ahead of discovered ones', async () => {
482
+ const control = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-'))
483
+ const home = await mkdtemp(join(tmpdir(), 'ez-catalog-curated-home-'))
484
+ try {
485
+ await mkdir(join(home, '.codex'))
486
+ await writeFile(join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
487
+ { slug: 'fixture-model', display_name: 'Fixture', visibility: 'list',
488
+ supported_reasoning_levels: [{ effort: 'medium' }] },
489
+ ] }))
490
+ await writeFile(join(control, 'ai-models.json'), JSON.stringify([
491
+ { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
492
+ { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Dupe', efforts: [] },
493
+ { cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
494
+ { cli: 'nope', model: 'x' },
495
+ 'not-an-entry',
496
+ ]))
497
+ const installedOnly = async (cli: string) => cli === 'codex' || cli === 'pi'
498
+ const noRunner = async (_args: string[]): Promise<string> => { throw new Error('no native CLI in test') }
499
+ const catalog = await readModels(home, installedOnly, join(home, '.codex'),
500
+ noRunner, undefined, undefined, control)
501
+ assert.deepEqual(catalog.filter((m) => m.cli === 'pi'), [
502
+ { cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
503
+ { cli: 'pi', name: 'Pi · client default', efforts: [] },
504
+ ])
505
+ // Curated entries win over the same discovered id and validate for selection.
506
+ assert.deepEqual(catalog.filter((m) => m.cli === 'codex'), [
507
+ { cli: 'codex', model: 'fixture-model', name: 'Curated Fixture', efforts: ['medium'] },
508
+ ])
509
+ await validateSelection(
510
+ { id: 'choice', name: 'Spark', cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', effort: 'xhigh' },
511
+ catalog, async (cli) => cli === 'pi')
512
+ // Missing or malformed curation files fall back to discovery alone.
513
+ assert.deepEqual(await readCuratedModels(join(control, 'missing-dir')), [])
514
+ await writeFile(join(control, 'ai-models.json'), 'not json')
515
+ assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', join(home, '.codex'),
516
+ noRunner, undefined, undefined, control), [
517
+ { cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
518
+ ])
519
+ } finally {
520
+ await rm(control, { recursive: true, force: true })
521
+ await rm(home, { recursive: true, force: true })
522
+ }
523
+ })
524
+
525
+ test('curated models respect installed clients and provider scope', async () => {
526
+ const control = await mkdtemp(join(tmpdir(), 'ez-catalog-scope-'))
527
+ try {
528
+ await writeFile(join(control, 'ai-models.json'), JSON.stringify([
529
+ { cli: 'pi', model: 'opencode-go/allowed', efforts: ['xhigh'] },
530
+ { cli: 'pi', model: 'other/blocked' },
531
+ { cli: 'opencode', model: 'other/blocked' },
532
+ { cli: 'claude', model: 'uninstalled' },
533
+ ]))
534
+ const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never,
535
+ async () => { throw new Error('no OpenCode catalog') }, undefined, ['opencode-go'], control)
536
+ assert.deepEqual(catalog.filter(entry => entry.model).map(entry => entry.model), ['opencode-go/allowed'])
537
+ assert.equal(catalog.some(entry => entry.cli === 'pi' && !entry.model), false)
538
+ } finally { await rm(control, { recursive: true, force: true }) }
539
+ })
@@ -23,9 +23,9 @@ test('rejects malformed timeouts', () => {
23
23
  assert.throws(() => loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_PAIRING_TTL_SECONDS: 'bad' }), /positive integer/)
24
24
  })
25
25
 
26
- test('loads executor CLI configuration with agy fallback', () => {
26
+ test('loads executor CLI configuration with codex fallback', () => {
27
27
  const defaultConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY' })
28
- assert.equal(defaultConfig.executorCli, 'agy')
28
+ assert.equal(defaultConfig.executorCli, 'codex')
29
29
 
30
30
  const customConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY', EZ_EXECUTOR_CLI: 'claude' })
31
31
  assert.equal(customConfig.executorCli, 'claude')
@@ -66,7 +66,7 @@ test('titles are bounded and rename is durable; malformed IDs and unsupported me
66
66
  await assert.rejects(store.listSessions(), /unsupported shape/)
67
67
  }))
68
68
 
69
- test('legacy sessions retain IDs; unbound and latest-only engines cannot resume a different context', async () => fixture(async (store, dir) => {
69
+ test('legacy sessions retain IDs; unbound sessions cannot resume a different context', async () => fixture(async (store, dir) => {
70
70
  const legacy = await store.ensureActiveSession()
71
71
  await store.markSessionStarted(legacy.sessionId)
72
72
  await store.captureChoice(initialPreset('grok'))
@@ -74,11 +74,5 @@ test('legacy sessions retain IDs; unbound and latest-only engines cannot resume
74
74
  await assert.rejects(store.switchSession(legacy.sessionId), /binding/)
75
75
  assert.match(sessionTitle((await store.listSessions()).find(s => s.sessionId === legacy.sessionId)!), /Conversation /)
76
76
  await store.archiveSession(legacy.sessionId, true)
77
- const agy = { id: 'agy-test', name: 'AGY', cli: 'agy' }
78
- await store.savePreset(agy)
79
- await store.selectPreset(agy.id, (await store.getActiveSession())!.sessionId, true)
80
- const old = await store.captureChoice(agy)
81
- await store.markSessionStarted(old.sessionId)
82
- await store.resetSession()
83
- await assert.rejects(store.switchSession(old.sessionId), /latest conversation/)
77
+
84
78
  }))
@@ -31,13 +31,13 @@ if(args[0]==='app-server'){
31
31
  }
32
32
  if(q.method==='thread/goal/get')send({id:q.id,result:{goal:null}});
33
33
  });
34
- }else capture(args.includes('--prompt-file')?fs.readFileSync(args[args.indexOf('--prompt-file')+1],'utf8'):args.some(a=>a.startsWith('--print='))?args.find(a=>a.startsWith('--print=')).slice(8):args.includes('--print')||(args[0]==='exec'&&args.at(-1)==='-')?fs.readFileSync(0,'utf8'):args.at(-1));
34
+ }else capture(args.includes('--prompt-file')?fs.readFileSync(args[args.indexOf('--prompt-file')+1],'utf8'):args.includes('--print')||(args[0]==='exec'&&args.at(-1)==='-')?fs.readFileSync(0,'utf8'):args.at(-1));
35
35
  `
36
- for(const name of ['codex','grok','agy','claude','opencode'])await writeFile(path.join(bin,name),fixture,{mode:0o700})
36
+ for(const name of ['codex','grok','claude','opencode'])await writeFile(path.join(bin,name),fixture,{mode:0o700})
37
37
  const previous={PATH:process.env.PATH,TELEGRAM_BOT_TOKEN:process.env.TELEGRAM_BOT_TOKEN,EZ_EXECUTOR_TRANSPORT:process.env.EZ_EXECUTOR_TRANSPORT}
38
38
  process.env.PATH=bin+path.delimiter+process.env.PATH;process.env.TELEGRAM_BOT_TOKEN='do-not-inherit';delete process.env.EZ_EXECUTOR_TRANSPORT
39
39
  try {
40
- for(const cli of ['codex','grok','agy','claude','opencode'])for(const isResume of [false,true])for(const text of [' /goal audit list of files and give me a simple list with filenames\n','--help','-','resume']) {
40
+ for(const cli of ['codex','grok','claude','opencode'])for(const isResume of [false,true])for(const text of [' /goal audit list of files and give me a simple list with filenames\n','--help','-','resume']) {
41
41
  const runId='r_'+cli+'_'+String(isResume)+'_'+Buffer.from(text).toString('hex').slice(0,20)
42
42
  await ownerRun(controlDir,runId)
43
43
  const job=await startExecutorJob([text],{workspace,controlDir,binDir:bin,cli,runId,timeoutMs:5000,isResume,sessionId:'native-existing',repairEnabled:false})
@@ -51,7 +51,6 @@ if(args[0]==='app-server'){
51
51
  }
52
52
  if(['codex','claude'].includes(cli))assert.ok(!captured.args.includes('--help'))
53
53
  if(cli==='opencode')assert.equal(captured.args.at(-2),'--')
54
- if(cli==='agy')assert.ok(captured.args.includes('--print='+text))
55
54
  if(cli==='claude')assert.ok(!captured.args.includes('--append-system-prompt-file'))
56
55
  }
57
56
  for(const [runId,texts] of [['r_schedule_literal',['/goal audit list of files and give me a simple list with filenames']],['r_batch',['first\nline',' second ']]] as const) {