@jc_stack/ez-agents 0.1.0-beta.48 → 0.1.0-beta.49

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.49
4
+
5
+ - Upgrade compatible plugins while native work continues, draining in-flight plugin calls for activation and preserving rollback. Core updates still wait for idle.
6
+ - Keep separate named Codex and Claude native login homes, with account-bound conversations, profile-aware model selection and fail-closed admission.
7
+ - Try ordered saved setups for a scheduled task only after a typed failure before work begins. Hooks, model/tool activity, access denial, cancellation and uncertain transport stop fallback; quota rejection skips the same native CLI/provider scope.
8
+ - Preserve each scheduled attempt's captured auth profile and admit only the active setup on the host transport.
9
+
10
+ Beta limits: live fresh-host/reboot acceptance, two-real-account channel switching and real exhausted-account/provider rejection remain pending. Fallback applies to scheduled agent tasks only; OpenCode/Pi/Grok advance only when their CLI is unavailable. The fallback command does not select named profiles, and different credentials do not establish independent quota allowances. Publication does not establish an installed-agent upgrade or authorize provider/domain writes.
11
+
3
12
  ## 0.1.0-beta.48
4
13
 
5
14
  - Skip empty or unchanged scheduled work through owner-configured installed read-only plugin preflight. Invalid or unavailable reads fail closed; saved execution settings and recurring timing remain intact. The bound plugin broker is required.
@@ -6,8 +6,8 @@ import path from 'node:path';
6
6
  import assert from 'node:assert/strict';
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { createHash } from 'node:crypto';
9
- import { atomic, snapshot } from '../src/plugins/manager.mjs';
10
- import { execute, refreshBroker } from '../src/updates/runtime.mjs';
9
+ import { atomic, snapshot, publishBrokerPlugins } from '../src/plugins/manager.mjs';
10
+ import { execute, refreshBroker, verifyBroker, brokerNeedsRefresh } from '../src/updates/runtime.mjs';
11
11
  const docker=(...args)=>execFileSync('docker',args,{encoding:'utf8',stdio:['ignore','pipe','pipe'],timeout:120000}).trim();
12
12
  const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
