@jc_stack/ez-agents 0.1.0-beta.37 → 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.
package/.env.example CHANGED
@@ -13,9 +13,9 @@ EZ_AGENT_WORKSPACE=./agent
13
13
  # Optional. Default is 300.
14
14
  # Agent execution has no wall-clock timeout. Use /stop or schedule cancel.
15
15
 
16
- # CLI Executor adapter. Supported: agy (default), claude, grok, opencode
16
+ # CLI Executor adapter. Supported: codex (default), codex-gui, claude, grok, opencode, pi
17
17
  # Switch easily with: pnpm run setup <executor-name>
18
- EZ_EXECUTOR_CLI=agy
18
+ EZ_EXECUTOR_CLI=codex
19
19
 
20
20
  # Optional: Audio transcription (STT) and voice synthesis (TTS)
21
21
  # GEMINI_API_KEY=
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.38
4
+
5
+ - Remove the retired Antigravity CLI integration and its unused engine paths.
6
+ - Add scoped application delivery history readback so an authorized application
7
+ can retrieve its own messages and delivery receipts.
8
+ - Validate host execution against its advertised model catalog and keep host
9
+ heartbeats responsive during background catalog refresh.
10
+
3
11
  ## 0.1.0-beta.37
4
12
 
5
13
  - List installed OpenCode models with their provider variants as selectable
package/README.md CHANGED
@@ -291,7 +291,6 @@ Existing conversations without a title show their first saved message and date w
291
291
  available. Empty routing placeholders are omitted from history; + New conversation
292
292
  is the create action. Back updates the current menu without creating a conversation. Use /rename for a custom name. Older
293
293
  records without a saved model reuse a known choice for their original CLI.
294
- Antigravity's latest-only resume cannot switch back to an older conversation.
295
294
  The list covers sessions already tracked by this Ez agent; it does not import
296
295
  unrelated GUI/CLI history. Application-backed channels keep session controls in
297
296
  the connected application. Shared workspace files still persist across topics.
@@ -131,6 +131,14 @@ not that a human read it. Failure adds a generic `error`; detailed logs remain
131
131
  local to core. Cancellation uses the existing child termination boundary and may
132
132
  return `running` until the process exits.
133
133
 
134
+ Inside an active native application run, `ezenciel-agents-message history
135
+ --limit 8` reads delivered text from this same inbox for the run's immutable
136
+ application binding and scope, including earlier runs in that scope. It cannot
137
+ read another binding, scope, or Telegram chat. The Telegram numeric
138
+ `--message-id` filter is unavailable for application runs. This reads the same
139
+ bounded relay delivery state as the application inbox; both disappear on relay
140
+ restart and add no persistent message record.
141
+
134
142
  `requestId` and `scope` accept 1–200 ASCII letters, digits, `_:.-`. Text is at most
135
143
  16,000 characters. Context is an optional JSON object (48 KiB); the complete HTTP
136
144
  body is bounded to 64 KiB. Unknown input fields are rejected. Use a stable job ID:
@@ -155,8 +163,7 @@ application must not infer or auto-submit consent.
155
163
 
156
164
  Each application binding plus scope gets an existing core session. Its first run
157
165
  captures the selected engine/preset; later runs resume that same native session.
158
- Application sessions are excluded from the Telegram session selector. Engines
159
- that only resume an implicit latest session (`agy`) cannot serve isolated scopes.
166
+ Application sessions are excluded from the Telegram session selector.
160
167
 
161
168
  Before cutting over an existing application, stop its old runner, copy its native
162
169
  transcripts into the ordinary engine's private state, and import each authoritative
package/docs/upgrades.md CHANGED
@@ -245,7 +245,7 @@ The package-owned locator is the managed `<!-- ez tools -->` block: the bound
245
245
  generated on read. Plugin registry init and update binding refresh that locator.
246
246
  Malformed markers and symlinks fail visibly rather than overwriting personal work.
247
247
 
248
- Codex, agy, Grok and OpenCode discover workspace instructions natively. Claude
248
+ Codex, Grok and OpenCode discover workspace instructions natively. Claude
249
249
  receives the native `--append-system-prompt-file` binding to `AGENTS.md`, alongside
250
250
  its own normal instructions. Fresh and resumed owner input is literal; ez does
251
251
  not surround it with policies, tool recipes, repair instructions or history.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.37",
3
+ "version": "0.1.0-beta.38",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
package/src/ai.ts CHANGED
@@ -12,7 +12,7 @@ export type AiPreset = { id: string; name: string; cli: string; provider?: strin
12
12
  export type ExecutionChoice = { sessionId: string; preset: AiPreset }
13
13
  export type ModelChoice = { cli: string; provider?: string; model?: string; name: string; efforts: string[] }
14
14
  const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
15
- const knownClis = ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy', 'pi']
15
+ const knownClis = ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'pi']
16
16
  const nativeEffort = (cli: string, effort: string) =>
17
17
  cli === 'pi' ? ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort) : true
