@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.
Files changed (70) hide show
  1. package/.env.example +4 -0
  2. package/AGENTS.md +46 -31
  3. package/CHANGELOG.md +33 -0
  4. package/CONTRIBUTING.md +29 -26
  5. package/Dockerfile +4 -0
  6. package/README.md +26 -18
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/compose.yaml +2 -1
  9. package/docker/smoke.mjs +6 -3
  10. package/docs/application-channel.md +6 -0
  11. package/docs/architecture/ai-selection.md +3 -1
  12. package/docs/docker-runtime.md +27 -10
  13. package/docs/host-service.md +17 -6
  14. package/docs/plugin-contributions.md +4 -1
  15. package/docs/plugins.md +15 -0
  16. package/docs/repair.md +1 -1
  17. package/docs/setup.md +88 -14
  18. package/docs/standalone-cli.md +6 -5
  19. package/docs/upgrades.md +6 -4
  20. package/package.json +1 -1
  21. package/scripts/smoke-scheduler.ts +2 -1
  22. package/src/agent-install.ts +20 -6
  23. package/src/ai-cli.ts +9 -7
  24. package/src/ai.ts +7 -7
  25. package/src/application-channel.ts +10 -10
  26. package/src/codex-session.ts +5 -3
  27. package/src/config.ts +6 -1
  28. package/src/control-state.ts +12 -1
  29. package/src/executor.ts +91 -9
  30. package/src/host-executor.ts +25 -6
  31. package/src/index.ts +1 -0
  32. package/src/install-config.ts +2 -2
  33. package/src/install-tools.mjs +3 -1
  34. package/src/isolation.ts +25 -0
  35. package/src/menu.ts +9 -6
  36. package/src/plugins/manager.mjs +49 -6
  37. package/src/reply-context.ts +0 -1
  38. package/src/setup.ts +11 -7
  39. package/src/task-executor.ts +4 -1
  40. package/src/updates/control.mjs +11 -1
  41. package/src/updates/runtime.mjs +4 -2
  42. package/src/updates/supervisor.mjs +17 -3
  43. package/src/workspace-confine.ts +38 -0
  44. package/src/workspace.ts +23 -13
  45. package/templates/agent/AGENTS.md +4 -14
  46. package/templates/agent-guidance.md +13 -110
  47. package/templates/agent-purpose.md +1 -0
  48. package/test/agent-guidance.test.ts +8 -12
  49. package/test/agent-install.test.ts +34 -0
  50. package/test/ai-cli.test.ts +18 -0
  51. package/test/ai.test.ts +48 -0
  52. package/test/application-channel.test.ts +5 -2
  53. package/test/application-controls.test.ts +56 -3
  54. package/test/application-only.test.ts +2 -0
  55. package/test/codex-context.test.ts +61 -1
  56. package/test/codex-session.test.ts +6 -1
  57. package/test/config.test.ts +17 -2
  58. package/test/engine-handoff.test.ts +5 -2
  59. package/test/executor.test.ts +38 -1
  60. package/test/failure.test.ts +6 -0
  61. package/test/host-executor.test.ts +6 -0
  62. package/test/install-config.test.ts +17 -6
  63. package/test/install-tools.test.mjs +6 -0
  64. package/test/plugin-manager.test.mjs +24 -3
  65. package/test/scheduler.test.ts +2 -2
  66. package/test/updates.test.mjs +34 -2
  67. package/test/workspace-confine.test.ts +47 -0
  68. package/test/workspace.test.ts +23 -10
  69. package/templates/agent/SOUL.md +0 -11
  70. 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. Infer
71
- the agent name and purpose from the owner's request; ask only when missing.
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 'Help my family plan shopping.' < /private/bot-token
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 that host transport, initialize the main package’s built-in plugin manager. It runs on the host
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. Follow [host startup templates](host-service.md). Register `bin/ezenciel-agents-host` with the host's native service manager,
206
- binding `EZ_DEPLOYMENT_DIR` and the user's existing CLI PATH/HOME. On macOS use
207
- a LaunchAgent; on Linux use a user service. Use the absolute Node/package
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 the host
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 its host
228
- transport, bring its Compose project up, and verify restart persistence. Plugins
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 seeds SOUL.md once; future customization survives restart. Conversations,
242
- pairing, files and plugin accounts are separate. The existing host CLI login is
243
- shared, so this is not a security sandbox against other agents under that user.
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. |
@@ -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 directory.
36
- Other projects/accounts should use separate registries and private plugin state.
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 explicitly reuses that registry and its accounts; never initialize
58
- over it or silently select it from another workspace. Coordinate writes with its
59
- active jobs. Separate registries do not automatically share credentials or data.
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. Changes to privileges, services, volumes or Compose
178
- configuration fail before replacement, even for an explicit candidate. These
179
- need a separately reviewed migration, not an override flag. Release authors must
180
- truthfully declare schema compatibility. Code rollback **does not rewind private
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.32",
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 initializeWorkspace(workspace)
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))
@@ -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 > 12000) throw new Error('Supply a purpose of 1–12000 characters.')
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
- 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']})))
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 readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR,'cli','codex'))
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)
@@ -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 || env.EZ_EXECUTOR_TRANSPORT !== 'local')) throw new Error('External Codex sandbox requires native local execution')
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',
@@ -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) state.activeSession.preset = preset
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)