@jc_stack/ez-agents 0.1.0-beta.37 → 0.1.0-beta.39

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,12 +13,14 @@ 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=
22
+ # EZ_SPEECH_VOICE_EN=Kore
23
+ # EZ_SPEECH_VOICE_ES=Kore
22
24
  # OPENAI_API_KEY=
23
25
 
24
26
  # Optional: OpenRouter API key for OpenCode CLI (Nemotron 3.5 Lightning)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.39
4
+
5
+ - Render completed, binding-scoped application replies as WAV speech with
6
+ configurable English and Spanish voices, while retaining Ogg/Opus for Telegram.
7
+ Recheck the original application binding after synthesis and report depleted
8
+ speech credits without provider details.
9
+ - Preserve live delivery sockets during upgrade backups and restart Linux host
10
+ services after a successful core replacement.
11
+
12
+ ## 0.1.0-beta.38
13
+
14
+ - Remove the retired Antigravity CLI integration and its unused engine paths.
15
+ - Add scoped application delivery history readback so an authorized application
16
+ can retrieve its own messages and delivery receipts.
17
+ - Validate host execution against its advertised model catalog and keep host
18
+ heartbeats responsive during background catalog refresh.
19
+
3
20
  ## 0.1.0-beta.37
4
21
 
5
22
  - 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.
@@ -10,6 +10,24 @@ Run admission, status and inbox receipts include the immutable `preset` captured
10
10
  for that run so an application can label delivered messages with the engine choice
11
11
  that actually produced them.
12
12
 
13
+ ## Speech playback
14
+
15
+ `POST /v1/runs/<id>/speech` renders the last delivered text reply of a completed
16
+ application run as `audio/wav` (24 kHz mono). It uses the same binding/owner
17
+ authorization as run readback, rechecked after synthesis, and accepts no client
18
+ text or provider credentials. Replies are limited to 8,000 characters. An
19
+ unfinished run returns 409, missing speech configuration 503, and provider
20
+ failure 502. Depleted Gemini credits return 503 with
21
+ `code: speech_credits_depleted`. Audio responses have `Cache-Control: no-store`.
22
+
23
+ Configure `GEMINI_API_KEY` on the relay. The existing speech renderer uses
24
+ `gemini-3.8-flash-lite-tts`; Telegram still receives Ogg Opus. Applications should
25
+ send `{"language":"en"}` or `{"language":"es"}` to select
26
+ `EZ_SPEECH_VOICE_EN` or `EZ_SPEECH_VOICE_ES` (both default to Kore). Telegram
27
+ voice notes use the English voice setting. Applications should retain the returned
28
+ audio for local replay; each POST generates fresh audio.
29
+ Speech is a rendering operation and does not start another agent turn.
30
+
13
31
  ## Shared runtime controls
14
32
 