18
18
  export const isPreset = (p: unknown): p is AiPreset => {
@@ -158,8 +158,7 @@ export const readModels = async (home = homedir(), available = installed, codexH
158
158
  models.push(...(desktop.length ? desktop : [{ cli: 'codex-gui', name: 'codex-gui · desktop', efforts: [] }]))
159
159
  }
160
160
  // Other adapters expose the authenticated client's default, not a guessed catalog.
161
- for (const cli of ['claude', 'agy'])
162
- if (await available(cli)) models.push({ cli, name: `${cli} · client default`, efforts: [] })
161
+ if (await available('claude')) models.push({ cli: 'claude', name: 'claude · client default', efforts: [] })
163
162
  const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
164
163
  if (await available('opencode')) models.push(...await opencodeCatalogModels(opencodeRunner, opencodeDataHome, allow))
165
164
  // Pi has separate provider configuration. OpenCode's catalog cannot
@@ -213,7 +212,7 @@ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env):
213
212
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
214
213
  assertEffort(p.effort, p.model, p.cli)
215
214
  if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
216
- if (!p.provider && !p.model && !p.effort && p.cli !== 'agy') return
215
+ if (!p.provider && !p.model && !p.effort) return
217
216
  const model = catalog.find((m) => m.cli === p.cli && m.provider === p.provider && m.model === p.model)
218
217
  if (!model || (p.effort && !model.efforts.includes(p.effort)))
219
218
  throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
@@ -206,7 +206,7 @@ export class ApplicationChannel {
206
206
  }
207
207
  if (value.action==='model') {
208
208
  const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.provider===value.provider && item.model===value.model)
209
- if (!model || model.cli==='agy' || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
209
+ if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
210
210
  preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
211
211
  }
212
212
  await control.changeApplicationSession(hashedScope,guard,preset)
package/src/config.ts CHANGED
@@ -78,7 +78,7 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
78
78
  isolation,
79
79
  codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
80
80
  codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
81
- executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
81
+ executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'codex',
82
82
  applicationPort: env.EZ_APPLICATION_PORT ? positiveInteger(env.EZ_APPLICATION_PORT, 'EZ_APPLICATION_PORT') : undefined,
83
83
  applicationHost: env.EZ_APPLICATION_HOST?.trim() || '127.0.0.1',
84
84
  channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
@@ -483,8 +483,6 @@ export class ControlStore {
483
483
  if (state.activeSession?.sessionId === sessionId) return state.activeSession
484
484
  const session = state.sessions?.find(s => s.sessionId === sessionId)
485
485
  if (!session || session.archived || (session.applicationScope && !session.telegramShared)) throw new Error('Conversation unavailable. Open /chats again.')
486
- if (session.cli === 'agy')
487
- throw new Error('Antigravity only resumes its latest conversation; selecting an older session is not supported.')
488
486
  const ai = state.ai
489
487
  // Older sessions did not record their model. Reuse a known preset for the
490
488
  // same CLI; never resume an engine ID through a different client.
@@ -684,7 +682,6 @@ export class ControlStore {
684
682
  return { sessionId: previous.sessionId, preset: previous.preset }
685
683
  }
686
684
  const preset = requested ?? state.ai.presets.find(item => item.id === state.ai!.selectedId)!
687
- if (preset.cli === 'agy') throw new Error('Application scopes require an engine with explicit session selection')
688
685
  const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset, applicationScope: scope }
689
686
  state.sessions.push(session)
690
687
  activate(session)
@@ -707,7 +704,7 @@ export class ControlStore {
707
704
  if (previous && (previous.telegramShared || previous === state.activeSession))
708
705
  throw new Error('Application scope is shared; use shared controls')
709
706
  const nextPreset = preset ?? state.ai?.presets.find(item => item.id === state.ai!.defaultId)
710
- if (!nextPreset || !isPreset(nextPreset) || nextPreset.cli === 'agy') throw new Error('Invalid application AI selection')
707
+ if (!nextPreset || !isPreset(nextPreset)) throw new Error('Invalid application AI selection')
711
708
  if (preset && previous?.cli === preset.cli) {
712
709
  previous.preset = persistedPreset(preset)
713
710
  await this.writeState(state)
package/src/executor.ts CHANGED
@@ -143,17 +143,6 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
143
143
  return args
144
144
  },
145
145
  },
146
- agy: {
147
- name: 'antigravity',
148
- command: 'agy',
149
- description: 'Google Antigravity CLI (default)',
150
- buildArgs: (opts, _promptFile, promptText) => {
151
- const args: string[] = []
152
- if (opts.isResume) args.push('-c')
153
- args.push('--dangerously-skip-permissions', `--print=${promptText}`)
154
- return args
155
- },
156
- },
157
146
  claude: {
158
147
  name: 'claude',
159
148
  command: 'claude',
@@ -242,13 +231,12 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
242
231
  }
243
232
 
244
233
  export const EXECUTOR_ALIASES: Record<string, string> = {
245
- antigravity: 'agy',
246
234
  'claude-code': 'claude',
247
235
  oc: 'opencode',
248
236
  }
249
237
 
