@jc_stack/ez-agents 0.1.0-beta.47 → 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.
Files changed (80) hide show
  1. package/.env.example +3 -0
  2. package/AGENTS.md +2 -2
  3. package/CHANGELOG.md +30 -0
  4. package/Dockerfile +5 -1
  5. package/THIRD_PARTY_NOTICES.md +6 -3
  6. package/bin/ez +7 -0
  7. package/docker/broker-readiness.mjs +54 -0
  8. package/docker/broker-update-smoke.mjs +100 -0
  9. package/docker/run.ts +1 -1
  10. package/docker/upgrade-smoke.mjs +1 -1
  11. package/docs/application-channel.md +32 -1
  12. package/docs/architecture/ai-selection.md +23 -4
  13. package/docs/docker-runtime.md +57 -0
  14. package/docs/isolated-plugin-update-readiness.md +66 -0
  15. package/docs/releasing.md +4 -0
  16. package/docs/scheduling.md +141 -6
  17. package/docs/selective-monitoring.md +13 -0
  18. package/docs/upgrades.md +33 -4
  19. package/docs/work-preflight.md +9 -0
  20. package/package.json +1 -1
  21. package/src/ai-cli.ts +28 -15
  22. package/src/ai.ts +98 -15
  23. package/src/application-channel.ts +52 -12
  24. package/src/application-cli.ts +7 -1
  25. package/src/application-origin.ts +2 -0
  26. package/src/audio.ts +37 -22
  27. package/src/auth-profile.ts +53 -0
  28. package/src/client-defaults.ts +3 -2
  29. package/src/codex-session.ts +14 -3
  30. package/src/config.ts +9 -0
  31. package/src/control-state.ts +31 -22
  32. package/src/delivery-socket-client.d.mts +2 -0
  33. package/src/delivery-socket-client.mjs +2 -0
  34. package/src/delivery-socket.ts +26 -1
  35. package/src/event-sources.ts +2 -2
  36. package/src/executor.ts +81 -7
  37. package/src/files.ts +47 -3
  38. package/src/host-executor.ts +68 -34
  39. package/src/index.ts +227 -110
  40. package/src/install-tools.mjs +8 -5
  41. package/src/menu.ts +9 -7
  42. package/src/plugin-broker.mjs +50 -14
  43. package/src/plugins/manager.mjs +34 -8
  44. package/src/run-evidence.ts +78 -0
  45. package/src/runs.ts +24 -2
  46. package/src/schedule-cli.ts +100 -13
  47. package/src/schedule-preflight.ts +23 -0
  48. package/src/scheduled-tasks.ts +16 -12
  49. package/src/scheduler.ts +85 -19
  50. package/src/scripts.ts +167 -0
  51. package/src/setup-fallback.ts +122 -0
  52. package/src/setup.ts +2 -2
  53. package/src/task-capability.ts +17 -1
  54. package/src/task-executor.ts +13 -9
  55. package/src/task-mcp.ts +20 -8
  56. package/src/tasks.ts +89 -9
  57. package/src/updates/runtime.mjs +107 -30
  58. package/src/updates/supervisor.mjs +27 -6
  59. package/test/ai.test.ts +40 -0
  60. package/test/application-channel.test.ts +16 -1
  61. package/test/audio.test.ts +19 -0
  62. package/test/auth-profiles.test.ts +187 -0
  63. package/test/config.test.ts +8 -0
  64. package/test/executor.test.ts +37 -0
  65. package/test/files.test.ts +44 -0
  66. package/test/host-executor.test.ts +45 -2
  67. package/test/install-json.test.mjs +21 -0
  68. package/test/plugin-broker.test.mjs +67 -0
  69. package/test/plugin-manager.test.mjs +44 -0
  70. package/test/private-speech-config.test.ts +22 -0
  71. package/test/run-evidence.test.ts +78 -0
  72. package/test/schedule-preflight.test.ts +49 -0
  73. package/test/scheduler-host.test.ts +7 -0
  74. package/test/scheduler-relay.test.ts +38 -3
  75. package/test/scheduler.test.ts +1 -1
  76. package/test/script-schedules.test.ts +219 -0
  77. package/test/setup-fallback.test.ts +245 -0
  78. package/test/task-native.test.ts +50 -12
  79. package/test/tasks.test.ts +97 -4
  80. package/test/updates.test.mjs +100 -11