15
33
  Register with `--share-owner` (the existing `--share-telegram` spelling is an
@@ -131,6 +149,14 @@ not that a human read it. Failure adds a generic `error`; detailed logs remain
131
149
  local to core. Cancellation uses the existing child termination boundary and may
132
150
  return `running` until the process exits.
133
151
 
152
+ Inside an active native application run, `ezenciel-agents-message history
153
+ --limit 8` reads delivered text from this same inbox for the run's immutable
154
+ application binding and scope, including earlier runs in that scope. It cannot
155
+ read another binding, scope, or Telegram chat. The Telegram numeric
156
+ `--message-id` filter is unavailable for application runs. This reads the same
157
+ bounded relay delivery state as the application inbox; both disappear on relay
158
+ restart and add no persistent message record.
159
+
134
160
  `requestId` and `scope` accept 1–200 ASCII letters, digits, `_:.-`. Text is at most
135
161
  16,000 characters. Context is an optional JSON object (48 KiB); the complete HTTP
136
162
  body is bounded to 64 KiB. Unknown input fields are rejected. Use a stable job ID:
@@ -155,8 +181,7 @@ application must not infer or auto-submit consent.
155
181
 
156
182
  Each application binding plus scope gets an existing core session. Its first run
157
183
  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.
184
+ Application sessions are excluded from the Telegram session selector.
160
185
 
161
186
  Before cutting over an existing application, stop its old runner, copy its native
162
187
  transcripts into the ordinary engine's private state, and import each authoritative
@@ -24,7 +24,7 @@ Environment=EZ_DEPLOYMENT_DIR=/absolute/private/agents/family
24
24
  Environment=PATH=/absolute/node/bin:/absolute/package-manager/bin:/absolute/cli/bin:/usr/local/bin:/usr/bin:/bin
25
25
  EnvironmentFile=-%h/.config/ez/family-provider.env
26
26
  ExecStart=/absolute/ez-package/package/bin/ezenciel-agents-host
27
- Restart=on-failure
27
+ Restart=always
28
28
  RestartSec=5
29
29
 
30
30
  [Install]
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.39",
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.')
@@ -11,6 +11,7 @@ import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
11
11
  import { assertEffort } from './model-policy.js'
12
12
  import { ApprovalStore } from './approval.js'
13
13
  import { Tasks } from './tasks.js'
14
+ import { SpeechCreditsDepletedError } from './audio.js'
14
15
 
15
16
  const hash = (value: string) => createHash('sha256').update(value).digest('hex')
16
17
  export const applicationScope = (bindingId: string, scope: string) => hash(JSON.stringify([bindingId, scope]))
@@ -74,6 +75,7 @@ export class ApplicationChannel {
74
75
  controlDir: string; workspace?: string; initial: AiPreset
75
76
  wake: () => void
76
77
  cancel: (id: string) => Promise<void>
78
+ speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
77
79
  createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
78
80
  telegramAvailable?: () => boolean
79
81
  aiControls?: {
@@ -206,7 +208,7 @@ export class ApplicationChannel {
206
208
  }
207
209
  if (value.action==='model') {
208
210
  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')
211
+ if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
210
212
  preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
211
213
  }
212
214
  await control.changeApplicationSession(hashedScope,guard,preset)
@@ -385,6 +387,31 @@ export class ApplicationChannel {
385
387
  const admitted = await this.runs.attachTelegramApplication(target.id, application)
386
388
  send(200, { id: admitted.id, scope, status: admitted.status }); return
387
389
  }
390
+ const speechMatch = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})\/speech$/)
391
+ if (speechMatch && request.method === 'POST') {
392
+ const chunks: Buffer[] = []; let size = 0
393
+ for await (const chunk of request) { size += chunk.length; if (size > 128) throw new Error('Invalid speech request'); chunks.push(chunk) }
394
+ const input = Buffer.concat(chunks).toString('utf8').trim()
395
+ const language = input ? (JSON.parse(input) as { language?: unknown }).language : 'en'
396
+ if (language !== 'en' && language !== 'es') { send(400, { error: 'Invalid speech language' }); return }
397
+ const snapshot = await this.snapshot(binding.bindingId, speechMatch[1])
398
+ if (snapshot.status !== 'completed') { send(409, { error: 'Speech requires a completed reply' }); return }
399
+ const text = snapshot.messages.at(-1)?.text?.trim()
400
+ if (!text || text.length > 8000) { send(400, { error: 'Speech requires a reply of 1–8000 characters' }); return }
401
+ if (!this.options.speech) { send(503, { error: 'Speech is not configured' }); return }
402
+ let audio: { buffer: Buffer; mimeType: string }
403
+ try { audio = await this.options.speech(text, language) }
404
+ catch (error) {
405
+ if (error instanceof SpeechCreditsDepletedError) { send(503, { error: 'Speech credits are depleted', code: 'speech_credits_depleted' }); return }
406
+ send(502, { error: 'Speech generation failed' }); return
407
+ }
408
+ // A binding can be revoked while the provider is generating audio.
409
+ const currentBinding = await this.bindings.authenticate(request.headers.authorization!.slice(7))
410
+ if (currentBinding.bindingId !== binding.bindingId) throw new Error('Application authority revoked')
411
+ await this.snapshot(binding.bindingId, speechMatch[1])
412
+ response.writeHead(200, { 'content-type': audio.mimeType, 'cache-control': 'no-store' })
413
+ response.end(audio.buffer); return
414
+ }
388
415
  const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
