@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 +2 -2
- package/CHANGELOG.md +8 -0
- package/README.md +0 -1
- package/docs/application-channel.md +9 -2
- package/docs/upgrades.md +1 -1
- package/package.json +1 -1
- package/src/ai.ts +3 -4
- package/src/application-channel.ts +1 -1
- package/src/config.ts +1 -1
- package/src/control-state.ts +1 -4
- package/src/executor.ts +1 -21
- package/src/host-executor.ts +46 -16
- package/src/message-history.ts +19 -1
- package/src/message.ts +2 -2
- package/src/setup.ts +2 -2
- package/test/ai.test.ts +2 -1
- package/test/config.test.ts +2 -2
- package/test/conversations.test.ts +2 -8
- package/test/engine-handoff.test.ts +3 -4
- package/test/executor.test.ts +4 -13
- package/test/host-executor.test.ts +155 -9
- package/test/message-history.test.ts +47 -0
- package/test/model-policy.test.ts +2 -2
- package/test/setup.test.ts +7 -7
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:
|
|
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=
|
|
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.
|
|
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,
|
|
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.
|
|
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', '
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
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() || '
|
|
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(),
|
package/src/control-state.ts
CHANGED
|
@@ -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)
|
|
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 ?? '
|
|
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: '' },
|
package/src/host-executor.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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/message-history.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
-
console.log(
|
|
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() || '
|
|
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
|
|
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
|
-
|
|
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(
|
package/test/config.test.ts
CHANGED
|
@@ -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
|
|
26
|
+
test('loads executor CLI configuration with codex fallback', () => {
|
|
27
27
|
const defaultConfig = loadConfig({ TELEGRAM_BOT_TOKEN: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsY' })
|
|
28
|
-
assert.equal(defaultConfig.executorCli, '
|
|
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
|
|
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
|
-
|
|
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.
|
|
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','
|
|
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','
|
|
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) {
|
package/test/executor.test.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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, '
|
|
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
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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'
|
|
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'
|
|
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}
|
package/test/setup.test.ts
CHANGED
|
@@ -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
|
|
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), '
|
|
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, '
|
|
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=
|
|
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
|
|
63
|
-
assert.ok(
|
|
64
|
-
assert.equal(
|
|
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
|
}
|