@@ -55,6 +55,112 @@ 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
+
99
+ ## Registered script schedules
100
+
101
+ A schedule has one execution type. **Agent** schedules run the saved prompt with
102
+ their saved engine, model and effort, as described below. **Script** schedules run
103
+ a registered, agent-owned script directly: no model, effort, prompt or native
104
+ session is involved.
105
+
106
+ ```sh
107
+ ezenciel-agents-schedule script register book-freshness \
108
+ --file scripts/book-freshness.mjs --interpreter node [--arg=VALUE ...] [--timeout-seconds 900]
109
+ ezenciel-agents-schedule create actual-book-freshness-guard \
110
+ --script book-freshness [--arg=VALUE ...] --cron '15 * * * *' --timezone Asia/Dubai
111
+ ezenciel-agents-schedule script list | show ID | update ID [...] | remove ID
112
+ ```
113
+
114
+ The agent writes, tests and maintains the script; core owns registration,
115
+ scheduling, authorization, process execution, cancellation and run receipts.
116
+ A registration records the owner binding, a workspace-relative entry point, an
117
+ installed interpreter (command name on the executor PATH or absolute path),
118
+ literal arguments, a timeout and the entry point's SHA-256 under a new revision.
119
+ It references the workspace file; core keeps no source copy. Paths outside the
120
+ workspace (including symlink escapes) and shell command strings are rejected.
121
+ Core invokes `interpreter entry registrationArgs... scheduleArgs...` directly,
122
+ without a shell. Missing interpreters fail clearly; nothing is installed.
123
+
124
+ When an occurrence is queued, the run captures the current registration
125
+ revision and hash. Immediately before spawning, the executor requires that
126
+ revision to be current and the entry-point bytes to match. Changed bytes fail
127
+ the run until the agent explicitly runs `script update`. The hash identifies the
128
+ entry-point file only; it does not certify imported modules or other files.
129
+
130
+ Script runs use the existing scheduler, admission queue, scheduled concurrency
131
+ limit, one-occurrence-per-schedule rule, run records, delivery binding, manual
132
+ `trigger`, pause/resume/edit/remove and `cancel`. They run in the agent's
133
+ isolated environment (relay or host transport) under its normal identity, with
134
+ the executor environment allowlist plus `EZ_RUN_ID`, `EZ_SCHEDULE_ID`,
135
+ `EZ_SCHEDULE_REVISION`, `EZ_DUE_AT`, `EZ_SCRIPT_ID`, `EZ_SCRIPT_REVISION` and
136
+ `EZ_SCRIPT_SHA256`. Use the run/schedule/due identifiers to form a stable
137
+ occurrence identity. External operations go through the bound plugin CLI; a
138
+ script must not read private databases, call providers directly or acquire
139
+ credentials. A script run can read schedule and script state but cannot change
140
+ registrations or schedules.
141
+
142
+ A script runs with the agent's runtime identity and file access, but not inside
143
+ a native engine's own sandbox (for example host Codex `workspace-write`); register
144
+ only code the agent would be allowed to run itself. An interpreter inside the
145
+ workspace is rejected because it would be unhashed code.
146
+
147
+ Each registration has a bounded timeout (default 900 seconds, maximum 21600)
148
+ that does not affect agent runs. Timeout and `cancel` stop the process and its
149
+ children. Failed, timed-out or interrupted script runs are not retried, do not
150
+ fall back to an LLM and do not start a debugging agent. Inspect them with
151
+ `runs`, `run RUN_ID`, `failures` and `evidence`: the run shows the script ID,
152
+ revision and hash, queue/start/finish clocks, exit code, cancellation/timeout,
153
+ failure reason and a 4 KiB redacted tail of stdout/stderr. Those records follow
154
+ the normal relay-memory retention. Stdout is diagnostic only. Process success
155
+ means the script exited 0; domain success still requires the owning plugin's
156
+ validation, receipt and canonical readback, which the script performs and
157
+ records. Notifications use `ezenciel-agents-message` under the normal policy;
158
+ routine unchanged runs should stay quiet.
159
+
160
+ Editing a schedule replaces it completely, so it may switch execution type.
161
+ When replacing an agent schedule with a script, move any judgment the prompt
162
+ performed to its owning workflow first; the script schedule does not carry it.
163
+
58
164
  ## Execution and authority