389
416
  if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
390
417
  await this.snapshot(binding.bindingId, match[1])
package/src/audio.ts CHANGED
@@ -7,6 +7,10 @@ export type AudioOptions = {
7
7
  voice?: string
8
8
  }
9
9
 
10
+ export class SpeechCreditsDepletedError extends Error {
11
+ constructor() { super('Gemini speech credits are depleted') }
12
+ }
13
+
10
14
  export const pcmToWav = (pcm: Buffer, sampleRate = 24000, channels = 1): Buffer => {
11
15
  const header = Buffer.alloc(44)
12
16
  const byteRate = sampleRate * channels * 2
@@ -135,7 +139,7 @@ export const transcribeAudio = async (
135
139
 
136
140
  export const synthesizeSpeech = async (
137
141
  text: string,
138
- options: AudioOptions = {},
142
+ options: AudioOptions & { format?: 'wav' | 'ogg' } = {},
139
143
  ): Promise<{ buffer: Buffer; mimeType: string }> => {
140
144
  const trimmed = text.trim()
141
145
  if (!trimmed) throw new Error('Cannot synthesize speech for empty text')
@@ -143,7 +147,7 @@ export const synthesizeSpeech = async (
143
147
  const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
144
148
  if (geminiKey) {
145
149
  const url =
146
- 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent'
150
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash-lite-tts:generateContent'
147
151
  const voiceName = options.voice || 'Kore'
148
152
  const response = await fetch(url, {
149
153
  method: 'POST',
@@ -153,14 +157,15 @@ export const synthesizeSpeech = async (
153
157
  contents: [{ parts: [{ text: trimmed }] }],
154
158
  generationConfig: {
155
159
  responseModalities: ['AUDIO'],
156
- speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName } } },
160
+ responseFormat: { audio: { mimeType: 'AUDIO_L16', sampleRate: 24000 } },
161
+ speechConfig: { voiceConfig: { voice: voiceName } },
157
162
  },
158
163
  }),
159
164
  })
160
165
 
161
166
  if (!response.ok) {
162
- const err = await response.text().catch(() => '')
163
- throw new Error(`Gemini speech synthesis error ${response.status}: ${err}`)
167
+ if (response.status === 402) throw new SpeechCreditsDepletedError()
168
+ throw new Error(`Gemini speech synthesis error ${response.status}`)
164
169
  }
165
170
 
166
171
  const payload = (await response.json()) as any
@@ -173,6 +178,9 @@ export const synthesizeSpeech = async (
173
178
  throw new Error(`Gemini returned no audio data (${candidate?.finishReason || 'no candidate'})`)
174
179
 
175
180
  const rawPcm = Buffer.from(inline.data, 'base64')
181
+ if (!/^audio\/l16(?:;|$)/i.test(inline.mimeType) || !rawPcm.length || rawPcm.length % 2)
182
+ throw new Error('Gemini returned an unsupported speech format')
183
+ if (options.format === 'wav') return { buffer: pcmToWav(rawPcm), mimeType: 'audio/wav' }
176
184
  const encoded = await encodeOggOpus(rawPcm, 24000, 1)
177
185
  return {
178
186
  buffer: encoded,
package/src/config.ts CHANGED
@@ -25,6 +25,8 @@ export type Config = ControlConfig & {
25
25
  applicationPort?: number
26
26
  applicationHost?: string
27
27
  geminiApiKey?: string
28
+ speechVoiceEn?: string
29
+ speechVoiceEs?: string
28
30
  openaiApiKey?: string
29
31
  }
30
32
 
@@ -78,12 +80,14 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
78
80
  isolation,
79
81
  codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
80
82
  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',
83
+ executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'codex',
82
84
  applicationPort: env.EZ_APPLICATION_PORT ? positiveInteger(env.EZ_APPLICATION_PORT, 'EZ_APPLICATION_PORT') : undefined,
83
85
  applicationHost: env.EZ_APPLICATION_HOST?.trim() || '127.0.0.1',
84
86
  channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
85
87
  channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
86
88
  geminiApiKey: env.GEMINI_API_KEY?.trim(),
89
+ speechVoiceEn: env.EZ_SPEECH_VOICE_EN?.trim(),
90
+ speechVoiceEs: env.EZ_SPEECH_VOICE_ES?.trim(),
87
91
  openaiApiKey: env.OPENAI_API_KEY?.trim(),
88
92
  }
89
93
  }
@@ -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
  }