13
13
  if(process.argv[2]!=='--inside') {
@@ -69,6 +69,20 @@ if(process.argv[2]!=='--inside') {
69
69
  assert.equal((await mounted()).trim(),ready.hostSha256);assert.equal((await prepare()).trim(),'prepared');
70
70
  assert.equal(ready.plugins[0].revision,b.revision);
71
71
  console.log(JSON.stringify({stage:'activation',oldBroker,newBroker,relay,hostSha256:ready.hostSha256,registrySha256:ready.registrySha256,revision:b.revision}));
72
+ // With the current host binding already approving both revisions, plugin-only
73
+ // activation/rollback must leave the broker socket and relay intact.
74
+ await publish(a);await publishBrokerPlugins(home,controlDir);
75
+ const pluginRollback=await verifyBroker(config);
76
+ assert.equal(pluginRollback.plugins[0].revision,a.revision);
77
+ assert.equal(await container('plugin-broker'),newBroker);assert.equal(await container('relay'),relay);
78
+ assert.equal(await brokerNeedsRefresh(config),false);assert.equal((await prepare()).trim(),'prepared');
79
+ await publish(b);await publishBrokerPlugins(home,controlDir);
80
+ const pluginActivation=await verifyBroker(config);
81
+ assert.equal(pluginActivation.plugins[0].revision,b.revision);
82
+ assert.equal(await container('plugin-broker'),newBroker);assert.equal(await container('relay'),relay);
83
+ assert.equal((await prepare()).trim(),'prepared');
84
+ console.log('PASS plugin activation/rollback: current registry accepted; broker and relay IDs preserved');
85
+
72
86
  await atomic(hostConfig,host([a.revision]));
73
87
  await assert.rejects(refreshBroker(config),/STRUCTURAL_READINESS_FAILED/);await assert.rejects(prepare(),/not pinned/);
74
88
  console.log('PASS unapproved B remains rejected after recreation');
@@ -14,7 +14,7 @@ const exec=promisify(execFile),root=await fs.realpath(await fs.mkdtemp(path.join
14
14
  const source=path.join(root,'source'),home=path.join(root,'tools'),mind=path.join(root,'mind'),control=path.join(root,'control');
15
15
  const input=path.resolve(process.env.EZ_WHATSAPP_SOURCE||new URL('../../ez_whatsapp',import.meta.url).pathname);
16
16
  const bin=new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname;
17
- const call=async(...args)=>JSON.parse((await exec(process.execPath,[bin,'--home',home,...args],{maxBuffer:4*1024*1024})).stdout);
17
+ const call=async(...args)=>JSON.parse((await exec(process.execPath,[bin,'--home',home,...args],{cwd:mind,maxBuffer:4*1024*1024})).stdout);
18
18
  let record;
19
19
  try {
20
20
  for(const dir of [source,mind,control])await fs.mkdir(dir);
@@ -12,7 +12,11 @@ Each named conversation saves its model and reasoning level. Switching back thro
12
12
  `/ai` shows the current choice and saves changes immediately. Changing model or
13
13
  reasoning level on the same client and provider preserves the conversation's name
14
14
  and native history; subsequent turns pass the new settings to the native engine.
15
- Changing client or provider starts a separate native conversation. Old bindings
15
+ Changing client, provider or auth profile starts a separate native conversation.
16
+ A conversation records its client and auth profile when created; launch rejects a
17
+ choice for another profile, so a native session never resumes under another
18
+ account. Provisioned profiles appear as additional catalog entries for their
19
+ client (see [auth profiles](../docker-runtime.md#auth-profiles)). Old bindings
16
20
  remain available so accepted work can finish and owners can reopen their chats.
17
21
 
18
22
  Grok/Claude accept caller-selected native UUIDs. Codex/OpenCode generate IDs, so
@@ -295,6 +295,34 @@ Without any login, Claude turns fail closed with the native "not logged in"
295
295
  error. Host-capable agents use the same agent-bound directory but share the
296
296
  installer login in place (`CLAUDE_SECURESTORAGE_CONFIG_DIR` empty).
297
297
 
298
+ ## Auth profiles
299
+
300
+ One agent can keep several owner-provisioned Codex or Claude logins, for example
301
+ a work and a personal account. Each named profile is a separate native home,
302
+ `control/cli/profiles/<name>/<cli>` (`CODEX_HOME` or `CLAUDE_CONFIG_DIR`), with
303
+ its own credentials, configuration and native sessions. The unnamed default
304
+ keeps the bindings above unchanged. Create the empty home and get the exact
305
+ native login command with:
306
+
307
+ ```sh
308
+ ezenciel-agents-ai profile add --cli claude --auth-profile work
309
+ ```
310
+
311
+ Then the owner logs in once with the printed command, inside the relay for
312
+ isolated agents. Claude may instead use a `claude setup-token` token in that
313
+ home's `oauth-token` (mode `600`). Ez never reads, copies or prints the
314
+ credential. A named profile never links the host login or another home: an
315
+ unprovisioned or logged-out profile fails closed with the native error.
316
+ `ezenciel-agents-ai profiles` reports each home's login status (logged in,
317
+ method, credential file names) from the native CLI without tokens or account
318
+ identifiers.
319
+
320
+ Select a profile with `ezenciel-agents-ai select --cli claude --auth-profile work`,
321
+ the profile entries in Choose AI, or `ezenciel-agents-schedule create ...
322
+ --auth-profile work`. A conversation is bound to its CLI and profile; changing
323
+ profile starts a new native conversation, and a run whose request names another
324
+ profile than its captured choice is rejected before launch.
325
+
298
326
  ## OpenCode auth
299
327
 
300
328
  Isolated agents run the image OpenCode with `XDG_DATA_HOME` set to the
@@ -55,6 +55,47 @@ Paused, stale-owner, overlapping or held tasks and unmet conditions cannot be
55
55
  triggered. Historical deferred tasks keep their source context
56
56
  available through `ezenciel-agents-schedule context`.
57
57
 
58
+ ## Ordered setup fallback
59
+
60
+ An agent task may list up to three fallback setups after its saved one, for
61
+ example `claude · opus → claude · sonnet → codex · gpt-6-sol`:
62
+
63
+ ```sh
64
+ ezenciel-agents-schedule create --name Daily --cli claude --model opus \
65
+ --cron '0 9 * * *' --timezone Asia/Dubai --text 'Prepare the daily report.' \
66
+ --fallback cli=claude,model=sonnet,effort=medium --fallback cli=codex,model=gpt-6-sol
67
+ ```
68
+
69
+ `edit` keeps the saved fallbacks unless `--fallback` replaces them or
70
+ `--clear-fallbacks` removes them. Setups must be distinct and each needs a model.
71
+
72
+ Core advances to the next setup only with typed evidence that nothing ran: the
73
+ CLI is not installed or executable, or the provider rejected the turn before any
74
+ model output, tool or hook item (quota/rate limit, billing, overload, server
75
+ error, unavailable model or bad request). It stops, without replay, on access
76
+ denial (authentication, account or organization), cancellation, relay shutdown,
77
+ a schedule edit, a policy or unknown error, an interrupted transport, or any
78
+ failure after work began. Evidence comes from structured events only: Codex
79
+ native-session `codexErrorInfo` and Claude `stream-json` error fields (scheduled
80
+ Claude runs use `--output-format stream-json`; the host forwards only a compact
81
+ startup summary). Other engines advance only when their CLI is unavailable.
82
+
83
+ A quota rejection skips the remaining setups in the same quota scope: the native
84
+ client (`claude`, `codex` including desktop and custom Codex providers, `grok`)
85
+ or the upstream provider for OpenCode/Pi models. A different credential alone is
86
+ never treated as an independent allowance. Each setup runs at most once per
87
+ occurrence.
88
+
89
+ Every attempt keeps the same run ID, occurrence, schedule revision, workspace,
90
+ delivery binding and scheduled concurrency slot, in a fresh native session.
91
+ `run RUN_ID` shows `attempts`: each setup, quota scope, outcome, failure category
92
+ (`cli-unavailable`, `quota`, `provider-rejected`, `access-denied`,
93
+ `after-work-began`, `uncertain`, or `same-quota-scope` for a skipped setup), exit
94
+ code and reported reset time. If every setup is unavailable the run fails with
95
+ reason `setups-unavailable`, a summary of each setup, and an owner notice on
96
+ Telegram-bound tasks. Core never purchases credits, enables overage or consumes
97
+ reset credits; the next regular occurrence runs normally.
98
+
58
99
  ## Registered script schedules
59
100
 
60
101
  A schedule has one execution type. **Agent** schedules run the saved prompt with
package/docs/upgrades.md CHANGED
@@ -16,7 +16,7 @@ automatic channel for core and plugins without a saved policy. Existing explicit
16
16
  stable or manual policies are preserved. The owner may select either per target.
17
17
  The main target and installed plugins version independently.
18
18
 
19
- Queued upgrades leave channel admission open while native work runs. The supervisor pauses admission only after work is idle and releases that pause if a turn raced the check.
19
+ Core upgrades leave channel admission open while native work runs, then pause it after work is idle and recheck races. Plugin upgrades build while native work continues. Activation closes the existing registry admission lock, drains in-flight plugin command leases, and replaces the plugin under that lock. New native plugin calls wait for admission; an outdated revision fails before provider execution and must be rediscovered. No admitted provider operation is replayed. The shared broker and native sessions remain running. Core upgrades still require the full native-work drain. A plugin update also retains that drain if an operator replaced the broker’s single-file host binding (for example to approve new network pins), because that case still requires broker recreation.
20
20
 
21
21
  The owner chooses the saved update policy. The host supervisor owns
22
22
  interruption-safe replacement. There is one host service per agent, not a second
@@ -153,9 +153,7 @@ is required. Stable automation excludes prereleases, major changes and 0.x minor
153
153
  changes. Beta policy permits compatible prereleases. Downgrades are rejected;
154
154
  rollback is a recovery operation for the previous installation only.
155
155
 
156
- `apply` records one durable queued job. **The agent must finish its turn after
157
- queuing it.** It must not wait for completion in that same turn. The supervisor
158
- pauses new run admission, drains already-started work, then consumes the job.
156
+ `apply` records one durable queued job. **For a core upgrade, the agent must finish its turn after queuing it** so the supervisor can drain native work. A plugin upgrade can complete while that turn continues; only plugin-call admission is held during activation. Calls already executing finish first.
159
157
  One job at a time; an unresolved recovery blocks new upgrades. The agent processes
160
158
  multiple requested packages over successive update requests.
161
159
 
@@ -294,12 +292,8 @@ Actual update transactions retain their existing admission, drain and rollback r
294
292
 
295
293
  ### Isolated broker readiness
296
294
 
297
- Isolated activation and rollback explicitly recreate only `plugin-broker` with
298
- `--no-deps --force-recreate --no-build --pull never --wait`. Plugin-only refresh
299
- pins the installed broker image ID; main activation uses the newly built image
300
- and rollback uses the recorded old image. This refreshes the narrow read-only
301
- host-configuration file mount after atomic replacement. The relay and native
302
- sessions are preserved during plugin-only refresh. Host-capable updates do not
295
+ Isolated core activation and rollback explicitly recreate only `plugin-broker` with
296
+ `--no-deps --force-recreate --no-build --pull never --wait`. Core activation uses the newly built image and rollback uses the recorded old image to refresh the single-file host binding. Plugin-only activation and rollback change directory-mounted registry files, verify the existing broker in place, and refresh its generated plugin-version inventory. They preserve the broker socket, relay and native sessions. Host-capable updates do not
303
297
  start a broker. Existing update admission and drain remain authoritative.
304
298
 
305
299
  Socket health is liveness only. Before reporting completion, the updater runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.48",
3
+ "version": "0.1.0-beta.49",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
package/src/ai-cli.ts CHANGED
@@ -1,38 +1,51 @@
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'
5
- import { readModels, presetProvider, validateSelection, type AiPreset } from './ai.js'
4
+ import { mkdir, readFile } from 'node:fs/promises'
5
+ import { authProfileStatus, readModels, presetProvider, sameChoice, sameEngine, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
6
+ import { cliHome } from './auth-profile.js'
6
7
  import { ControlStore } from './control-state.js'
7
8
 
8
- const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},provider:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
9
+ const usage='Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--auth-profile <name>] [--provider <provider>] [--model <model>] [--effort <effort>] | default | profiles | profile add --cli codex|claude --auth-profile <name>'
10
+ const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},'auth-profile':{type:'string'},provider:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
9
11
  if(values.help){
10
- console.log('Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>] | default\nChoose only values returned by list. Selection affects subsequent messages; default makes the selected AI the choice for new conversations. Queued work is unchanged.')
12
+ console.log(`${usage}\nChoose only values returned by list. Selection affects subsequent messages; default makes the selected AI the choice for new conversations. Queued work is unchanged.\nAuth profiles are separate owner-provisioned Codex/Claude logins with their own configuration and sessions; changing profile starts a new conversation. profile add creates an empty home and prints the native login command for the owner to run; profiles reports read-only login status without secrets.`)
11
13
  process.exit(0)
12
14
  }
13
15
  if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
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'),undefined,undefined,undefined,process.env.EZ_CONTROL_DIR))
16
- if(positionals[0]==='list')console.log(JSON.stringify(catalog))
16
+ const controlDir=process.env.EZ_CONTROL_DIR
17
+ const authProfile=values['auth-profile']
18
+ const catalog=():Promise<ModelChoice[]>=>readFile(join(controlDir,'host-executor','models.json'),'utf8')
19
+ .then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(controlDir,'cli','codex'),undefined,undefined,undefined,controlDir))
20
+ if(positionals[0]==='list')console.log(JSON.stringify(await catalog()))
21
+ else if(positionals[0]==='profiles')console.log(JSON.stringify(await authProfileStatus(controlDir)))
22
+ else if(positionals[0]==='profile' && positionals[1]==='add'){
23
+ if(!authProfile)throw new Error('profile add requires --auth-profile <name>')
24
+ const home=cliHome(controlDir,values.cli||'',authProfile)
25
+ const created=await mkdir(home,{recursive:true,mode:0o700})
26
+ const login=values.cli==='codex' ? `CODEX_HOME=${home} codex login`
27
+ : `CLAUDE_CONFIG_DIR=${home} claude auth login (or store a \`claude setup-token\` token in ${home}/oauth-token, mode 600)`
28
+ console.log(JSON.stringify({cli:values.cli,authProfile,home,created:created!==undefined,ownerLogin:login}))
29
+ }
17
30
  else if(positionals[0]==='default'){
18
- const control=new ControlStore(process.env.EZ_CONTROL_DIR,900000)
31
+ const control=new ControlStore(controlDir,900000)
19
32
  const state=await control.status()
20
33
  const selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
21
34
  if(!selected)throw new Error('No selected AI')
22
- await validateSelection(selected,catalog)
35
+ await validateSelection(selected,await catalog())
23
36
  await control.defaultPreset(selected.id)
24
37
  console.log(JSON.stringify({default:selected,applies:'new conversations; current and queued work unchanged'}))
25
38
  }
26
39
  else if(positionals[0]==='select'){
27
- 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}
28
- await validateSelection(preset,catalog)
29
- const control=new ControlStore(process.env.EZ_CONTROL_DIR,900000)
40
+ const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.provider,values.model||values.cli,values.effort].filter(Boolean).join(' · ')+(authProfile ? ` @${authProfile}` : ''),cli:values.cli||'',provider:values.provider,...(authProfile ? {authProfile} : {}),model:values.model,effort:values.effort}
41
+ await validateSelection(preset,await catalog())
42
+ const control=new ControlStore(controlDir,900000)
30
43
  const state=await control.status()
31
44
  if(!state.ai)throw new Error('Agent AI settings are not initialized')
32
- const existing=state.ai.presets.find(p=>p.cli===preset.cli&&p.provider===preset.provider&&p.model===preset.model&&p.effort===preset.effort)
45
+ const existing=state.ai.presets.find(p=>sameChoice(p,preset))
33
46
  const selected=existing||preset
34
47
  if(!existing)await control.savePreset(selected)
35
48
  const current=state.ai.presets.find(p=>p.id===state.ai!.selectedId)!
36
- await control.selectPreset(selected.id,state.activeSession?.sessionId??null,current.cli!==selected.cli||presetProvider(current)!==presetProvider(selected)||Boolean(state.activeSession&&!state.activeSession.cli))
49
+ await control.selectPreset(selected.id,state.activeSession?.sessionId??null,!sameEngine(current,selected)||presetProvider(current)!==presetProvider(selected)||Boolean(state.activeSession&&!state.activeSession.cli))
37
50
  console.log(JSON.stringify({selected,defaultUnchanged:true,applies:'subsequent messages; queued work keeps its captured choice'}))
38
- }else throw new Error('Use list, select --cli <installed-cli> [--provider <provider>] [--model <model>] [--effort <effort>], or default')
51
+ }else throw new Error(usage)
package/src/ai.ts CHANGED
@@ -5,12 +5,20 @@ import { execFile } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
6
  import { homedir } from 'node:os'
