@jc_stack/ez-agents 0.1.0-beta.32 → 0.1.0-beta.34
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 +4 -0
- package/AGENTS.md +46 -31
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +29 -26
- package/Dockerfile +4 -0
- package/README.md +26 -18
- package/THIRD_PARTY_NOTICES.md +4 -4
- package/compose.yaml +2 -1
- package/docker/smoke.mjs +6 -3
- package/docs/application-channel.md +6 -0
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/docker-runtime.md +27 -10
- package/docs/host-service.md +17 -6
- package/docs/plugin-contributions.md +4 -1
- package/docs/plugins.md +15 -0
- package/docs/repair.md +1 -1
- package/docs/setup.md +88 -14
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +6 -4
- package/package.json +1 -1
- package/scripts/smoke-scheduler.ts +2 -1
- package/src/agent-install.ts +20 -6
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +10 -10
- package/src/codex-session.ts +5 -3
- package/src/config.ts +6 -1
- package/src/control-state.ts +12 -1
- package/src/executor.ts +91 -9
- package/src/host-executor.ts +25 -6
- package/src/index.ts +1 -0
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +3 -1
- package/src/isolation.ts +25 -0
- package/src/menu.ts +9 -6
- package/src/plugins/manager.mjs +49 -6
- package/src/reply-context.ts +0 -1
- package/src/setup.ts +11 -7
- package/src/task-executor.ts +4 -1
- package/src/updates/control.mjs +11 -1
- package/src/updates/runtime.mjs +4 -2
- package/src/updates/supervisor.mjs +17 -3
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +23 -13
- package/templates/agent/AGENTS.md +4 -14
- package/templates/agent-guidance.md +13 -110
- package/templates/agent-purpose.md +1 -0
- package/test/agent-guidance.test.ts +8 -12
- package/test/agent-install.test.ts +34 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +48 -0
- package/test/application-channel.test.ts +5 -2
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +2 -0
- package/test/codex-context.test.ts +61 -1
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +17 -2
- package/test/engine-handoff.test.ts +5 -2
- package/test/executor.test.ts +38 -1
- package/test/failure.test.ts +6 -0
- package/test/host-executor.test.ts +6 -0
- package/test/install-config.test.ts +17 -6
- package/test/install-tools.test.mjs +6 -0
- package/test/plugin-manager.test.mjs +24 -3
- package/test/scheduler.test.ts +2 -2
- package/test/updates.test.mjs +34 -2
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +23 -10
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
package/docs/setup.md
CHANGED
|
@@ -67,8 +67,23 @@ not imported into restricted sessions. No workflow prompt is added to input.
|
|
|
67
67
|
Use the existing owner's host account. Unless a layout was supplied, use
|
|
68
68
|
`${XDG_DATA_HOME:-$HOME/.local/share}/ez/packages/<version>/` for extracted main
|
|
69
69
|
packages and `${XDG_DATA_HOME:-$HOME/.local/share}/ez/agents/` for private deployments.
|
|
70
|
-
Never overwrite an existing package/deployment or extract over its state.
|
|
71
|
-
|
|
70
|
+
Never overwrite an existing package/deployment or extract over its state.
|
|
71
|
+
|
|
72
|
+
Before creation, use your own reasoning and the context already available from
|
|
73
|
+
the owner to write the agent's initial brief. This is not a label or a copy of
|
|
74
|
+
the request. Describe, in concrete language, who the agent serves, what product
|
|
75
|
+
or environment it works in, the outcomes it should help produce, the capabilities
|
|
76
|
+
currently available to it, and important scope boundaries. Include material facts
|
|
77
|
+
you already know about the application; do not invent unknown facts. Omit generic
|
|
78
|
+
personality prose and sections irrelevant to this agent. Keep the result compact
|
|
79
|
+
enough to read on every turn. Ask the owner only when a missing fact would
|
|
80
|
+
materially change the agent being installed.
|
|
81
|
+
|
|
82
|
+
Pass that generated brief as the purpose. For example, a request to create a
|
|
83
|
+
personal assistant should produce a brief tailored to that person's stated work,
|
|
84
|
+
preferences and connected capabilities—not merely `Personal assistant`. A product
|
|
85
|
+
agent should identify the product, user, current product scope and available
|
|
86
|
+
actions without embedding product-specific behavior in Ez Core.
|
|
72
87
|
|
|
73
88
|
Before running Node commands, check `command -v node` and `node --version`.
|
|
74
89
|
If Node 22+ or this package's pinned pnpm is missing, provision it using the
|
|
@@ -163,20 +178,72 @@ so later service starts use the same build without depending on shell environmen
|
|
|
163
178
|
|
|
164
179
|
```sh
|
|
165
180
|
export EZ_AGENTS_HOME=/absolute/private/agents
|
|
166
|
-
bin/ezenciel-agents-create --name family-shopper --purpose '
|
|
181
|
+
bin/ezenciel-agents-create --name family-shopper --purpose 'You are the shopping assistant for the owner and their family. Help plan shared shopping, organize confirmed requests, and use only the installed shopping and messaging capabilities. Do not purchase, message others, or infer household preferences without owner authority.' < /private/bot-token
|
|
167
182
|
export EZ_DEPLOYMENT_DIR="$EZ_AGENTS_HOME/family-shopper"
|
|
168
183
|
```
|
|
169
184
|
|
|
185
|
+
Creation records an isolation class. Packaged Codex defaults to `isolated`: the
|
|
186
|
+
native CLI runs inside the relay, which only has that agent's workspace and control mounts.
|
|
187
|
+
Sibling host paths are absent (`ENOENT`), not merely unreadable. Pass
|
|
188
|
+
`--isolation host-capable` only when the owner needs the installer UID (host
|
|
189
|
+
browser, local git, host files). Other installed CLIs default to `host-capable`
|
|
190
|
+
because they are not packaged in the relay; requesting isolated execution for
|
|
191
|
+
them fails closed. Host-capable reuses the host CLI login and is not an OS tenant
|
|
192
|
+
boundary. The class cannot be changed later by flipping Compose; recreate the agent.
|
|
193
|
+
|
|
194
|
+
Isolated Codex uses the pinned CLI in the relay image and
|
|
195
|
+
`EZ_CODEX_SANDBOX=external` so Codex does not nest a sandbox inside the
|
|
196
|
+
container. Provision that agent's `control/cli/codex/auth.json`; do not bind-mount
|
|
197
|
+
the operator `~/.codex`. The host LaunchAgent is not started. Host-capable agents
|
|
198
|
+
still need [host startup](host-service.md).
|
|
199
|
+
|
|
170
200
|
Agent creation defaults to the CLI recorded when the initial package was
|
|
171
201
|
installed. It needs no `--cli` argument. A later shell or different available CLI
|
|
172
202
|
does not change that saved default. No installed-binary ranking or Grok fallback
|
|
173
203
|
is used.
|
|
204
|
+
|
|
205
|
+
Custom Codex providers such as OpenRouter currently require an explicitly
|
|
206
|
+
host-capable agent: their keys are supplied by the private host-service
|
|
207
|
+
environment. The isolated relay has no custom-provider key injection path.
|
|
208
|
+
Register the provider and initial model set as non-secret installation metadata;
|
|
209
|
+
repeat `--codex-model` for every model the agent may select:
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
bin/ezenciel-agents-create \
|
|
213
|
+
--name family-shopper \
|
|
214
|
+
--purpose 'Help my family plan shopping.' \
|
|
215
|
+
--isolation host-capable \
|
|
216
|
+
--codex-provider openrouter \
|
|
217
|
+
--codex-provider-name OpenRouter \
|
|
218
|
+
--codex-base-url https://openrouter.ai/api/v1 \
|
|
219
|
+
--codex-env-key OPENROUTER_API_KEY \
|
|
220
|
+
--codex-model google/gemini-3.8-flash \
|
|
221
|
+
--codex-model deepseek/deepseek-v4.1-flash \
|
|
222
|
+
< /private/bot-token
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
This stores provider configuration and model IDs in `host-executor.json`, never
|
|
226
|
+
the API key. The key belongs only in the private host-service environment. The
|
|
227
|
+
agent or owner can later change the active provider/model through the existing
|
|
228
|
+
runtime control without reinstalling or editing an environment file:
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
ezenciel-agents-ai list
|
|
232
|
+
ezenciel-agents-ai select --cli codex --provider openrouter \
|
|
233
|
+
--model google/gemini-3.8-flash
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Changing provider starts a fresh native session so a session is never resumed
|
|
237
|
+
against a different backend. Queued work retains the provider/model captured
|
|
238
|
+
when it was admitted.
|
|
174
239
|
The creator writes `host-executor.json`, private secrets, mind/control paths,
|
|
175
240
|
purpose and unique plugin volume names. Duplicate names fail without overwriting.
|
|
176
241
|
`ezenciel-agents-create --list` lists deployments without secrets.
|
|
177
242
|
|
|
178
|
-
Before starting
|
|
243
|
+
Before starting a host-capable transport, initialize the main package’s built-in plugin manager. It runs on the host
|
|
179
244
|
for Docker access and adds no separate package or provider library. The packaged catalog is empty so the relay installs independently.
|
|
245
|
+
Isolated agents still initialize this registry for plugin discovery; they do not
|
|
246
|
+
start `ezenciel-agents-host`.
|
|
180
247
|
Use its empty default catalog. Plugin preparation follows the verified Telegram
|
|
181
248
|
handoff, not this step. Run:
|
|
182
249
|
|
|
@@ -202,16 +269,21 @@ the plugin skill through onboarding and verified use under that same request.
|
|
|
202
269
|
Registry initialization itself does not connect accounts or start plugins.
|
|
203
270
|
Never silently reuse a global `ez` from the old sandbox runtime.
|
|
204
271
|
|
|
205
|
-
4.
|
|
206
|
-
|
|
207
|
-
|
|
272
|
+
4. Isolated agents skip host transport. Run `bin/ezenciel-agents-docker up -d --wait`
|
|
273
|
+
from the deployment directory. Do not register `ezenciel-agents-host` for them;
|
|
274
|
+
starting it fails closed.
|
|
275
|
+
|
|
276
|
+
Host-capable agents follow [host startup templates](host-service.md). Register
|
|
277
|
+
`bin/ezenciel-agents-host` with the host's native service manager, binding
|
|
278
|
+
`EZ_DEPLOYMENT_DIR` and the user's existing CLI PATH/HOME. On macOS use a
|
|
279
|
+
LaunchAgent; on Linux use a user service. Use the absolute Node/package
|
|
208
280
|
paths. This generic transport is the only host execution bridge. Do not start
|
|
209
281
|
another host relay or install another CLI. Verify its heartbeat, then run
|
|
210
282
|
`bin/ezenciel-agents-docker up -d --wait`.
|
|
211
283
|
|
|
212
284
|
`ezenciel-agents-setup service` is Docker-only startup: run it from the
|
|
213
285
|
deployment directory containing `docker.env`. It starts the relay and does
|
|
214
|
-
not install or start the host-executor user service. Complete
|
|
286
|
+
not install or start the host-executor user service. Complete host-capable
|
|
215
287
|
registration above separately.
|
|
216
288
|
|
|
217
289
|
5. Ask the owner to message the exact bot. Verify the pending numeric identity,
|
|
@@ -224,8 +296,8 @@ Never silently reuse a global `ez` from the old sandbox runtime.
|
|
|
224
296
|
stopped to verify repairs, then restore the poller; never replay uncertain
|
|
225
297
|
user actions merely to test delivery.
|
|
226
298
|
|
|
227
|
-
The main runtime is fully installer-owned: build it, register
|
|
228
|
-
|
|
299
|
+
The main runtime is fully installer-owned: build it, register host transport
|
|
300
|
+
only for host-capable agents, bring its Compose project up, and verify restart persistence. Plugins
|
|
229
301
|
are installed by the working Telegram agent when the owner requests their
|
|
230
302
|
capability there; account onboarding and provider confirmation follow that request.
|
|
231
303
|
Never use a missing plugin as a reason
|
|
@@ -238,9 +310,11 @@ current heartbeats and owner-bound completed-run delivery receipts without sendi
|
|
|
238
310
|
messages or exposing tokens. Receipt evidence is historical: separately verify
|
|
239
311
|
the current conversation and restart persistence before declaring setup complete.
|
|
240
312
|
|
|
241
|
-
Purpose
|
|
242
|
-
pairing, files and plugin accounts are separate.
|
|
243
|
-
|
|
313
|
+
Purpose scopes AGENTS.md once; future customization survives restart. Conversations,
|
|
314
|
+
pairing, files and plugin accounts are separate. Isolated agents use the relay
|
|
315
|
+
mount namespace as the filesystem boundary. Host-capable agents share the
|
|
316
|
+
installer UID and CLI login; that class is not a security sandbox against other
|
|
317
|
+
agents under that user.
|
|
244
318
|
Plugins run in their own containers and own their authentication. Install and
|
|
245
319
|
register them only for the requested agent. Never clone another agent's plugin
|
|
246
320
|
credentials. See [runtime, migration and QA](docker-runtime.md).
|
|
@@ -256,7 +330,7 @@ default. Existing queued jobs retain their captured execution choice.
|
|
|
256
330
|
| Symptom | Inspect and recover |
|
|
257
331
|
|---|---|
|
|
258
332
|
| Docker unavailable | Check engine and Compose, start the supported installation; complete any required OS consent. |
|
|
259
|
-
| Bot stays silent | Check pending owner identity, relay health, host heartbeat and outbox; approve only the verified owner. |
|
|
333
|
+
| Bot stays silent | Check pending owner identity, relay health, host heartbeat (host-capable only) and outbox; approve only the verified owner. |
|
|
260
334
|
| CLI exits without reply | Inspect the actual tool result and delivery receipt; stdout is not Telegram output. |
|
|
261
335
|
| Exit 73 | An existing writer holds this deployment; stop the exact duplicate, never delete the kernel lock. |
|
|
262
336
|
| Plugin absent | Check the bound catalog and install/start the reviewed package explicitly; no plugin ships by default. |
|
package/docs/standalone-cli.md
CHANGED
|
@@ -32,8 +32,9 @@ Use `ez tools list --details` for generated installed-plugin descriptions, help
|
|
|
32
32
|
Add that instruction to its existing project instructions without replacing them.
|
|
33
33
|
Use the absolute launcher, or prepend its bin directory to that session's PATH.
|
|
34
34
|
Never overwrite another global `ez`; it may belong to a different installation.
|
|
35
|
-
A company registry remains explicit even when invoked from a different
|
|
36
|
-
|
|
35
|
+
A standalone company registry remains explicit even when invoked from a different
|
|
36
|
+
directory. A relay-bound registry rejects calls from outside its workspace. Other
|
|
37
|
+
projects/accounts must use separate registries and private plugin state.
|
|
37
38
|
|
|
38
39
|
## Install and verify a capability
|
|
39
40
|
|
|
@@ -54,9 +55,9 @@ Native client plugins/connectors are not converted into Ez plugins automatically
|
|
|
54
55
|
|
|
55
56
|
A relay can coexist with CLI-only registries on the same machine. It retains its
|
|
56
57
|
own pairing, single-writer queue, mind and plugin registry. Calling its existing
|
|
57
|
-
bound launcher
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
bound launcher must be run from the owning agent workspace. Never initialize over
|
|
59
|
+
it or select it from another workspace. Coordinate writes with its active jobs.
|
|
60
|
+
Separate registries do not automatically share credentials or data.
|
|
60
61
|
|
|
61
62
|
Continuous monitoring requires a configured event consumer/relay and the plugin's
|
|
62
63
|
supported watcher. CLI-only installation does not create background agent turns.
|
package/docs/upgrades.md
CHANGED
|
@@ -174,10 +174,12 @@ No backup deletes live data or revokes credentials. Retain old package roots and
|
|
|
174
174
|
images until QA and any recovery window are complete; there is no automatic GC.
|
|
175
175
|
|
|
176
176
|
The updater accepts only matching state-schema and protocol contracts and an
|
|
177
|
-
unchanged deployment layout.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
unchanged deployment layout. Plugins may add command routes to existing services;
|
|
178
|
+
existing routes and all services, privileges, volumes and other deployment
|
|
179
|
+
configuration must remain unchanged. Incompatible changes fail before replacement,
|
|
180
|
+
even for an explicit candidate, and need a separately reviewed migration rather
|
|
181
|
+
than an override flag. Release authors must truthfully declare schema compatibility.
|
|
182
|
+
Code rollback **does not rewind private
|
|
181
183
|
state**, provider cursors or operation receipts; uncertain actions are never
|
|
182
184
|
replayed. Docker health is not proof of live provider identity or delivery.
|
|
183
185
|
|
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.34",
|
|
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.",
|
|
@@ -15,7 +15,8 @@ const duration=Number(process.argv[2] || 1860),cli=process.argv[3] || 'codex'
|
|
|
15
15
|
const nativeGoal=process.argv[4]==='goal'
|
|
16
16
|
if(!Number.isSafeInteger(duration) || duration<30)throw new Error('Duration must be at least 30 seconds')
|
|
17
17
|
const root=await mkdtemp(join(tmpdir(),'ez-scheduler-smoke-')),workspace=join(root,'agent'),controlDir=join(root,'control')
|
|
18
|
-
await
|
|
18
|
+
const purpose=join(root,'purpose.md');await writeFile(purpose,'Verify native scheduled work and responsive owner chat.\n')
|
|
19
|
+
await initializeWorkspace(workspace,purpose)
|
|
19
20
|
const control=new ControlStore(controlDir,1000),scheduler=new Scheduler(controlDir),runs=new RunStore(controlDir)
|
|
20
21
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
21
22
|
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset(cli))
|
package/src/agent-install.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { mkdir, readFile, writeFile, rm, readdir } from 'node:fs/promises'
|
|
|
2
2
|
import { isAbsolute, join, resolve, dirname } from 'node:path'
|
|
3
3
|
import { parseArgs } from 'node:util'
|
|
4
4
|
import { executorKey } from './executor.js'
|
|
5
|
+
import { isolationTransport, parseIsolationClass, type IsolationClass } from './isolation.js'
|
|
5
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { validateCodexProvider, type CodexProviderBinding } from './executor.js'
|
|
6
8
|
|
|
7
9
|
// Recorded by the installing CLI once; agent creation inherits this binding.
|
|
8
10
|
export const installationCli = async (root: string, installerCli?: string): Promise<string> => {
|
|
@@ -30,18 +32,25 @@ export const installationCli = async (root: string, installerCli?: string): Prom
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export const createAgent = async (options: {
|
|
33
|
-
root: string; hostRoot: string; composeFile: string; name: string; purpose: string; token: string; cli?: string; image?: string
|
|
35
|
+
root: string; hostRoot: string; composeFile: string; name: string; purpose: string; token: string; cli?: string; image?: string; isolation?: string; codexProvider?: CodexProviderBinding
|
|
34
36
|
}) => {
|
|
35
37
|
const { root, hostRoot, composeFile, name, purpose, token } = options
|
|
38
|
+
const requestedIsolation = options.isolation?.trim()
|
|
39
|
+
if (requestedIsolation) parseIsolationClass(requestedIsolation)
|
|
36
40
|
if (!/^[a-z][a-z0-9-]{0,39}$/.test(name)) throw new Error('Use an agent name of 1–40 lowercase letters, digits or hyphens, starting with a letter.')
|
|
37
41
|
if (![root, hostRoot, composeFile].every(p => isAbsolute(p) && !/[\r\n\0']/.test(p)))
|
|
38
42
|
throw new Error('Installation paths must be absolute and contain no newline or single quote.')
|
|
39
|
-
if (!purpose.trim() || purpose.length >
|
|
43
|
+
if (!purpose.trim() || purpose.length > 2000) throw new Error('Supply a concise purpose of 1–2000 characters.')
|
|
40
44
|
if (!/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) throw new Error('Supply the BotFather token through stdin.')
|
|
41
45
|
const image=options.image||'ezenciel-agents:local'
|
|
42
46
|
if(!/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(image))throw new Error('Invalid relay image reference')
|
|
43
47
|
await mkdir(root, {recursive:true, mode:0o700})
|
|
44
48
|
const cli = await installationCli(root, options.cli)
|
|
49
|
+
const isolation: IsolationClass = requestedIsolation ? parseIsolationClass(requestedIsolation) : cli === 'codex' ? 'isolated' : 'host-capable'
|
|
50
|
+
if (isolation === 'isolated' && cli !== 'codex') throw new Error(`Isolated execution is unavailable for ${cli}; use host-capable isolation`)
|
|
51
|
+
const transport = isolationTransport(isolation)
|
|
52
|
+
const codexProvider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
|
|
53
|
+
if (codexProvider && cli !== 'codex') throw new Error('A Codex provider requires the Codex installation CLI')
|
|
45
54
|
const directory = join(root, name), deploymentDir = join(hostRoot, name)
|
|
46
55
|
// Exclusive directory creation: a repeated name never overwrites another agent.
|
|
47
56
|
await mkdir(directory, {mode:0o700})
|
|
@@ -54,6 +63,9 @@ export const createAgent = async (options: {
|
|
|
54
63
|
EZ_RELAY_ENV_FILE: join(deploymentDir, 'relay.env'),
|
|
55
64
|
EZ_AGENT_PURPOSE_FILE: join(deploymentDir, 'purpose.md'),
|
|
56
65
|
EZ_EXECUTOR_CLI: cli,
|
|
66
|
+
EZ_ISOLATION: isolation,
|
|
67
|
+
EZ_EXECUTOR_TRANSPORT: transport,
|
|
68
|
+
...(isolation === 'isolated' && cli === 'codex' ? { EZ_CODEX_SANDBOX: 'external' } : {}),
|
|
57
69
|
EZ_AGENT_WORKSPACE: join(deploymentDir, 'mind'),
|
|
58
70
|
EZ_CONTROL_DIR: join(deploymentDir, 'control'),
|
|
59
71
|
EZ_WHATSAPP_IPC_VOLUME: `${project}-whatsapp-ipc`,
|
|
@@ -64,8 +76,8 @@ export const createAgent = async (options: {
|
|
|
64
76
|
await writeFile(join(directory, 'purpose.md'), purpose.trim()+'\n', {mode:0o644, flag:'wx'})
|
|
65
77
|
await mkdir(join(directory,'mind'),{mode:0o700})
|
|
66
78
|
await mkdir(join(directory,'control'),{mode:0o700})
|
|
67
|
-
const agent = {name, project, deploymentDir, purpose:purpose.trim(), executor:cli}
|
|
68
|
-
const host = {cli,agents:[{name,workspace:join(deploymentDir,'mind'),controlDir:join(deploymentDir,'control'),binDir:join(dirname(composeFile),'bin')}]}
|
|
79
|
+
const agent = {name, project, deploymentDir, purpose:purpose.trim(), executor:cli, isolation}
|
|
80
|
+
const host = {cli,isolation,agents:[{name,workspace:join(deploymentDir,'mind'),controlDir:join(deploymentDir,'control'),binDir:join(dirname(composeFile),'bin'),...(codexProvider?{codexProviders:[codexProvider]}:{})}]}
|
|
69
81
|
await writeFile(join(directory,'host-executor.json'),JSON.stringify(host,null,2)+'\n',{mode:0o600,flag:'wx'})
|
|
70
82
|
await writeFile(join(directory, 'agent.json'), JSON.stringify(agent,null,2)+'\n', {mode:0o600, flag:'wx'})
|
|
71
83
|
return agent
|
|
@@ -84,13 +96,15 @@ export const listAgents = async (root: string) => {
|
|
|
84
96
|
|
|
85
97
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
86
98
|
try {
|
|
87
|
-
const {values:v} = parseArgs({options:{name:{type:'string'},purpose:{type:'string'},'host-root':{type:'string'},'compose-file':{type:'string'},'relay-image':{type:'string'},list:{type:'boolean'},cli:{type:'string'},'register-cli':{type:'string'}}})
|
|
99
|
+
const {values:v} = parseArgs({options:{name:{type:'string'},purpose:{type:'string'},isolation:{type:'string'},'host-root':{type:'string'},'compose-file':{type:'string'},'relay-image':{type:'string'},list:{type:'boolean'},cli:{type:'string'},'register-cli':{type:'string'},'codex-provider':{type:'string'},'codex-provider-name':{type:'string'},'codex-base-url':{type:'string'},'codex-env-key':{type:'string'},'codex-model':{type:'string',multiple:true}}})
|
|
88
100
|
if (v['register-cli']) console.log(JSON.stringify({cli:await installationCli('/installations',v['register-cli'])}))
|
|
89
101
|
else if (v.list) console.log(JSON.stringify(await listAgents('/installations')))
|
|
90
102
|
else {
|
|
91
103
|
let token=''
|
|
92
104
|
for await (const chunk of process.stdin) { token+=chunk; if(token.length>512) throw new Error('Token input is too long.') }
|
|
93
|
-
|
|
105
|
+
const providerValues=[v['codex-provider'],v['codex-provider-name'],v['codex-base-url'],v['codex-env-key'],v['codex-model']]
|
|
106
|
+
const codexProvider=providerValues.some(Boolean)?validateCodexProvider({id:v['codex-provider']||'',name:v['codex-provider-name']||v['codex-provider']||'',baseUrl:v['codex-base-url']||'',envKey:v['codex-env-key']||'',models:v['codex-model']||[]}):undefined
|
|
107
|
+
console.log(JSON.stringify(await createAgent({root:'/installations',hostRoot:v['host-root']||'',composeFile:v['compose-file']||'',name:v.name||'',purpose:v.purpose||'',token:token.trim(),cli:v.cli||'',image:v['relay-image'],isolation:v.isolation,codexProvider})))
|
|
94
108
|
}
|
|
95
109
|
} catch (error) { console.error((error as Error).message); process.exitCode=1 }
|
|
96
110
|
}
|
package/src/ai-cli.ts
CHANGED
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util'
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
4
5
|
import { readModels, validateSelection, type AiPreset } from './ai.js'
|
|
5
6
|
import { ControlStore } from './control-state.js'
|
|
6
7
|
|
|
7
|
-
const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
|
|
8
|
+
const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},provider:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
|
|
8
9
|
if(values.help){
|
|
9
|
-
console.log('Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--model <model>] [--effort <effort>]\nChoose only values returned by list. Selection affects subsequent messages; queued work and the installation default are unchanged.')
|
|
10
|
+
console.log('Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>]\nChoose only values returned by list. Selection affects subsequent messages; queued work and the installation default are unchanged.')
|
|
10
11
|
process.exit(0)
|
|
11
12
|
}
|
|
12
13
|
if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
|
|
13
|
-
const catalog=await
|
|
14
|
+
const catalog=await readFile(join(process.env.EZ_CONTROL_DIR,'host-executor','models.json'),'utf8')
|
|
15
|
+
.then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR!,'cli','codex')))
|
|
14
16
|
if(positionals[0]==='list')console.log(JSON.stringify(catalog))
|
|
15
17
|
else if(positionals[0]==='select'){
|
|
16
|
-
const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',model:values.model,effort:values.effort}
|
|
18
|
+
const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.provider,values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',provider:values.provider,model:values.model,effort:values.effort}
|
|
17
19
|
await validateSelection(preset,catalog)
|
|
18
20
|
const control=new ControlStore(process.env.EZ_CONTROL_DIR,900000)
|
|
19
21
|
const state=await control.status()
|
|
20
22
|
if(!state.ai)throw new Error('Agent AI settings are not initialized')
|
|
21
|
-
const existing=state.ai.presets.find(p=>p.cli===preset.cli&&p.model===preset.model&&p.effort===preset.effort)
|
|
23
|
+
const existing=state.ai.presets.find(p=>p.cli===preset.cli&&p.provider===preset.provider&&p.model===preset.model&&p.effort===preset.effort)
|
|
22
24
|
const selected=existing||preset
|
|
23
25
|
if(!existing)await control.savePreset(selected)
|
|
24
26
|
const current=state.ai.presets.find(p=>p.id===state.ai!.selectedId)!
|
|
25
|
-
await control.selectPreset(selected.id,state.activeSession?.sessionId??null,current.cli!==selected.cli||Boolean(state.activeSession&&!state.activeSession.cli))
|
|
27
|
+
await control.selectPreset(selected.id,state.activeSession?.sessionId??null,current.cli!==selected.cli||current.provider!==selected.provider||Boolean(state.activeSession&&!state.activeSession.cli))
|
|
26
28
|
console.log(JSON.stringify({selected,defaultUnchanged:true,applies:'subsequent messages; queued work keeps its captured choice'}))
|
|
27
|
-
}else throw new Error('Use list or select --cli <installed-cli> [--model <model>] [--effort <effort>]')
|
|
29
|
+
}else throw new Error('Use list or select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>]')
|
package/src/ai.ts
CHANGED
|
@@ -6,26 +6,26 @@ import { join, delimiter } from 'node:path'
|
|
|
6
6
|
import { executorKey, resolveExecutor } from './executor.js'
|
|
7
7
|
import { desktopCodexPath } from './desktop-bridge.js'
|
|
8
8
|
|
|
9
|
-
export type AiPreset = { id: string; name: string; cli: string; model?: string; effort?: string }
|
|
9
|
+
export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
|
|
10
10
|
export type ExecutionChoice = { sessionId: string; preset: AiPreset }
|
|
11
|
-
export type ModelChoice = { cli: string; model?: string; name: string; efforts: string[] }
|
|
11
|
+
export type ModelChoice = { cli: string; provider?: string; model?: string; name: string; efforts: string[] }
|
|
12
12
|
const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
|
|
13
13
|
export const isPreset = (p: unknown): p is AiPreset => {
|
|
14
14
|
if (!p || typeof p !== 'object') return false
|
|
15
15
|
const v = p as AiPreset
|
|
16
16
|
return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
|
|
17
17
|
['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy'].includes(v.cli) &&
|
|
18
|
-
(v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
|
|
18
|
+
(v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
|
|
19
19
|
}
|
|
20
20
|
export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
|
|
21
21
|
const c = v as ExecutionChoice | undefined
|
|
22
22
|
return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
|
|
23
23
|
}
|
|
24
|
-
export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
|
|
24
|
+
export const presetLabel = (p: AiPreset) => `${p.cli}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
|
|
25
25
|
// The seed delegates model selection to the native client. Project its resolved
|
|
26
26
|
// settings for status without pinning future conversations to that snapshot.
|
|
27
27
|
export const statusPreset = (preset: AiPreset, discovered: AiPreset[]): AiPreset =>
|
|
28
|
-
['codex','codex-gui'].includes(preset.cli) && !preset.model && !preset.effort
|
|
28
|
+
['codex','codex-gui'].includes(preset.cli) && !preset.provider && !preset.model && !preset.effort
|
|
29
29
|
? discovered.find((candidate) => candidate.cli === preset.cli) ?? preset
|
|
30
30
|
: preset
|
|
31
31
|
export const initialPreset = (cli: string): AiPreset => {
|
|
@@ -87,8 +87,8 @@ export const readModels = async (home = homedir(), available = installed, codexH
|
|
|
87
87
|
export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
|
|
88
88
|
assertEffort(p.effort, p.model, p.cli)
|
|
89
89
|
if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
|
|
90
|
-
if (!p.model && !p.effort && p.cli !== 'agy') return
|
|
91
|
-
const model = catalog.find((m) => m.cli === p.cli && m.model === p.model)
|
|
90
|
+
if (!p.provider && !p.model && !p.effort && p.cli !== 'agy') return
|
|
91
|
+
const model = catalog.find((m) => m.cli === p.cli && m.provider === p.provider && m.model === p.model)
|
|
92
92
|
if (!model || (p.effort && !model.efforts.includes(p.effort)))
|
|
93
93
|
throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
|
|
94
94
|
}
|
|
@@ -105,10 +105,10 @@ export class ApplicationChannel {
|
|
|
105
105
|
const owner = await this.sharedBinding(bindingId)
|
|
106
106
|
const controls = this.options.aiControls
|
|
107
107
|
if (!controls) throw new Error('Application controls unavailable')
|
|
108
|
-
const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; model?: unknown; effort?: unknown}
|
|
108
|
+
const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
|
|
109
109
|
if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
|
|
110
110
|
!(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
|
|
111
|
-
const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
|
|
111
|
+
const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
|
|
112
112
|
if (Object.keys(value).some(key => !['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
|
|
113
113
|
const control = new ControlStore(this.options.controlDir, 900000)
|
|
114
114
|
const expected = value.expectedSession as string | null
|
|
@@ -131,7 +131,7 @@ export class ApplicationChannel {
|
|
|
131
131
|
await controls.select(preset, expected, guard)
|
|
132
132
|
}
|
|
133
133
|
if (value.action === 'model') {
|
|
134
|
-
const model = (await controls.catalog()).find(item => item.cli === value.cli && item.model === value.model)
|
|
134
|
+
const model = (await controls.catalog()).find(item => item.cli === value.cli && item.provider === value.provider && item.model === value.model)
|
|
135
135
|
if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
|
|
136
136
|
await this.sharedBinding(bindingId)
|
|
137
137
|
const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
|
|
@@ -160,10 +160,10 @@ export class ApplicationChannel {
|
|
|
160
160
|
const owner = await this.sharedBinding(bindingId, false)
|
|
161
161
|
const controls = this.options.aiControls
|
|
162
162
|
if (!controls) throw new Error('Application controls unavailable')
|
|
163
|
-
const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; model?:unknown; effort?:unknown}
|
|
163
|
+
const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; model?:unknown; effort?:unknown}
|
|
164
164
|
if (!value || !['new','select','model'].includes(String(value.action)) ||
|
|
165
165
|
!(value.expectedSession===null || (typeof value.expectedSession==='string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
|
|
166
|
-
const fields = {new:[],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'select'|'model']!
|
|
166
|
+
const fields = {new:[],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'select'|'model']!
|
|
167
167
|
if (Object.keys(value).some(key=>!['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
|
|
168
168
|
const hashedScope = applicationScope(bindingId,scope)
|
|
169
169
|
const guard:ControlGuard = {owner,applicationScope:hashedScope,expectedSession:value.expectedSession as string|null,
|
|
@@ -180,7 +180,7 @@ export class ApplicationChannel {
|
|
|
180
180
|
await controls.validate(preset)
|
|
181
181
|
}
|
|
182
182
|
if (value.action==='model') {
|
|
183
|
-
const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.model===value.model)
|
|
183
|
+
const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.provider===value.provider && item.model===value.model)
|
|
184
184
|
if (!model || model.cli==='agy' || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
|
|
185
185
|
preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
|
|
186
186
|
}
|
|
@@ -195,7 +195,7 @@ export class ApplicationChannel {
|
|
|
195
195
|
if ('followTelegram' in rest) throw new Error('Invalid application request: choose one conversation option')
|
|
196
196
|
input = {...rest, followTelegram: followOwner}
|
|
197
197
|
}
|
|
198
|
-
const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; model?: unknown; effort?: unknown } }
|
|
198
|
+
const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown } }
|
|
199
199
|
if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || (!value.text.trim() && value.attachment === undefined) || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','attachment','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
|
|
200
200
|
if (value.expectedNativeSessionId !== undefined && (typeof value.expectedNativeSessionId !== 'string' || !/^[a-zA-Z0-9_-]{1,160}$/.test(value.expectedNativeSessionId))) throw new Error('Invalid application native session assertion')
|
|
201
201
|
if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
|
|
@@ -204,7 +204,7 @@ export class ApplicationChannel {
|
|
|
204
204
|
let requestedPreset: AiPreset | undefined
|
|
205
205
|
if (value.ai !== undefined) {
|
|
206
206
|
const candidate = { ...value.ai, id: 'application', name: 'Application selection' }
|
|
207
|
-
if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
|
|
207
|
+
if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
|
|
208
208
|
assertEffort(candidate.effort, candidate.model, candidate.cli)
|
|
209
209
|
requestedPreset = candidate
|
|
210
210
|
}
|
|
@@ -227,7 +227,7 @@ export class ApplicationChannel {
|
|
|
227
227
|
const existing = await this.runs.get(id)
|
|
228
228
|
if (existing) {
|
|
229
229
|
await this.bindings.authorize(existing)
|
|
230
|
-
if (requestedPreset && (existing.execution?.preset.cli !== requestedPreset.cli || existing.execution?.preset.model !== requestedPreset.model || existing.execution?.preset.effort !== requestedPreset.effort)) throw new Error('Application request ID conflicts with prior AI selection')
|
|
230
|
+
if (requestedPreset && (existing.execution?.preset.cli !== requestedPreset.cli || existing.execution?.preset.provider !== requestedPreset.provider || existing.execution?.preset.model !== requestedPreset.model || existing.execution?.preset.effort !== requestedPreset.effort)) throw new Error('Application request ID conflicts with prior AI selection')
|
|
231
231
|
if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || (existing.application?.inputText ?? existing.texts[0]) !== value.text || existing.application?.attachmentHash !== attachmentHash) throw new Error('Application request ID conflicts with prior scope or text')
|
|
232
232
|
return existing // Retried context never replaces already admitted capabilities.
|
|
233
233
|
}
|
|
@@ -252,7 +252,7 @@ export class ApplicationChannel {
|
|
|
252
252
|
await this.bindings.authorize(run)
|
|
253
253
|
const state = await new ControlStore(this.options.controlDir, 900000).status()
|
|
254
254
|
const session = state.activeSession?.sessionId === run.execution?.sessionId ? state.activeSession : state.sessions?.find(item => item.sessionId === run.execution?.sessionId)
|
|
255
|
-
return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), approvals:await this.runs.applicationApprovals(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
|
|
255
|
+
return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.execution?.preset ? {preset:run.execution.preset} : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), approvals:await this.runs.applicationApprovals(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
|
|
256
256
|
}
|
|
257
257
|
async deliver(run: RunRecord, item: OutboxItem): Promise<void> {
|
|
258
258
|
await this.bindings.authorize(run)
|
package/src/codex-session.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { spawn, type ChildProcess } from 'node:child_process'
|
|
|
3
3
|
import { createInterface } from 'node:readline'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import { terminateJob } from './executor.js'
|
|
6
|
+
import { terminateJob, validateCodexProvider, type CodexProviderBinding } from './executor.js'
|
|
7
7
|
|
|
8
|
-
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external'}
|
|
8
|
+
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external';codexProvider?:CodexProviderBinding}
|
|
9
9
|
type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
|
|
10
10
|
|
|
11
11
|
// Keep Codex's native session alive. Codex itself starts goal continuation turns;
|
|
@@ -13,6 +13,7 @@ type Message = {id?:number;method?:string;params?:any;result?:any;error?:{messag
|
|
|
13
13
|
export async function runCodexSession(options:Options, io:{launch?:()=>ChildProcess;emit?:(line:string)=>void}={}):Promise<number> {
|
|
14
14
|
options = executionDefaults('codex', options)
|
|
15
15
|
if (options.codexSandbox !== undefined && options.codexSandbox !== 'external') throw new Error('Invalid Codex sandbox selection')
|
|
16
|
+
const provider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
|
|
16
17
|
const child=io.launch?.() ?? spawn('codex',['app-server','--stdio','--disable','memories','--enable','skip_host_skill_discovery'],{cwd:options.workspace,env:process.env,stdio:['pipe','pipe','pipe']})
|
|
17
18
|
const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
|
|
18
19
|
let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false
|
|
@@ -73,7 +74,8 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
73
74
|
const result=await request('thread/start',{
|
|
74
75
|
cwd:options.workspace,approvalPolicy:'never',sandbox:options.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write',model:options.model,
|
|
75
76
|
config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[]),...(options.additionalWorkspaces ?? [])],
|
|
76
|
-
'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})
|
|
77
|
+
'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{}),
|
|
78
|
+
...(provider?{model_provider:provider.id,[`model_providers.${provider.id}`]:{name:provider.name,base_url:provider.baseUrl,env_key:provider.envKey,wire_api:'responses',supports_websockets:false}}:{})},
|
|
77
79
|
})
|
|
78
80
|
threadId=result.thread?.id
|
|
79
81
|
if(!threadId)throw new Error('Codex did not return a native thread ID')
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseWebLauncher, type WebLauncher } from './web-launcher.js'
|
|
2
2
|
import { repairEnabled } from './repair-policy.js'
|
|
3
|
+
import { isolationTransport, resolveIsolation, type IsolationClass } from './isolation.js'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import { homedir } from 'node:os'
|
|
5
6
|
|
|
@@ -15,6 +16,7 @@ export type Config = ControlConfig & {
|
|
|
15
16
|
telegramBotToken: string
|
|
16
17
|
workspace: string
|
|
17
18
|
executorTimeoutMs: number
|
|
19
|
+
isolation?: IsolationClass
|
|
18
20
|
codexSandbox?: 'external'
|
|
19
21
|
codexAutoCompactTokens?: number
|
|
20
22
|
executorCli: string
|
|
@@ -56,9 +58,11 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
56
58
|
|
|
57
59
|
if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
|
|
58
60
|
if (env.EZ_APPLICATION_PORT && env.EZ_CHANNEL_BACKEND_URL) throw new Error('Application input requires the native Ez executor, not a channel backend')
|
|
61
|
+
const isolation = resolveIsolation(env)
|
|
62
|
+
const transport = env.EZ_EXECUTOR_TRANSPORT?.trim() || isolationTransport(isolation)
|
|
59
63
|
const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
|
|
60
64
|
if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
|
|
61
|
-
if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL ||
|
|
65
|
+
if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || transport !== 'local')) throw new Error('External Codex sandbox requires native local execution')
|
|
62
66
|
const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
|
|
63
67
|
const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
|
|
64
68
|
if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
|
|
@@ -78,6 +82,7 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
78
82
|
repairEnabled: repairEnabled(env.EZ_REPAIR_ENABLED),
|
|
79
83
|
workspace: path.resolve(env.EZ_AGENT_WORKSPACE?.trim() || './agent'),
|
|
80
84
|
executorTimeoutMs: 0,
|
|
85
|
+
isolation,
|
|
81
86
|
codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
|
|
82
87
|
codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
|
|
83
88
|
executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
|
package/src/control-state.ts
CHANGED
|
@@ -470,7 +470,18 @@ export class ControlStore {
|
|
|
470
470
|
const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
|
|
471
471
|
state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
|
|
472
472
|
if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
|
|
473
|
-
if (state.activeSession.cli === preset.cli)
|
|
473
|
+
if (state.activeSession.cli === preset.cli) {
|
|
474
|
+
const previous = state.activeSession.preset
|
|
475
|
+
// Same CLI, different provider/model: web/Telegram must not resume the old native thread.
|
|
476
|
+
// Undefined is the native client default and is a distinct selection.
|
|
477
|
+
if (state.activeSession.hasStarted && previous &&
|
|
478
|
+
(previous.provider !== preset.provider || previous.model !== preset.model)) {
|
|
479
|
+
;(state.sessions ??= []).push(state.activeSession)
|
|
480
|
+
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset }
|
|
481
|
+
} else {
|
|
482
|
+
state.activeSession.preset = preset
|
|
483
|
+
}
|
|
484
|
+
}
|
|
474
485
|
if (!state.activeSession.title && !state.activeSession.hasStarted && title?.trim())
|
|
475
486
|
state.activeSession.title = title.replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
476
487
|
await this.writeState(state)
|