package/src/index.ts CHANGED
@@ -103,6 +103,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
103
103
  const applicationChannel = new ApplicationChannel({
104
104
  controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
105
105
  aiControls: aiMenu,
106
+ speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
107
+ geminiApiKey: config.geminiApiKey,
108
+ voice: language === 'es' ? config.speechVoiceEs : config.speechVoiceEn,
109
+ format: 'wav',
110
+ }) : undefined,
106
111
  wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
107
112
  createTelegramPairing: async (bindingId, owner) => {
108
113
  const username = bot?.botInfo?.username
@@ -581,6 +586,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
581
586
  const { buffer } = await synthesizeSpeech(item.voiceText, {
582
587
  geminiApiKey: config.geminiApiKey,
583
588
  openaiApiKey: config.openaiApiKey,
589
+ voice: config.speechVoiceEn,
584
590
  })
585
591
  await paceSend()
586
592
  await authorizeChannelDelivery()
@@ -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')
@@ -31,6 +31,9 @@ export function execute(command,args,options={}) {
31
31
  export async function textAtomic(file,text) {
32
32
  const tmp=file+'.update.tmp';await fs.writeFile(tmp,text,{mode:0o600});await fs.rename(tmp,file);
33
33
  }
34
+ export async function backupStateDirectory(source,destination) {
35
+ await fs.cp(source,destination,{recursive:true,filter:async entry=>!(await fs.lstat(entry)).isSocket()});
36
+ }
34
37
  export const pluginArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
35
38
  export async function packageManager(root,run=execute) {
36
39
  const {packageManager:required}=await read(path.join(root,'package.json'));
@@ -116,7 +119,7 @@ export async function perform(home,job,hooks) {
116
119
  job.rollback={env:envValue(oldEnv,'EZ_RELAY_IMAGE',oldImage),packageRoot:config.packageRoot};await save();
117
120
  await run('docker',[...relayArgs(config),'stop','relay']);await hooks.stopHost();
118
121
  const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
119
- for(const name of ['mind','control'])await fs.cp(path.join(config.deploymentDir,name),path.join(backup,name),{recursive:true});
122
+ for(const name of ['mind','control'])await backupStateDirectory(path.join(config.deploymentDir,name),path.join(backup,name));
120
123
  for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
121
124
  let env=oldEnv.split(job.previousRoot+path.sep).join(root+path.sep);env=envValue(env,'EZ_RELAY_IMAGE',image);
122
125
  await textAtomic(envFile,env);
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(
@@ -100,6 +100,58 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
100
100
  assert.equal((await request('/v1/runs',secondToken,input)).status,401)
101
101
  })
102
102
 
103
+ test('speech renders only a completed bound reply and rechecks authority after generation', async t => {
104
+ const root = await mkdtemp(join(tmpdir(), 'ez-app-speech-'))
105
+ const owned = await owner(root), runs = new RunStore(root)
106
+ const firstToken = token(), secondToken = token()
107
+ const spoken: string[] = []
108
+ let revoke = false, reassign = false
109
+ const channel = new ApplicationChannel({
110
+ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {},
111
+ speech: async text => {
112
+ spoken.push(text)
113
+ if (revoke) await channel.bindings.register('first', null, owned)
114
+ if (reassign) {
115
+ await channel.bindings.register('first', token(), owned, false, true)
116
+ await channel.bindings.register('second', firstToken, owned, false, true)
117
+ }
118
+ return { buffer: Buffer.from('audio-fixture'), mimeType: 'audio/wav' }
119
+ },
120
+ })
121
+ t.after(async () => { await channel.stop(); await rm(root, { recursive: true, force: true }) })
122
+ const first = (await channel.bindings.register('first', firstToken, owned))!
123
+ await channel.bindings.register('second', secondToken, owned)
124
+ const run = await channel.submit(first.bindingId, { requestId: 'speech', scope: 'exercise', text: 'Explain this' })
125
+ const address = await channel.listen(0) as { port: number }
126
+ const request = (bearer: string) => fetch(`http://127.0.0.1:${address.port}/v1/runs/${run.id}/speech`, {
127
+ method: 'POST', headers: { Authorization: `Bearer ${bearer}` },
128
+ })
129
+ assert.equal((await request(secondToken)).status, 404)
130
+ assert.equal((await request(firstToken)).status, 409)
131
+ assert.deepEqual(spoken, [])
132
+ for (const text of ['Progress', 'Keep the movement controlled.']) {
133
+ const item = await runs.enqueueMessage(run.id, text)
134
+ await runs.claimOutbox(item.id)
135
+ await channel.deliver(run, item)
136
+ }
137
+ await runs.patch(run.id, { status: 'completed' })
138
+ const audio = await request(firstToken)
139
+ assert.equal(audio.status, 200)
140
+ assert.equal(audio.headers.get('content-type'), 'audio/wav')
141
+ assert.equal(audio.headers.get('cache-control'), 'no-store')
142
+ assert.equal(await audio.text(), 'audio-fixture')
143
+ assert.deepEqual(spoken, ['Keep the movement controlled.'])
144
+ reassign = true
145
+ const beforeReassign = spoken.length
146
+ assert.notEqual((await request(firstToken)).status, 200)
147
+ assert.equal(spoken.length, beforeReassign + 1)
148
+ reassign = false
149
+ await channel.bindings.register('second', secondToken, owned, false, true)
150
+ await channel.bindings.register('first', firstToken, owned, false, true)
151
+ revoke = true
152
+ assert.notEqual((await request(firstToken)).status, 200)
153
+ })
154
+
103
155
  test('owner replacement invalidates binding and client secrets never enter native environment',async t=>{
104
156
  const root=await mkdtemp(join(tmpdir(),'ez-app-owner-'))
105
157
  t.after(()=>rm(root,{recursive:true,force:true}))
@@ -1,6 +1,11 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { pcmToWav, encodeOggOpus, transcribeAudio, synthesizeSpeech } from '../src/audio.js'
3
+ import { pcmToWav, encodeOggOpus, transcribeAudio, synthesizeSpeech, SpeechCreditsDepletedError } from '../src/audio.js'
4
+
5
+ test('speech reports depleted credits without exposing provider details', async (t) => {
6
+ t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 402 }))
7
+ await assert.rejects(synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key' }), SpeechCreditsDepletedError)
8
+ })
4
9
 
5
10
  test('speech provider failure stays a failure, never a text or WAV success', async (t) => {
6
11
  t.mock.method(
@@ -13,9 +18,15 @@ test('speech provider failure stays a failure, never a text or WAV success', asy
13
18
  })
14
19
 
15
20
  test('speech finds audio beyond the first response part and keeps credentials out of URLs', async (t) => {
21
+ let calls = 0
16
22
  t.mock.method(globalThis, 'fetch', async (url: string, options: RequestInit) => {
23
+ calls += 1
24
+ assert.match(url, /gemini-3\.8-flash-lite-tts:generateContent$/)
17
25
  assert.equal(url.includes('fixture-key'), false)
18
26
  assert.equal(new Headers(options.headers).get('x-goog-api-key'), 'fixture-key')
27
+ const config = JSON.parse(String(options.body)).generationConfig
28
+ assert.deepEqual(config.responseFormat, { audio: { mimeType: 'AUDIO_L16', sampleRate: 24000 } })
29
+ assert.deepEqual(config.speechConfig.voiceConfig, { voice: calls === 3 ? 'voice_r3yhwvxvihxg' : 'Kore' })
19
30
  return new Response(
20
31
  JSON.stringify({
21
32
  candidates: [
@@ -39,6 +50,11 @@ test('speech finds audio beyond the first response part and keeps credentials ou
39
50
  const speech = await synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key' })
40
51
  assert.equal(speech.mimeType, 'audio/ogg')
41
52
  assert.equal(speech.buffer.subarray(0, 4).toString(), 'OggS')
53
+ const wav = await synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key', format: 'wav' })
54
+ assert.equal(wav.mimeType, 'audio/wav')
55
+ assert.equal(wav.buffer.subarray(0, 4).toString(), 'RIFF')
56
+ assert.equal(wav.buffer.length, 24044)
57
+ await synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key', voice: 'voice_r3yhwvxvihxg', format: 'wav' })
42
58
  })
43
59
 
44
60
  test('pcmToWav generates a valid 44-byte WAV header', () => {
@@ -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
  }
@@ -5,16 +5,30 @@ import path from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { gzipSync } from 'node:zlib';
7
7
  import { spawn, execFile } from 'node:child_process';
8
+ import { createServer } from 'node:net';
8
9
  import { promisify } from 'node:util';
9
10
  import { extract, digest, version, newer, compatible } from '../src/updates/artifact.mjs';
10
11
  import { prepare, submit, command, read, jobPath, eligibility, jobs, cleanupStaleBackups, reviewedPluginDeploymentMigration } from '../src/updates/control.mjs';
11
- import { perform, environment, packageManager } from '../src/updates/runtime.mjs';
12
+ import { perform, environment, packageManager, backupStateDirectory } from '../src/updates/runtime.mjs';
12
13
  import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
13
14
  import { bindUpdates } from '../src/updates/binding.mjs';
14
15
  import { status as runtimeStatus } from '../src/updates/status.mjs';
15
16
  import { providerEnvironment } from '../src/updates/supervisor.mjs';
16
17
  const exec=promisify(execFile);
17
18
 
19
+ test('main upgrade backup omits a live plugin socket and keeps private files',async t=>{
20
+ const root=await fs.mkdtemp('/tmp/ez-upgrade-socket-');
21
+ t.after(()=>fs.rm(root,{recursive:true,force:true}));
22
+ const source=path.join(root,'control'),destination=path.join(root,'backup');
23
+ await fs.mkdir(source);await fs.writeFile(path.join(source,'state.json'),'private state');
24
+ const socket=path.join(source,'plugin-broker.sock'),server=createServer();
25
+ await new Promise((resolve,reject)=>server.once('error',reject).listen(socket,resolve));
26
+ t.after(()=>new Promise(resolve=>server.close(resolve)));
27
+ await backupStateDirectory(source,destination);
28
+ assert.equal(await fs.readFile(path.join(destination,'state.json'),'utf8'),'private state');
29
+ assert.deepEqual(await fs.readdir(destination),['state.json']);
30
+ });
31
+
18
32
  test('supervisor forwards only provider keys declared by the agent installation',()=>{
19
33
  const host={agents:[{codexProviders:[{envKey:'OPENROUTER_API_KEY'}]}]};
20
34
  assert.deepEqual(providerEnvironment(host,{OPENROUTER_API_KEY:'provider-secret',TELEGRAM_BOT_TOKEN:'relay-secret'}),{OPENROUTER_API_KEY:'provider-secret'});