7
7
  import { join, delimiter } from 'node:path'
8
- import { executorEnvironment, executorInvocation, executorKey, resolveExecutor } from './executor.js'
8
+ import { CliUnavailableError, executorEnvironment, executorInvocation, executorKey, resolveExecutor } from './executor.js'
9
9
  import { desktopCodexPath } from './desktop-bridge.js'
10
+ import { AUTH_PROFILE_CLIS, claudeAuthEnvironment, cliHome, isAuthProfile, listAuthProfiles } from './auth-profile.js'
10
11
 
11
- export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
12
- export type ExecutionChoice = { sessionId: string; preset: AiPreset }
13
- export type ModelChoice = { cli: string; provider?: string; model?: string; name: string; efforts: string[] }
12
+ export type AiPreset = { id: string; name: string; cli: string; provider?: string; authProfile?: string; model?: string; effort?: string }
13
+ // Optional ordered fallbacks are tried only for a scheduled occurrence that never started work.
14
+ export type ExecutionChoice = { sessionId: string; preset: AiPreset; fallbacks?: AiPreset[] }
15
+ export const MAX_FALLBACKS = 3
16
+ export type ModelChoice = { cli: string; provider?: string; authProfile?: string; model?: string; name: string; efforts: string[] }
17
+ type Engine = { cli?: string; authProfile?: string }
18
+ // A native conversation belongs to one client and one credential home.
19
+ export const sameEngine = (a: Engine, b: Engine) => a.cli === b.cli && a.authProfile === b.authProfile
20
+ export const sameChoice = (a: Omit<AiPreset, 'id' | 'name'>, b: Omit<AiPreset, 'id' | 'name'>) =>
21
+ sameEngine(a, b) && a.provider === b.provider && a.model === b.model && a.effort === b.effort
14
22
  const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