250
238
  export const executorKey = (name?: string): string => {
251
- const normalized = (name ?? 'agy').trim().toLowerCase()
239
+ const normalized = (name ?? 'codex').trim().toLowerCase()
252
240
  const resolvedKey = EXECUTOR_ALIASES[normalized] ?? normalized
253
241
  if (!EXECUTOR_REGISTRY[resolvedKey]) {
254
242
  const supported = [...Object.keys(EXECUTOR_REGISTRY), ...Object.keys(EXECUTOR_ALIASES)].join(', ')
@@ -314,14 +302,6 @@ export const grokInvocation = (
314
302
  args: EXECUTOR_REGISTRY.grok.buildArgs(options, promptFile, ''),
315
303
  })
316
304
 
317
- export const antigravityInvocation = (
318
- prompt: string,
319
- options: Pick<ExecutorOptions, 'isResume'> = {},
320
- ): { command: string; args: string[] } => ({
321
- command: EXECUTOR_REGISTRY.agy.command,
322
- args: EXECUTOR_REGISTRY.agy.buildArgs({ workspace: '', ...options }, '', prompt),
323
- })
324
-
325
305
  export const opencodeInvocation = (
326
306
  prompt: string,
327
307
  options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume'> = { workspace: '' },
@@ -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, validateOpencodeProviders } 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'
@@ -20,9 +20,27 @@ export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;
20
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,6 +49,7 @@ 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[]>()
@@ -47,6 +66,12 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
47
66
  model.model !== undefined && declaredModels.has(model.model) && model.cli === 'codex'
48
67
  return [...discovered.filter(model => !isReplacedByProvider(model)), ...declared]
49
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
+ }
50
75
  try {
51
76
  for (const agent of installation.agents) {
52
77
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
@@ -79,7 +104,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
79
104
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
80
105
  await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
81
106
  locks.push(lock)
82
- const models = await catalog(agent)
107
+ const models = await freshCatalog(agent)
83
108
  await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
84
109
  const modelsFile=path.join(directory,'models.json')
85
110
  await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
@@ -102,22 +127,23 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
102
127
  while (!signal.aborted) {
103
128
  const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
104
129
  if(parent) { try { process.kill(parent,0) } catch { break } }
105
- if(Date.now()-catalogAt>30000){
106
- for(const agent of installation.agents){
107
- const catalogModels = await catalog(agent)
108
- const models=JSON.stringify(catalogModels)
109
- if(catalogSeen.get(agent)===models)continue
110
- await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(catalogModels)
111
- const file=path.join(agent.controlDir,'host-executor/models.json')
112
- await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
113
- catalogSeen.set(agent,models)
114
- }
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){
115
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})
116
144
  }
117
145
  for (const agent of installation.agents) {
118
146
  const directory=path.join(agent.controlDir,'host-executor')
119
- 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})
120
- await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
121
147
  for (const file of await readdir(directory)) {
122
148
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
123
149
  const id=file.slice(0,-13)
@@ -163,7 +189,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
163
189
  if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
164
190
  if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
165
191
  throw new Error('Selected Codex provider is required for this model')
166
- 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')))
167
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,
168
197
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
169
198
  sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
@@ -200,6 +229,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
200
229
  } finally {
201
230
  for(const child of active.values())terminateJob(child)
202
231
  await Promise.allSettled(tasks)
232
+ await catalogRefresh
203
233
  for(const lock of locks)await rm(lock,{force:true})
204
234
  }
205
235
  }
@@ -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
  }
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,7 +102,7 @@ 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
108
  console.log(' pnpm run setup pi')