59
165
 
60
166
  Due occurrences enter the durable queue with stable IDs and literal task text.
@@ -63,17 +169,20 @@ No identity files or role instructions are generated. Existing workspace Markdow
63
169
  provides context, including native `AGENTS.md` discovery and relative links; the
64
170
  engine chooses what to read. Per-run native state stays under `control/`.
65
171
 
66
- One foreground turn and one owner-scheduled turn can run at the same time.
67
- Foreground inputs queue behind foreground work; scheduled turns queue behind
68
- scheduled work. A long scheduled goal therefore does not block chat. Both streams
172
+ One foreground turn and up to six owner-scheduled turns can run at the same time.
173
+ Set `EZ_SCHEDULED_CONCURRENCY` to a positive integer to change the scheduled-job
174
+ limit; it defaults to six. For Docker deployments, put this setting in the
175
+ existing private relay environment file (`EZ_RELAY_ENV_FILE`); no Compose
176
+ layout migration is needed. Foreground inputs queue behind foreground work;
177
+ scheduled turns queue only when their limit is occupied. A long scheduled goal therefore does not block chat. Both streams
69
178
  use the existing admission and executor path, with separate native sessions.
70
179
  They share the agent-owned Markdown workspace: the agent coordinates shared file
71
180
  updates, and plugins enforce canonical record-write guards. The host keeps plugin
72
- workspace invocations excluded until both native turns finish.
181
+ workspace invocations excluded until all native turns finish.
73
182
  The agent decides when to send through the message CLI.
74
183
  A recurring schedule has at most one pending or active occurrence. Shared
75
- provider resources still need writer coordination. The former four background
76
- slots are no longer used for owner schedules.
184
+ provider resources still need writer coordination. This is admission to the existing native executor, not a second queue or
185
+ workflow controller.
77
186
 
78
187
  Production relay/host execution has no wall-clock timeout. The old
79
188
  `EZ_EXECUTOR_TIMEOUT_SECONDS` setting is ignored. Individual network/tool waits