15
23
  const knownClis = ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'pi']
16
24
  const nativeEffort = (cli: string, effort: string) =>
@@ -20,13 +28,15 @@ export const isPreset = (p: unknown): p is AiPreset => {
20
28
  const v = p as AiPreset
21
29
  return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
22
30
  knownClis.includes(v.cli) &&
23
- (v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
31
+ (v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort)) &&
32
+ (v.authProfile === undefined || (isAuthProfile(v.authProfile) && AUTH_PROFILE_CLIS.includes(v.cli)))
24
33
  }
25
34
  export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
26
35
  const c = v as ExecutionChoice | undefined
27
- return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
36
+ return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset) &&
37
+ (c.fallbacks === undefined || (Array.isArray(c.fallbacks) && c.fallbacks.length > 0 && c.fallbacks.length <= MAX_FALLBACKS && c.fallbacks.every(isPreset))))
28
38
  }
29
- export const presetLabel = (p: AiPreset) => `${p.cli}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
39
+ export const presetLabel = (p: AiPreset) => `${p.cli}${p.authProfile ? `@${p.authProfile}` : ''}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
30
40
  // OpenCode encodes its provider in the native provider/model identifier.
31
41
  export const presetProvider = (p: AiPreset) => p.cli === 'opencode' ? p.model?.split('/')[0] : p.provider