package/test/ai.test.ts CHANGED
@@ -468,7 +468,8 @@ test('pi does not borrow an unrelated OpenCode catalog', async () => {
468
468
  const catalog = await readModels(undefined, async (cli) => cli === 'pi', undefined as never, runner)
469
469
  assert.deepEqual(catalog, [{ cli: 'pi', name: 'Pi · client default', efforts: [] }])
470
470
  assert.equal(isPreset({ id: 'x', name: 'Pi', cli: 'pi' }), true)
471
- assert.equal(isPreset({ id: 'x', name: 'Unavailable', cli: 'unreal-agent' }), false)
471
+ for (const cli of ['unreal-agent', 'antigravity', 'agy'])
472
+ assert.equal(isPreset({ id: 'x', name: 'Unavailable', cli }), false)
472
473
  await validateSelection({ id: 'choice', name: 'Default', cli: 'pi' }, catalog, async (cli) => cli === 'pi')
473
474
  await assert.rejects(
474
475
  validateSelection(
@@ -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) {
@@ -6,7 +6,7 @@ import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { tmpdir } from 'node:os'
8
8
  import path from 'node:path'
9
- import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
9
+ import { EXECUTOR_REGISTRY, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
10
10
  import { requireOwnerExecution } from '../src/execution-authority.js'
11
11
  import { splitTelegramText } from '../src/reply.js'
12
12
  import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
@@ -97,14 +97,6 @@ test('the Grok invocation is headless, workspace-scoped, and token-free', () =>
97
97
  assert.equal(invocation.args.includes('TELEGRAM_BOT_TOKEN'), false)
98
98
  })
99
99
 
100
- test('the antigravity invocation is headless, skips permissions, and uses print mode', () => {
101
- const invocation = antigravityInvocation('test prompt')
102
- assert.equal(invocation.command, 'agy')
103
- assert.deepEqual(invocation.args, [
104
- '--dangerously-skip-permissions', '--print=test prompt',
105
- ])
106
- })
107
-
108
100
  test('the opencode invocation is headless, auto-approves, and sets model and workspace', () => {
109
101
  const invocation = opencodeInvocation('test prompt', { workspace: '/tmp/test-ws' })
110
102
  assert.equal(invocation.command, 'opencode')
@@ -119,16 +111,15 @@ test('the opencode invocation is headless, auto-approves, and sets model and wor
119
111
  })
120
112
 
121
113
  test('resolveExecutor correctly maps keys and aliases', () => {
122
- assert.equal(resolveExecutor('agy').command, 'agy')
123
- assert.equal(resolveExecutor('antigravity').command, 'agy')
124
114
  assert.equal(resolveExecutor('claude').command, 'claude')
125
115
  assert.equal(resolveExecutor('grok').command, 'grok')
126
116
  assert.equal(resolveExecutor('opencode').command, 'opencode')
127
117
  assert.equal(resolveExecutor('oc').command, 'opencode')
128
- assert.throws(() => resolveExecutor('unreal-agent'), /Unsupported executor CLI/)
118
+ for (const cli of ['unreal-agent', 'antigravity', 'agy'])
119
+ assert.throws(() => resolveExecutor(cli), /Unsupported executor CLI/)
129
120
  assert.equal(resolveExecutor('codex-gui').name, 'codex-gui')
130
121
  assert.notEqual(resolveExecutor('codex-gui').name, resolveExecutor('codex').name)
131
- assert.equal(resolveExecutor(undefined).command, 'agy')
122
+ assert.equal(resolveExecutor(undefined).command, 'codex')
132
123
  assert.throws(() => resolveExecutor('claude-spark'), /Unsupported executor CLI "claude-spark"/)
133
124
  assert.throws(() => resolveExecutor('spark'), /Unsupported executor CLI "spark"/)
134
125
  assert.throws(() => resolveExecutor('nonexistent'), /Unsupported executor CLI "nonexistent"/)
@@ -4,7 +4,7 @@ import test from 'node:test'
4
4
  import { mkdtemp, mkdir, readFile, writeFile, rm, realpath, symlink } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
6
  import { tmpdir } from 'node:os'
7
- import { serveHostExecutor } from '../src/host-executor.js'
7
+ import { serveHostExecutor, refreshAgentCatalog } from '../src/host-executor.js'
8
8
  import { spawn } from 'node:child_process'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { isHostRunId } from '../src/host-executor-protocol.js'
@@ -21,7 +21,7 @@ test('host restart replaces a lock whose PID was reused by another process',asyn
21
21
  try {
22
22
  await mkdir(workspace);await mkdir(directory,{recursive:true})
23
23
  await writeFile(path.join(directory,'worker.lock'),JSON.stringify({pid:process.pid,started:'reused-pid'}))
24
- server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal)
24
+ server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,undefined,async()=>[])
25
25
  for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
26
26
  await readFile(path.join(directory,'heartbeat.json'))
27
27
  const lock=JSON.parse(await readFile(path.join(directory,'worker.lock'),'utf8'))
@@ -72,7 +72,7 @@ test('host restart clears dead native lease only after proving previous CLI stop
72
72
  await assert.rejects(serveHostExecutor(installation,abort.signal),/Previous host CLI is still running/);
73
73
  await readFile(path.join(toolsHome,'workspace-writer.lock'));
74
74
  await writeFile(path.join(directory,'r_old.process.json'),JSON.stringify({pid:deadPid}));
75
- server=serveHostExecutor(installation,abort.signal);
75
+ server=serveHostExecutor(installation,abort.signal,undefined,async()=>[]);
76
76
  for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
77
77
  await readFile(path.join(directory,'heartbeat.json'));
78
78
  await assert.rejects(readFile(path.join(toolsHome,'workspace-writer.lock')),{code:'ENOENT'});
@@ -103,6 +103,7 @@ test('host execution resolves a declared provider from the bound agent, not a la
103
103
  await mkdir(fakeBin, {recursive: true})
104
104
  await mkdir(path.join(controlDir, 'cli', 'codex'), {recursive: true})
105
105
  await writeFile(path.join(fakeBin, 'codex'), '#!/bin/sh\nexit 0\n', {mode: 0o700})
106
+ await writeFile(path.join(fakeBin, 'opencode'), '#!/bin/sh\nexit 0\n', {mode: 0o700})
106
107
  await writeFile(path.join(controlDir, 'cli', 'codex', 'models_cache.json'), JSON.stringify({models: [
107
108
  {slug: provider.models[0], visibility: 'list', display_name: 'DeepSeek V4.1 Flash', supported_reasoning_levels: [{effort: 'max'}]},
108
109
  ]}))
@@ -117,6 +118,9 @@ test('host execution resolves a declared provider from the bound agent, not a la
117
118
  try { await readFile(path.join(directory, 'heartbeat.json')); break }
118
119
  catch { await new Promise(resolve => setTimeout(resolve, 20)) }
119
120
  }
121
+ // The selected model was advertised at startup. A later catalog probe
122
+ // can fail while the client remains able to execute the selected model.
123
+ await rm(path.join(controlDir, 'cli', 'codex', 'models_cache.json'))
120
124
  await writeFile(path.join(directory, 'r_provider.request.json'), JSON.stringify({
121
125
  texts: ['hello'], options: {cli: 'codex', provider: 'openrouter', model: provider.models[0], effort: 'max'},
122
126
  }))
@@ -166,6 +170,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
166
170
  await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID==='r_hold')setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,repair:process.env.EZ_REPAIR_ENABLED,args:process.argv.slice(2)}));\n`,{mode:0o700})
167
171
  await writeFile(path.join(root,'claude'),await readFile(binary),{mode:0o700})
168
172
  await writeFile(path.join(root,'codex'),await readFile(binary),{mode:0o700})
173
+ await writeFile(path.join(root,'opencode'),'#!/bin/sh\nexit 0\n',{mode:0o700})
169
174
  process.env.PATH=root+path.delimiter+oldPath
170
175
  EXECUTOR_REGISTRY.grok.command=binary
171
176
  EXECUTOR_REGISTRY.grok.buildArgs=(opts,file,prompt)=>[...EXECUTOR_REGISTRY.codex.buildArgs(opts,file,prompt).slice(0,-1),prompt]
@@ -241,10 +246,14 @@ test('one installed CLI executes two agent bindings with separate minds and sani
241
246
  switched.stdin.end(JSON.stringify({texts:['Explicit CLI change'],options:executionDefaults('claude',{cli:'claude',timeoutMs:5000,effort:undefined})}))
242
247
  assert.equal(await new Promise(resolve=>switched.once('close',resolve)),0)
243
248
  assert.ok(JSON.parse(switchedOutput).args.includes('--print'))
244
- // The bound cache may advertise a model absent from the host's cache.
249
+ // The host's published catalog can gain a model after its first probe.
245
250
  const codexHome=path.join(agents[0].controlDir,'cli','codex')
246
251
  await mkdir(codexHome,{recursive:true})
247
252
  await writeFile(path.join(codexHome,'models_cache.json'),JSON.stringify({models:[{slug:'agent-only-fixture',visibility:'list',display_name:'Agent model',supported_reasoning_levels:[]}]}))
253
+ const catalogFile=path.join(agents[0].controlDir,'host-executor','models.json')
254
+ const advertised=JSON.parse(await readFile(catalogFile,'utf8'))
255
+ advertised.push({cli:'codex',model:'agent-only-fixture',name:'Agent model',efforts:[]})
256
+ await writeFile(catalogFile,JSON.stringify(advertised))
248
257
  await ownerRun(agents[0].controlDir,'tg_6293307')
249
258
  const bound=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),agents[0].controlDir,'tg_6293307'],{stdio:['pipe','pipe','pipe']})
250
259
  let boundOutput='',boundError=''
@@ -354,9 +363,146 @@ test('client cancels on stale or invalid heartbeat instead of waiting indefinite
354
363
 
355
364
 
356
365
  test('host rejects a plugin registry bound to another workspace',async()=>{
357
- const root=await mkdtemp(path.join(tmpdir(),'ez-host-plugin-binding-'))
358
- try {
359
- await writeFile(path.join(root,'config.json'),JSON.stringify({schemaVersion:1,workspace:'/another-agent'}))
360
- await assert.rejects(serveHostExecutor({cli:'codex',agents:[{name:'test',workspace:root,controlDir:root,binDir:root,toolsHome:root}]},new AbortController().signal),/another workspace/)
361
- } finally {await rm(root,{recursive:true,force:true})}
366
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-plugin-binding-'))
367
+ try {
368
+ await writeFile(path.join(root,'config.json'),JSON.stringify({schemaVersion:1,workspace:'/another-agent'}))
369
+ await assert.rejects(serveHostExecutor({cli:'codex',agents:[{name:'test',workspace:root,controlDir:root,binDir:root,toolsHome:root}]},new AbortController().signal),/another workspace/)
370
+ } finally {await rm(root,{recursive:true,force:true})}
371
+ })
372
+
373
+ test('refresh blip throws so the caller keeps last-good models instead of crashing',async()=>{
374
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-refresh-blip-'))
375
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),directory=path.join(controlDir,'host-executor')
376
+ try {
377
+ await mkdir(workspace,{recursive:true});await mkdir(directory,{recursive:true})
378
+ const agent={name:'test',workspace,controlDir,binDir:root}
379
+ const lastGood=[{cli:'codex',model:'cached-model',name:'Cached',efforts:[]}]
380
+ await writeFile(path.join(directory,'models.json'),JSON.stringify(lastGood))
381
+ const seen=new Map()
382
+ await assert.rejects(refreshAgentCatalog(agent,async()=>{throw new Error('transient catalog blip')},seen),/transient catalog blip/)
383
+ assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),lastGood)
384
+ assert.equal(seen.has(agent),false)
385
+ } finally {await rm(root,{recursive:true,force:true})}
386
+ })
387
+
388
+ test('empty refresh preserves the last-good catalog and a later nonempty refresh replaces it',async()=>{
389
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-empty-refresh-'))
390
+ const directory=path.join(root,'host-executor')
391
+ try {
392
+ await mkdir(directory)
393
+ const agent={name:'test',workspace:root,controlDir:root,binDir:root}
394
+ const lastGood=[{cli:'codex',model:'cached-model',name:'Cached',efforts:[]}]
395
+ const fresh=[{cli:'codex',model:'new-model',name:'New',efforts:[]}]
396
+ await writeFile(path.join(directory,'models.json'),JSON.stringify(lastGood))
397
+ const seen=new Map([[agent,JSON.stringify(lastGood)]])
398
+ await refreshAgentCatalog(agent,async()=>[],seen)
399
+ assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),lastGood)
400
+ assert.equal(seen.get(agent),JSON.stringify(lastGood))
401
+ await refreshAgentCatalog(agent,async()=>fresh,seen)
402
+ assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),fresh)
403
+ assert.equal(seen.get(agent),JSON.stringify(fresh))
404
+ } finally {await rm(root,{recursive:true,force:true})}
405
+ })
406
+
407
+ test('slow catalog refresh keeps heartbeats live and active client completes without cancellation', {timeout:70000}, async()=>{
408
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-slow-refresh-'))
409
+ const directory=path.join(root,'host-executor')
410
+ const abort=new AbortController()
411
+ let server:Promise<void>|undefined
412
+ let client:ReturnType<typeof spawn>|undefined
413
+ let closed:Promise<number|null>|undefined
414
+ let releaseRefresh!:()=>void
415
+ let startedRefresh!:()=>void
416
+ const blocked=new Promise<void>(resolve=>{releaseRefresh=resolve})
417
+ const refreshing=new Promise<void>(resolve=>{startedRefresh=resolve})
418
+ let calls=0
419
+ let finishRun: (()=>void) | undefined
420
+ try {
421
+ await mkdir(directory)
422
+ await ownerRun(root,'r_slow_catalog')
423
+ server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace:root,controlDir:root,binDir:root}]},abort.signal,async()=>{
424
+ const child=spawn(process.execPath,['-e','process.stdin.resume();process.stdin.once("data",()=>process.exit(0))'])
425
+ finishRun=()=>{child.stdin!.end('done')}
426
+ return {child,cleanup:async()=>{},stdout:''}
427
+ },async()=>{
428
+ if(++calls>1){startedRefresh();await blocked}
429
+ return [{cli:'grok',name:'Grok',efforts:[]}]
430
+ })
431
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
432
+ client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),root,'r_slow_catalog'],{stdio:['pipe','pipe','pipe']})
433
+ closed=new Promise(resolve=>client!.once('close',resolve))
434
+ let stderr='';client.stderr!.on('data',chunk=>stderr+=chunk);client.stdout!.resume()
435
+ client.stdin!.end(JSON.stringify({texts:['test'],options:{cli:'grok'}}))
436
+ await refreshing
437
+ // Exceed the real relay client's 15s offline threshold while discovery hangs.
438
+ await new Promise(resolve=>setTimeout(resolve,16500))
439
+ const heartbeat=JSON.parse(await readFile(path.join(directory,'heartbeat.json'),'utf8'))
440
+ assert.ok(Date.now()-heartbeat.at<5000,'heartbeat stopped during catalog I/O')
441
+ assert.equal(client.exitCode,null,stderr)
442
+ await assert.rejects(readFile(path.join(directory,'r_slow_catalog.cancel')),{code:'ENOENT'})
443
+ assert.equal(calls,2,'catalog refresh must not overlap itself')
444
+ assert.ok(finishRun,'the native run must have started')
445
+ finishRun()
446
+ assert.equal(await closed,0,stderr)
447
+ releaseRefresh()
448
+ } finally {
449
+ releaseRefresh();abort.abort()
450
+ if(client?.exitCode===null && client.signalCode===null)client.kill()
451
+ await closed;await server;await rm(root,{recursive:true,force:true})
452
+ }
453
+ })
454
+
455
+ test('cross-CLI admission uses the advertised catalog without another discovery probe',async()=>{
456
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-advertised-'))
457
+ const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),directory=path.join(controlDir,'host-executor')
458
+ const abort=new AbortController();let server:Promise<void>|undefined
459
+ const previousPath=process.env.PATH
460
+ try {
461
+ await mkdir(workspace,{recursive:true});await mkdir(directory,{recursive:true})
462
+ const fakeBin=path.join(root,'bin');await mkdir(fakeBin,{recursive:true})
463
+ await writeFile(path.join(fakeBin,'codex'),'#!/bin/sh\nexit 0\n',{mode:0o700})
464
+ process.env.PATH=`${fakeBin}${path.delimiter}${previousPath ?? ''}`
465
+ const installation={cli:'grok' as const,agents:[{name:'test',workspace,controlDir,binDir:fakeBin}]}
466
+ const advertised=[{cli:'codex',model:'cached-model',name:'Cached',efforts:[]}]
467
+ let reads=0
468
+ server=serveHostExecutor(installation,abort.signal,async()=>{
469
+ const child=spawn(process.execPath,['-e','process.exit(0)'])
470
+ return {child,cleanup:async()=>{},stdout:''}
471
+ },async()=>{if(++reads>1)throw new Error('unexpected discovery during admission');return advertised})
472
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
473
+ await readFile(path.join(directory,'heartbeat.json'))
474
+ await ownerRun(controlDir,'r_advertised')
475
+ await writeFile(path.join(directory,'r_advertised.request.json'),JSON.stringify({
476
+ texts:['hello'],options:{cli:'codex',model:'cached-model'},
477
+ }))
478
+ let events=''
479
+ for(let n=0;n<200;n++){
480
+ try{events=await readFile(path.join(directory,'r_advertised.events'),'utf8');if(events.includes('"stream":"exit"'))break}catch{}
481
+ await new Promise(r=>setTimeout(r,20))
482
+ }
483
+ assert.match(events,/"stream":"exit","code":0/)
484
+ assert.equal(reads,1)
485
+ } finally {
486
+ abort.abort();await server
487
+ if(previousPath===undefined)delete process.env.PATH;else process.env.PATH=previousPath
488
+ await rm(root,{recursive:true,force:true})
489
+ }
490
+ })
491
+
492
+ test('startup replaces prior catalog after provider policy changes even when discovery is empty',async()=>{
493
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-policy-reset-'))
494
+ const directory=path.join(root,'host-executor')
495
+ const abort=new AbortController();let server:Promise<void>|undefined
496
+ try {
497
+ await mkdir(directory)
498
+ await writeFile(path.join(directory,'models.json'),JSON.stringify([
499
+ {cli:'opencode',model:'old-provider/model',name:'Old provider',efforts:[]},
500
+ ]))
501
+ server=serveHostExecutor({cli:'grok',agents:[{
502
+ name:'test',workspace:root,controlDir:root,binDir:root,opencodeProviders:['allowed-provider'],
503
+ }]},abort.signal,undefined,async()=>[])
504
+ for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
505
+ await readFile(path.join(directory,'heartbeat.json'))
506
+ assert.deepEqual(JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')),[])
507
+ } finally {abort.abort();await server;await rm(root,{recursive:true,force:true})}
362
508
  })
@@ -10,6 +10,9 @@ import { serveTestLedger } from './helpers/ledger.js'
10
10
  import { deliveredMessages } from '../src/message-history.js'
11
11
  import { RunStore } from '../src/runs.js'
12
12
  import { ControlStore } from '../src/control-state.js'
13
+ import { ownerId, ownerEpoch } from '../src/control-state.js'
14
+ import { ApplicationBindings } from '../src/application-channel.js'
15
+ import { randomBytes } from 'node:crypto'
13
16
 
14
17
  async function fixture(t: test.TestContext) {
15
18
  const dir = await mkdtemp(join(tmpdir(), 'ez-history-'))
@@ -117,3 +120,47 @@ test('CLI flushes complete long reports and rejects send options in history mode
117
120
  // History reads never create outbox state on disk.
118
121
  await assert.rejects(stat(join(dir, 'outbox')))
119
122
  })
123
+
124
+ test('application history reads only delivered literal text in its current binding and scope', async t => {
125
+ const { dir, runs } = await fixture(t)
126
+ const owner = (await new ControlStore(dir, 1000).status()).owner!
127
+ const bindings = new ApplicationBindings(dir)
128
+ const first = (await bindings.register('first', randomBytes(32).toString('base64url'), owner))!
129
+ const second = (await bindings.register('second', randomBytes(32).toString('base64url'), owner))!
130
+ const create = async (id: string, bindingId: string, scope: string) => {
131
+ const run = await runs.create({ id, ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner),
132
+ texts: ['test'], application: { bindingId, scope, requestId: id } })
133
+ await runs.patch(id, { status: 'running' })
134
+ return run
135
+ }
136
+ const earlier = await create('r_app_earlier', first.bindingId, 'book')
137
+ const current = await create('r_app_current', first.bindingId, 'book')
138
+ const otherScope = await create('r_app_other_scope', first.bindingId, 'private')
139
+ const otherBinding = await create('r_app_other_binding', second.bindingId, 'book')
140
+ const deliver = async (runId: string, text: string) => {
141
+ const item = await runs.enqueueMessage(runId, text)
142
+ await runs.claimOutbox(item.id)
143
+ await runs.markOutboxSent(item.id)
144
+ return item
145
+ }
146
+ const literal = 'Book is $1,234; path C:\\reports\\$raw'
147
+ const firstItem = await deliver(earlier.id, literal)
148
+ await runs.patch(earlier.id, { status: 'completed' })
149
+ await deliver(otherScope.id, 'other scope secret')
150
+ await deliver(otherBinding.id, 'other binding secret')
151
+ await deliver('tg_1', 'Telegram secret')
152
+ await runs.enqueueMessage(current.id, 'queued is not delivered')
153
+ const env = { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: current.id }
154
+ const { stdout } = await promisify(execFile)(process.execPath, ['bin/ezenciel-agents-message.mjs', 'history'], { env })
155
+ const result = JSON.parse(stdout)
156
+ assert.equal(result.scope, 'book')
157
+ assert.deepEqual(result.messages.map((message: { id: string; text: string }) => ({ id: message.id, text: message.text })),
158
+ [{ id: firstItem.id, text: literal }])
159
+ assert.equal(result.hasMore, false)
160
+ await assert.rejects(promisify(execFile)(process.execPath,
161
+ ['bin/ezenciel-agents-message.mjs', 'history', '--message-id', '1'], { env }), /Telegram history/)
162
+ await bindings.register('first', null, owner)
163
+ await assert.rejects(deliveredMessages(dir, current.id), /revoked/)
164
+ await new ControlStore(dir, 1000).revokeOwner()
165
+ await assert.rejects(deliveredMessages(dir, otherBinding.id), /owner-mismatch|revoked/)
166
+ })
@@ -12,7 +12,7 @@ import { runCodexSession } from '../src/codex-session.js'
12
12
  import { runDesktopTurn } from '../src/desktop-bridge.js'
13
13
 
14
14
  test('engine defaults stay omitted and explicit native settings survive every adapter', async () => {
15
- for(const cli of ['codex','codex-gui','grok','claude','opencode','agy']) {
15
+ for(const cli of ['codex','codex-gui','grok','claude','opencode']) {
16
16
  assert.deepEqual(executionDefaults(cli,{}),{})
17
17
  const preset={id:'chosen',name:'Chosen',cli,model:'native-model',effort:'ultra'}
18
18
  assert.deepEqual(executionDefaults(cli,preset),preset)
@@ -41,7 +41,7 @@ test('invalid setting syntax never mutates a saved choice', async () => {
41
41
  test('non-Codex catalog defaults survive executor normalization and host revalidation', async () => {
42
42
  const dir = await mkdtemp(join(tmpdir(), 'ez-adapter-defaults-'))
43
43
  try {
44
- const available = async (cli: string) => ['claude', 'opencode', 'agy'].includes(cli)
44
+ const available = async (cli: string) => ['claude', 'opencode'].includes(cli)
45
45
  const catalog = await readModels(dir, available)
46
46
  for (const choice of catalog) {
47
47
  const preset = {id:'selected', name:choice.name, cli:choice.cli, model:choice.model}
@@ -13,11 +13,11 @@ test('findExecutableInPath locates existing binary and returns null for nonexist
13
13
  assert.equal(missing, null)
14
14
  })
15
15
 
16
- test('readActiveExecutor extracts EZ_EXECUTOR_CLI or defaults to agy', async () => {
16
+ test('readActiveExecutor extracts EZ_EXECUTOR_CLI or defaults to codex', async () => {
17
17
  const dir = await mkdtemp(path.join(tmpdir(), 'ez-setup-test-'))
18
18
  try {
19
19
  const envPath = path.join(dir, '.env')
20
- assert.equal(await readActiveExecutor(envPath), 'agy')
20
+ assert.equal(await readActiveExecutor(envPath), 'codex')
21
21
 
22
22
  await writeFile(envPath, 'EZ_EXECUTOR_CLI=claude\n', 'utf8')
23
23
  assert.equal(await readActiveExecutor(envPath), 'claude')
@@ -37,10 +37,10 @@ test('setExecutorInEnv updates or adds EZ_EXECUTOR_CLI without altering other en
37
37
  assert.match(content, /TELEGRAM_BOT_TOKEN=token123/)
38
38
  assert.match(content, /EZ_EXECUTOR_CLI=claude/)
39
39
 
40
- await setExecutorInEnv(envPath, 'antigravity')
40
+ await setExecutorInEnv(envPath, 'codex')
41
41
  content = await readFile(envPath, 'utf8')
42
42
  assert.match(content, /TELEGRAM_BOT_TOKEN=token123/)
43
- assert.match(content, /EZ_EXECUTOR_CLI=antigravity/)
43
+ assert.match(content, /EZ_EXECUTOR_CLI=codex/)
44
44
  assert.ok(!content.includes('claude'))
45
45
  } finally {
46
46
  await rm(dir, { recursive: true, force: true })
@@ -59,9 +59,9 @@ test('getExecutorsStatus returns all executors and sets isActive correctly', asy
59
59
  assert.ok(claudeItem)
60
60
  assert.equal(claudeItem.isActive, true)
61
61
 
62
- const agyItem = status.items.find((item) => item.id === 'agy')
63
- assert.ok(agyItem)
64
- assert.equal(agyItem.isActive, false)
62
+ const codexItem = status.items.find((item) => item.id === 'codex')
63
+ assert.ok(codexItem)
64
+ assert.equal(codexItem.isActive, false)
65
65
  } finally {
66
66
  await rm(dir, { recursive: true, force: true })
67
67
  }