@@ -170,6 +279,32 @@ Create a normal recurring schedule with `--every-seconds 900 --when unreviewed-f
170
279
 
171
280
  `failures [--all] [--limit N]` returns failedAt, reason, exit code, native session, captured error and runtime versions. Capture keeps at most 4 KiB of redacted stderr, including a failed native Codex turn's error message when supplied. A failure without error details remains explicitly unknown; historical failures are not backfilled. `run RUN_ID` reads an owned run. `review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT` records the investigation without rewriting execution history. A stale timestamp is rejected; a later failure needs a new review. Restricted reply, external and isolated-task callers cannot review failures. An attention review is handed off, not repeatedly relaunched; another new failure wakes the next review.
172
281
 
282
+ For a shareable operational projection, use `evidence [RUN_ID] --offset 0 --limit 100`.
283
+ This reads the live relay through its existing authenticated socket, under the
284
+ bound operator or an active unrestricted Telegram owner run. Application/channel,
285
+ external and isolated-task callers cannot use it. It exposes only current-owner
286
+ Telegram foreground/scheduled runs, stable IDs, clocks/status/presets, failure
287
+ metadata without prose, and retained run-associated outbox metadata. No native
288
+ session, prompt, reasoning, tool arguments/results, message text or channel identity
289
+ is returned. Failure text is represented by its SHA-256, not copied or diagnosed.
290
+
291
+ The response carries exact `readStartedAt`/`observedAt`, `snapshotSha256`, and
292
+ coverage total/offset/limit/returned/nextOffset. Follow `nextOffset` with the same
293
+ `--expected SNAPSHOT_SHA256`; changed retained evidence fails closed and requires
294
+ a fresh traversal. Each page recomputes the projection; no snapshot store or
295
+ background capture is added. History resets on relay restart and is bounded to
296
+ 2000 terminal runs and 2000 terminal outbox items; full historical coverage cannot
297
+ be inferred. Unassociated deliveries and excluded scopes remain unknown.
298
+
299
+ Delivery records identify queued/sent/failed/unknown state and whether a retained
300
+ Telegram receipt contains a valid clock and returned message IDs (only their count
301
+ is exported). The submitted UTF-8 text/voice-text hash can be compared to an exact
302
+ canonical source. It does not prove rendered or current recipient-side content;
303
+ attachments are not exported. Empty receipt lists never prove that no delivery
304
+ occurred. The relay does not retain native tool/delegation events, so that coverage
305
+ is explicitly unavailable. This command does not mark failures reviewed, send
306
+ messages, read private sessions, trigger work or certify owning results.
307
+
173
308
  The prompt controls diagnosis, authorized recovery and quiet notification behavior. Inspect prior effects and receipts before retrying anything. A failed review run itself remains visible as a new failure for the next occurrence.
174
309
 
175
310
  The Telegram Scheduled tasks menu lists enabled schedules that still have a pending
@@ -178,3 +178,16 @@ After revocation/expiry no subsequent reply may dispatch. Keep these checks
178
178
  agent-owned: the owner supplies only necessary confirmation, QR scan if needed,
179
179
  and the test contact/message. Do not bypass a missing grant by polling the inbox
180
180
  in an unrestricted scheduled owner session or sending through the raw CLI.
181
+
182
+ Task `send` accepts `voice: true` for spoken replies up to 1,000 characters on sources advertising `taskVoice`. It uses the relay’s configured speech renderer, rechecks authorization after generation, and preserves the same uncertain-send key contract. Synthesis failure happens before the provider is contacted: it returns an error, sends no text and stores no operation, so the same key may be retried. Voice and file attachments cannot be combined.
183
+
184
+ ### Incoming files and images
185
+
186
+ The restricted MCP `read_attachment` tool accepts only an `incomingId` from the
187
+ current run's correspondence. The channel supplies private bounded bytes through
188
+ `task-document`, bound to its account and exact conversation. Core reuses
189
+ `stageChatAttachment` validation (10 MiB) and returns UTF-8 TXT/Markdown or
190
+ PDF text via Poppler, at most 256000 bytes. JPEG/PNG/WebP images return their
191
+ original bounded pixels as MCP image content to the native engine, never an
192
+ extra inference call or a base64 text prompt. It never accepts an owner file path.
193
+ MP4 clips up to 10 MiB and 120 seconds return at most eight sampled JPEG frames, 640 pixels wide and at most 1 MiB combined, through the same native image content. Speech transcripts supplied by the source remain in incoming context. The audited native CLI accepts text and images, not raw video/audio; sampled frames do not capture every motion. Scanned PDFs with no text layer need OCR and return a clear limitation.
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
@@ -93,6 +93,12 @@ same candidate again and apply the new job; do not edit the old receipt. Only
93
93
  Finish the requesting turn after queuing either operation. Verify the main relay
94
94
  before requesting a separate plugin upgrade.
95
95
 
96
+ Codex version discovery parses machine-readable stdout separately from package
97
+ manager notices on stderr. Invalid stdout fails with a named discovery error;
98
+ it is never repaired by stripping arbitrary lines. A historical generic JSON
99
+ parse error alone does not establish which command or output stream caused it.
100
+ Reconcile the job's completed stages and installed state before retrying.
101
+
96
102
  ## Agent interface
97
103
 
98
104
  Telegram `/status` shows the relay's loaded version, fresh host version and
@@ -147,9 +153,7 @@ is required. Stable automation excludes prereleases, major changes and 0.x minor
147
153
  changes. Beta policy permits compatible prereleases. Downgrades are rejected;
148
154
  rollback is a recovery operation for the previous installation only.
149
155
 
150
- `apply` records one durable queued job. **The agent must finish its turn after
151
- queuing it.** It must not wait for completion in that same turn. The supervisor
152
- 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.
153
157
  One job at a time; an unresolved recovery blocks new upgrades. The agent processes
154
158
  multiple requested packages over successive update requests.
155
159
 
@@ -285,3 +289,28 @@ the host or create a repair task. An automatically failed or rolled-back version
285
289
  is not retried until a newer version appears or the owner explicitly retries.
286
290
  Inspect `ez updates check` and `ez updates status` for target diagnostics.
287
291
  Actual update transactions retain their existing admission, drain and rollback rules.
292
+
293
+ ### Isolated broker readiness
294
+
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
297
+ start a broker. Existing update admission and drain remain authoritative.
298
+
299
+ Socket health is liveness only. Before reporting completion, the updater runs
300
+ `docker/broker-readiness.mjs` inside the actual broker, with expected SHA-256
301
+ fingerprints of the host binding, registry and registry configuration. This
302
+ bounded diagnostic checks socket existence, agent/registry ownership, packaged
303
+ source identity, declared command routes, folder grants and reviewed network
304
+ pins. It reads state without rewriting Compose files, executing a plugin,
305
+ creating authorization or supplying `EZ_RUN_ID`. Fingerprints are checked again
306
+ after validation and on the host; a changed binding fails verification.
307
+ `brokerReadiness` and `rollbackBrokerReadiness` in update status are structural
308
+ readbacks at that transaction, not continuous health or native invocation proof.
309
+ A failed refresh or probe enters the existing rollback/recovery path.
310
+
311
+ A genuine authorized native invocation through the bound launcher, with its real
312
+ receipt, remains separate acceptance evidence. Structural readiness and Docker
313
+ health cannot prove provider delivery or a successful native command. Do not
314
+ invent a run ID for that evidence. The synthetic regression is
315
+ `EZ_RELAY_IMAGE=<exact runtime image ID> node docker/broker-update-smoke.mjs`;
316
+ it uses disposable Linux state, no credentials or provider calls.
@@ -0,0 +1,9 @@
1
+ # Plugin-backed scheduled preflight
2
+
3
+ Empty or unchanged provider work can skip a scheduled session. Eligible work uses the schedule's saved execution settings. Core owns admission; installed read-only plugin commands own provider eligibility. Preflight requires the bound plugin broker used by isolated deployments; unavailable broker or provider reads fail closed.
4
+
5
+ A schedule's `preflight` is `{ "on": "eligible" | "changed", "checks": [{ "alias": "registered-read-only-command", "args": ["literal", "arguments"] }] }`.
6
+ The plugin emits `{ "schemaVersion": 1, "eligible": boolean, "fingerprint": "sha256 hex", "observedAt": "ISO timestamp", "count": integer }`.
7
+ Any check eligible admits the owning saved task; changed mode additionally requires a new combined fingerprint. No domain rules or DAG execution live in core. Empty/unchanged checks advance the ordinary schedule cursor without a run/session. Errors retain a sanitized preflight receipt and skip the occurrence; no empty-success fallback.
8
+
9
+ Use `--preflight-file FILE` when creating/editing a native task. Editing preserves its condition unless explicitly replaced; `--clear-preflight` removes it. Preflight receipts remain on the native schedule cursor outside the agent workspace. Manual triggers run the same gate; an ineligible manual request reports unmet preflight and creates no run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.47",
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
@@ -146,7 +156,27 @@ export const readCodexCatalog = async (codexHome: string, run?: (args: string[],
146
156
  try { return JSON.parse(await readFile(join(codexHome, 'models_cache.json'), 'utf8')) } catch { return {} }
147
157
  }
148
158
 
149
- export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex'), opencodeRunner?: (args: string[]) => Promise<string>, opencodeDataHome?: string, opencodeAllowlist?: string[], curationDir?: string, codexRunner?: (args: string[], env: NodeJS.ProcessEnv) => Promise<string>): Promise<ModelChoice[]> => {
159
+ // Claude has no catalog command; its installed help is the native metadata for
160
+ // the selectable model aliases and effort levels. No session or inference runs.
161
+ // Unparseable or unavailable help keeps the client default, never guessed names.
162
+ export const claudeCatalogModels = async (run?: (args: string[]) => Promise<string>): Promise<ModelChoice[]> => {
163
+ const fallback = [{ cli: 'claude', name: 'claude · client default', efforts: [] as string[] }]
164
+ let help: string
165
+ try {
166
+ help = run ? await run(['--help']) : await (async () => {
167
+ const invocation = executorInvocation('claude', ['--help'])
168
+ return (await promisify(execFile)(invocation.command, invocation.args, { env: executorEnvironment(), timeout: 8000, maxBuffer: 1024 * 1024 })).stdout
169
+ })()
170
+ } catch { return fallback }
171
+ const option = (flag: string) => help.split(/\r?\n(?=\s*(?:-\w, )?--)/).find((block) => block.trimStart().replace(/^-\w, /, '').startsWith(`${flag} `))?.replace(/\s+/g, ' ') ?? ''
172
+ const aliases = [...(option('--model').match(/\(e\.g\. ([^)]*)\)/)?.[1] ?? '').matchAll(/'([a-z][a-z0-9-]{0,31})'/g)].map((match) => match[1])
173
+ const levels = (option('--effort').match(/\(([a-z][a-z0-9, -]*)\)\s*$/)?.[1] ?? '').split(',').map((level) => level.trim())
174
+ .filter((level) => safe(level) && allowedEffort(level, undefined, 'claude'))
175
+ if (!aliases.length) return fallback
176
+ return [...fallback, ...[...new Set(aliases)].map((alias) => ({ cli: 'claude', model: alias, name: `claude · ${alias}`, efforts: levels }))]
177
+ }
178
+
179
+ export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex'), opencodeRunner?: (args: string[]) => Promise<string>, opencodeDataHome?: string, opencodeAllowlist?: string[], curationDir?: string, codexRunner?: (args: string[], env: NodeJS.ProcessEnv) => Promise<string>, claudeRunner?: (args: string[]) => Promise<string>): Promise<ModelChoice[]> => {
150
180
  const models: ModelChoice[] = []
151
181
  const record = (value: unknown): Record<string, unknown> =>
152
182
  value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
@@ -179,15 +209,18 @@ export const readModels = async (home = homedir(), available = installed, codexH
179
209
  }))
180
210
  models.push(...(desktop.length ? desktop : [{ cli: 'codex-gui', name: 'codex-gui · desktop', efforts: [] }]))
181
211
  }
182
- // Other adapters expose the authenticated client's default, not a guessed catalog.
183
- if (await available('claude')) models.push({ cli: 'claude', name: 'claude · client default', efforts: [] })
212
+ if (await available('claude')) models.push(...await claudeCatalogModels(claudeRunner))
184
213
  const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
185
214
  if (await available('opencode')) models.push(...await opencodeCatalogModels(opencodeRunner, opencodeDataHome, allow))
186
215
  // Pi has separate provider configuration. OpenCode's catalog cannot
187
216
  // establish which models Pi can execute.
188
217
  if (!allow && await available('pi')) models.push({ cli: 'pi', name: 'Pi · client default', efforts: [] })
189
218
  const curated = await readCuratedModels(curationDir)
190
- 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)
191
224
  const scoped: ModelChoice[] = []
192
225
  for (const entry of curated) {
193
226
  if (!(await available(entry.cli))) continue
@@ -197,7 +230,7 @@ export const readModels = async (home = homedir(), available = installed, codexH
197
230
  }
198
231
  const key = (m: { cli: string; provider?: string; model?: string }) => `${m.cli}‖${m.provider ?? ''}‖${m.model ?? ''}`
199
232
  const curatedKeys = new Set(scoped.map(key))
200
- return [...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))]
233
+ return withProfiles([...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))])
201
234
  }
202
235
  export const opencodeCatalogModels = async (
203
236
  opencodeRunner?: (args: string[]) => Promise<string>,
@@ -233,9 +266,59 @@ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env):
233
266
 
234
267
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
235
268
  assertEffort(p.effort, p.model, p.cli)
236
- if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
237
- if (!p.provider && !p.model && !p.effort) return
238
- 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)
239
277
  if (!model || (p.effort && !model.efforts.includes(p.effort)))
240
278
  throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
241
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
+ }