32
42
  // The seed delegates model selection to the native client. Project its resolved
@@ -206,7 +216,11 @@ export const readModels = async (home = homedir(), available = installed, codexH
206
216
  // establish which models Pi can execute.
207
217
  if (!allow && await available('pi')) models.push({ cli: 'pi', name: 'Pi · client default', efforts: [] })
208
218
  const curated = await readCuratedModels(curationDir)
209
- if (!curated.length) return models
219
+ const profiles = await listAuthProfiles(curationDir)
220
+ // Each provisioned profile offers its client's entries under its own home.
221
+ const withProfiles = (listed: ModelChoice[]) => [...listed, ...profiles.flatMap(({ cli, authProfile }) =>
222
+ listed.filter((model) => model.cli === cli).map((model) => ({ ...model, authProfile, name: `${authProfile} · ${model.name}`.slice(0, 80) })))]
223
+ if (!curated.length) return withProfiles(models)
210
224
  const scoped: ModelChoice[] = []
211
225
  for (const entry of curated) {
212
226
  if (!(await available(entry.cli))) continue
@@ -216,7 +230,7 @@ export const readModels = async (home = homedir(), available = installed, codexH
216
230
  }
217
231
  const key = (m: { cli: string; provider?: string; model?: string }) => `${m.cli}‖${m.provider ?? ''}‖${m.model ?? ''}`
218
232
  const curatedKeys = new Set(scoped.map(key))
219
- return [...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))]
233
+ return withProfiles([...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))])
220
234
  }
221
235
  export const opencodeCatalogModels = async (
222
236
  opencodeRunner?: (args: string[]) => Promise<string>,
@@ -252,9 +266,59 @@ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env):
252
266
 
253
267
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
254
268
  assertEffort(p.effort, p.model, p.cli)
255
- if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
256
- if (!p.provider && !p.model && !p.effort) return
257
- const model = catalog.find((m) => m.cli === p.cli && m.provider === p.provider && m.model === p.model)
269
+ if (!isPreset(p)) throw new Error('This CLI is not installed.')
270
+ if (!(await available(p.cli))) throw new CliUnavailableError('This CLI is not installed.')
271
+ if (!p.provider && !p.authProfile && !p.model && !p.effort) return
272
+ if (p.authProfile && !p.provider && !p.model && !p.effort) {
273
+ if (!catalog.some((m) => sameEngine(m, p))) throw new Error('This auth profile is not provisioned for the selected CLI.')
274
+ return
275
+ }
276
+ const model = catalog.find((m) => sameEngine(m, p) && m.provider === p.provider && m.model === p.model)
258
277
  if (!model || (p.effort && !model.efforts.includes(p.effort)))
259
278
  throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
260
279
  }
280
+
281
+ export type AuthProfileStatus = { cli: string; authProfile: string | null; provisioned: boolean; sharesHostLogin: boolean
282
+ credentialFiles: string[]; loggedIn: boolean | null; method: string | null }
283
+ type StatusRunner = (command: string, args: string[], env: NodeJS.ProcessEnv, cwd: string) => Promise<{ code: number | null; stdout: string; stderr: string }>
284
+ const nativeStatus: StatusRunner = (command, args, env, cwd) => new Promise((resolve) => {
285
+ const invocation = executorInvocation(command, args)
286
+ execFile(invocation.command, invocation.args, { env, cwd, timeout: 8000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) =>
287
+ resolve({ code: error ? (typeof error.code === 'number' ? error.code : null) : 0, stdout: String(stdout), stderr: String(stderr) }))
288
+ })
289
+ const credentialNames: Record<string, string[]> = { codex: ['auth.json'], claude: ['oauth-token', '.credentials.json'] }
290
+
291
+ // Read-only auth diagnostics per credential home. Native status runs with the
292
+ // same home and credential environment as execution; only whitelisted fields
293
+ // are projected, never tokens, account identifiers or raw native output.
294
+ export const authProfileStatus = async (controlDir: string, available = installed, run = nativeStatus,
295
+ isolated = process.env.EZ_ISOLATION === 'isolated'): Promise<AuthProfileStatus[]> => {
296
+ const homes: { cli: string; authProfile?: string }[] = [...AUTH_PROFILE_CLIS.map((cli) => ({ cli })), ...await listAuthProfiles(controlDir)]
297
+ const statuses: AuthProfileStatus[] = []
298
+ for (const { cli, authProfile } of homes) {
299
+ if (!authProfile && !(await available(cli))) continue
300
+ const home = cliHome(controlDir, cli, authProfile)
301
+ const provisioned = await access(home).then(() => true, () => false)
302
+ const credentialFiles: string[] = []
303
+ for (const name of credentialNames[cli]) if (await access(join(home, name)).then(() => true, () => false)) credentialFiles.push(name)
304
+ let loggedIn: boolean | null = null, method: string | null = null
305
+ if (provisioned) {
306
+ try {
307
+ if (cli === 'codex') {
308
+ const result = await run('codex', ['login', 'status'], { ...executorEnvironment(), CODEX_HOME: home }, home)
309
+ loggedIn = result.code === null ? null : result.code === 0
310
+ // Codex prints its login status on stderr.
311
+ const text = result.stdout + result.stderr
312
+ method = loggedIn ? /chatgpt/i.test(text) ? 'chatgpt' : /api key/i.test(text) ? 'api-key' : 'other' : null
313
+ } else {
314
+ const result = await run('claude', ['auth', 'status', '--json'], { ...executorEnvironment(), ...await claudeAuthEnvironment(home, authProfile, isolated) }, home)
315
+ const status = result.code === null ? undefined : JSON.parse(result.stdout) as { loggedIn?: unknown; authMethod?: unknown }
316
+ loggedIn = status ? status.loggedIn === true : null
317
+ method = loggedIn && safe(status?.authMethod) ? status.authMethod : null
318
+ }
319
+ } catch { loggedIn = false }
320
+ }
321
+ statuses.push({ cli, authProfile: authProfile ?? null, provisioned, sharesHostLogin: !isolated && !authProfile, credentialFiles, loggedIn, method })
322
+ }
323
+ return statuses
324
+ }
@@ -6,7 +6,7 @@ import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owne
6
6
  import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
7
7
  import { ownsRun } from './identity.js'
8
8
  import { applicationId, validApplicationOrigin } from './application-origin.js'
9
- import { isPreset, type AiPreset, type ModelChoice } from './ai.js'
9
+ import { isPreset, sameChoice, sameEngine, type AiPreset, type ModelChoice } from './ai.js'
10
10
  import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
11
11
  import { assertEffort } from './model-policy.js'
12
12
  import { ApprovalStore } from './approval.js'
@@ -158,7 +158,7 @@ export class ApplicationChannel {
158
158
  const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
159
159
  if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
160
160
  !(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
161
- const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
161
+ const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
162
162
  if (Object.keys(value).some(key => !['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
163
163
  const control = new ControlStore(this.options.controlDir, 900000)
164
164
  const expected = value.expectedSession as string | null
@@ -181,7 +181,7 @@ export class ApplicationChannel {
181
181
  await controls.select(preset, expected, guard)
182
182
  }
183
183
  if (value.action === 'model') {
184
- const model = (await controls.catalog()).find(item => item.cli === value.cli && item.provider === value.provider && item.model === value.model)
184
+ const model = (await controls.catalog()).find(item => sameEngine(item, value as {cli?:string;authProfile?:string}) && item.provider === value.provider && item.model === value.model)
185
185
  if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
186
186
  await this.sharedBinding(bindingId)
187
187
  const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
@@ -211,10 +211,10 @@ export class ApplicationChannel {
211
211
  const owner = await this.sharedBinding(bindingId, false)
212
212
  const controls = this.options.aiControls
213
213
  if (!controls) throw new Error('Application controls unavailable')
214
- const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; model?:unknown; effort?:unknown}
214
+ const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; authProfile?:unknown; model?:unknown; effort?:unknown}
215
215
  if (!value || !['new','select','model'].includes(String(value.action)) ||
216
216
  !(value.expectedSession===null || (typeof value.expectedSession==='string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
217
- const fields = {new:[],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'select'|'model']!
217
+ const fields = {new:[],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'select'|'model']!
218
218
  if (Object.keys(value).some(key=>!['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
219
219
  const hashedScope = applicationScope(bindingId,scope)
220
220
  const guard:ControlGuard = {owner,applicationScope:hashedScope,expectedSession:value.expectedSession as string|null,
@@ -231,7 +231,7 @@ export class ApplicationChannel {
231
231
  await controls.validate(preset)
232
232
  }
233
233
  if (value.action==='model') {
234
- const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.provider===value.provider && item.model===value.model)
234
+ const model = (await controls.catalog()).find(item=>sameEngine(item,value as {cli?:string;authProfile?:string}) && item.provider===value.provider && item.model===value.model)
235
235
  if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
236
236
  preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
237
237
  }
@@ -257,7 +257,7 @@ export class ApplicationChannel {
257
257
  const run=await this.runs.create({id,ownerId:ownerId(access.task.owner),ownerEpoch:ownerEpoch(access.task.owner),taskId:access.task.id,texts:[v.text],execution:access.execution,application:{bindingId,scope,requestId:v.requestId,taskAccess:ticketHash}})
258
258
  this.options.wake();return run
259
259
  }
260
- 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 } }
260
+ 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; authProfile?: unknown; model?: unknown; effort?: unknown } }
261
261
  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')
262
262
  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')
263
263
  if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
@@ -266,7 +266,7 @@ export class ApplicationChannel {
266
266
  let requestedPreset: AiPreset | undefined
267
267
  if (value.ai !== undefined) {
268
268
  const candidate = { ...value.ai, id: 'application', name: 'Application selection' }
269
- if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
269
+ if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'authProfile', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
270
270
  assertEffort(candidate.effort, candidate.model, candidate.cli)
271
271
  requestedPreset = candidate
272
272
  }
@@ -289,7 +289,7 @@ export class ApplicationChannel {
289
289
  const existing = await this.runs.get(id)
290
290
  if (existing) {
291
291
  await this.bindings.authorize(existing)
292
- 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')
292
+ if (requestedPreset && (!existing.execution || !sameChoice(existing.execution.preset, requestedPreset))) throw new Error('Application request ID conflicts with prior AI selection')
293
293
  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')
294
294
  return existing // Retried context never replaces already admitted capabilities.
295
295
  }
@@ -0,0 +1,53 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ // Owner-provisioned named credential homes beside the agent default binding.
5
+ // Unset keeps control/cli/<cli>; a name selects control/cli/profiles/<name>/<cli>
6
+ // with its own credentials, configuration and native sessions. Ez never stores,
7
+ // copies or prints credentials; the native CLI logs in inside the home.
8
+ export const AUTH_PROFILE_CLIS: readonly string[] = ['codex', 'claude']
9
+ export const isAuthProfile = (value: unknown): value is string =>
10
+ typeof value === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value)
11
+
12
+ export const cliHome = (controlDir: string, cli: string, authProfile?: string): string => {
13
+ if (authProfile === undefined) return path.join(controlDir, 'cli', cli)
14
+ if (!isAuthProfile(authProfile) || !AUTH_PROFILE_CLIS.includes(cli)) throw new Error('Invalid auth profile selection')
15
+ return path.join(controlDir, 'cli', 'profiles', authProfile, cli)
16
+ }
17
+
18
+ const realDirectory = (directory: string) => lstat(directory).then(stat => stat.isDirectory(), () => false)
19
+
20
+ // A named home exists only once the owner provisioned it as a real directory.
21
+ // Execution never creates, links or falls back from it.
22
+ export const provisionedHome = async (controlDir: string, cli: string, authProfile?: string): Promise<string> => {
23
+ const home = cliHome(controlDir, cli, authProfile)
24
+ if (authProfile !== undefined && !await realDirectory(home)) throw new Error(`Auth profile ${authProfile} is not provisioned for ${cli}`)
25
+ return home
26
+ }
27
+
28
+ export const listAuthProfiles = async (controlDir?: string): Promise<{ cli: string; authProfile: string }[]> => {
29
+ if (!controlDir) return []
30
+ const names = await readdir(path.join(controlDir, 'cli', 'profiles')).catch(() => [] as string[])
31
+ const found: { cli: string; authProfile: string }[] = []
32
+ for (const authProfile of names.filter(isAuthProfile).sort())
33
+ for (const cli of AUTH_PROFILE_CLIS)
34
+ if (await realDirectory(cliHome(controlDir, cli, authProfile))) found.push({ cli, authProfile })
35
+ return found
36
+ }
37
+
38
+ // Claude credentials for one home. Host-capable default homes share the
39
+ // installer login in place; named homes and isolated agents use only their
40
+ // own store, plus an optional owner-provisioned long-lived `oauth-token`.
41
+ export const claudeAuthEnvironment = async (home: string, authProfile?: string, isolated = process.env.EZ_ISOLATION === 'isolated'): Promise<NodeJS.ProcessEnv> => {
42
+ const environment: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: home }
43
+ if (!isolated && authProfile === undefined) environment.CLAUDE_SECURESTORAGE_CONFIG_DIR = ''
44
+ const token = await readFile(path.join(home, 'oauth-token'), 'utf8').then(value => value.trim(), (error: NodeJS.ErrnoException) => {
45
+ if (error.code === 'ENOENT') return undefined
46
+ throw error
47
+ })
48
+ if (token !== undefined) {
49
+ if (!/^[A-Za-z0-9._-]{20,512}$/.test(token)) throw new Error('Invalid Claude token binding')
50
+ environment.CLAUDE_CODE_OAUTH_TOKEN = token
51
+ }
52
+ return environment
53
+ }
@@ -5,6 +5,7 @@ import { createInterface } from 'node:readline'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import path from 'node:path'
7
7
  import { terminateJob, validateCodexProvider, type CodexProviderBinding } from './executor.js'
8
+ import { codexRejection, codexResetAt, startupLine } from './setup-fallback.js'
8
9
 
9
10
  type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external';codexProvider?:CodexProviderBinding}
10
11
  type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
@@ -17,7 +18,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
17
18
  const provider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
18
19
  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']})
19
20
  const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
20
- let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false
21
+ let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false,workBegan=false,resetAt:string|undefined
21
22
  let resolveDone!:(code:number)=>void
22
23
  const done=new Promise<number>(resolve=>{resolveDone=resolve})
23
24
  const pending=new Map<number,{resolve:(value:any)=>void;reject:(error:Error)=>void;timer:ReturnType<typeof setTimeout>}>()
@@ -38,7 +39,10 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
38
39
  finish(0)
39
40
  }
40
41
  child.stderr?.pipe(process.stderr)
41
- child.on('error',fail)
42
+ child.on('error',error=>{
43
+ if((error as NodeJS.ErrnoException).code==='ENOENT')emit(startupLine({workBegan:false,rejection:{category:'cli-unavailable'}}))
44
+ fail(error)
45
+ })
42
46
  child.stdin?.on('error',fail)
43
47
  child.once('close',()=>{
44
48
  for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Codex app-server closed'))}
@@ -57,16 +61,23 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
57
61
  // Never turn an unexpected approval/elicitation request into permission.
58
62
  send({id:message.id,error:{code:-32601,message:`Unsupported unattended request: ${message.method}`}});fail(new Error(`Codex requires attention: ${message.method}`));return
59
63
  }
64
+ if(message.method==='account/rateLimits/updated')resetAt=codexResetAt(message.params?.rateLimits) ?? resetAt
60
65
  if(message.params?.threadId!==threadId)return
61
66
  if(message.method==='turn/started'){activeTurn=message.params.turn.id;sawTurn=true}
67
+ // Any item beyond the echoed input means the provider accepted the turn.
68
+ if(message.method==='item/started' && message.params.item?.type!=='userMessage' && !workBegan){workBegan=true;emit(startupLine({workBegan}))}
62
69
  if(message.method==='thread/goal/updated')settled(message.params.goal)
63
70
  if(message.method==='thread/goal/cleared')settled(null)
64
71
  if(message.method==='turn/completed'){
65
72
  if(activeTurn===message.params.turn.id)activeTurn=undefined
66
73
  if(message.params.turn.status!=='completed'){
67
74
  if(message.params.turn.status==='interrupted')finish(130)
68
- else fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
75
+ else {
76
+ const category=workBegan ? undefined : codexRejection(message.params.turn.error?.codexErrorInfo)
77
+ if(category)emit(startupLine({workBegan,rejection:{category,...(category==='quota' && resetAt ? {resetAt} : {})}}))
78
+ fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
69
79
  ? message.params.turn.error.message : 'Native turn failed without error details')))
80
+ }
70
81
  return
71
82
  }
72
83
  // Completion of a turn is not completion of a native goal. The native