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

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 (46) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +9 -5
  4. package/bin/ez +16 -7
  5. package/docs/architecture/ai-selection.md +4 -0
  6. package/docs/docker-runtime.md +18 -2
  7. package/docs/plugin-guide.md +3 -3
  8. package/docs/plugins.md +13 -10
  9. package/docs/scheduling.md +55 -18
  10. package/docs/setup.md +2 -2
  11. package/docs/standalone-cli.md +4 -4
  12. package/docs/upgrades.md +9 -13
  13. package/package.json +1 -1
  14. package/src/agent-guidance.ts +2 -2
  15. package/src/ai.ts +17 -5
  16. package/src/application-channel.ts +12 -8
  17. package/src/executor.ts +9 -0
  18. package/src/host-executor-client.ts +19 -7
  19. package/src/index.ts +125 -21
  20. package/src/menu.ts +3 -2
  21. package/src/plugins/manager.mjs +43 -40
  22. package/src/runs.ts +61 -3
  23. package/src/schedule-cli.ts +18 -12
  24. package/src/scheduled-tasks.ts +42 -8
  25. package/src/scheduler.ts +1 -1
  26. package/src/session-close.ts +17 -0
  27. package/src/setup-fallback.ts +62 -25
  28. package/src/tools-line.d.mts +1 -0
  29. package/src/tools-line.mjs +3 -0
  30. package/src/updates/binding.mjs +4 -10
  31. package/src/updates/control.mjs +3 -2
  32. package/src/updates/supervisor.mjs +1 -1
  33. package/src/workspace.ts +12 -1
  34. package/test/ai.test.ts +48 -2
  35. package/test/application-channel.test.ts +24 -0
  36. package/test/application-controls.test.ts +4 -1
  37. package/test/intake-relay.test.ts +63 -2
  38. package/test/model-policy.test.ts +1 -1
  39. package/test/plugin-broker.test.mjs +48 -1
  40. package/test/plugin-manager.test.mjs +52 -38
  41. package/test/run-handoff.test.ts +132 -0
  42. package/test/scheduled-tasks.test.ts +24 -2
  43. package/test/session-close.test.ts +17 -0
  44. package/test/setup-fallback.test.ts +194 -16
  45. package/test/updates.test.mjs +19 -10
  46. package/test/workspace.test.ts +6 -0
package/AGENTS.md CHANGED
@@ -16,11 +16,13 @@ through one generic transport, with separate agent workspaces and sessions.
16
16
 
17
17
  ## Operating-agent instructions
18
18
 
19
- Do not inject a coding handbook into production agent minds. The package-owned
20
- workspace locator is the managed `<!-- ez tools -->` block: the bound `ez`
21
- launcher, `ez --help`, and `ez tools list --details`. Inventory is generated on
22
- read; do not hardcode command recipes. Purpose lives in that agent's own
23
- `AGENTS.md`. Coding defaults for this repository stay in this file and
19
+ Do not inject a coding handbook into production agent minds. The only
20
+ package-written `AGENTS.md` text is the initial workspace seed: purpose, a short
21
+ engine-neutral `memory/` note and one static line pointing at
22
+ `ez tools list --details`. After seeding the agent owns `AGENTS.md`; inventory is
23
+ generated on read, so do not hardcode command recipes. The one transport-level
24
+ lifecycle prompt is the silent conversation-close turn (`src/session-close.ts`),
25
+ which runs only for owner conversations. Coding defaults for this repository stay in this file and
24
26
  CONTRIBUTING.md.
25
27
 
26
28
  ## Installing this package
@@ -105,7 +107,7 @@ If no answer identifies a material blocker, finish. Do not start another review
105
107
  round merely to seek more criticism, tests or hypothetical edge cases.
106
108
 
107
109
  ## 3. Crash-Safe Disk State
108
- - Persistent authority state (`ControlStore` owner/pairing/sessions/presets, application bindings, schedule definitions) stays in disk-backed JSON files. Run/outbox/inbox bookkeeping is relay memory reached through the delivery socket (`src/delivery-socket.ts`): a restart drops in-flight runs and queued deliveries by design, uncertain sends report unknown rather than success, and Telegram redelivery is the replay mechanism. The live delivery socket also replaces the relay flock as the single-relay guard.
110
+ - Persistent authority state (`ControlStore` owner/pairing/sessions/presets, application bindings, schedule definitions) stays in disk-backed JSON files. Run/outbox/inbox bookkeeping is relay memory reached through the delivery socket (`src/delivery-socket.ts`): a restart drops in-flight runs and queued deliveries by design, uncertain sends report unknown rather than success, and Telegram redelivery is the replay mechanism. The one exception: a graceful stop hands running host-transport runs, whose executors outlive the relay, to the next relay through private `control/run-handoff.json`; that relay adopts and watches them and never relaunches one. The live delivery socket also replaces the relay flock as the single-relay guard.
109
111
  - **Atomic write pattern:** Never write directly to a state file. Always write to a temporary file (`${target}.${process.pid}.tmp`) with mode `0o600`, then atomically `rename` it over the destination.
110
112
  - **Path sanitization:** Every ID parameter (`runId`, `outboxId`, `pairingId`) must be validated against `/^[a-zA-Z0-9_-]+$/` before being joined into paths. Never allow directory traversal (`..`).
111
113
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.50
4
+
5
+ - Share memory across engines through the workspace. New workspaces seed a short `## Memory` note (durable facts in `memory/`, one per file, indexed in `memory/MEMORY.md`); every engine loads `AGENTS.md`, so Codex and Claude read the same files. When a chat conversation is replaced (`/new`, the New button, web app new, or an AI/provider switch), its engine gets one silent turn in that native session to save durable facts; nothing is delivered and the engine decides what to write.
6
+ - Drop the managed `<!-- ez tools -->` AGENTS.md footer. The install seed writes one static line ("Tools: run `ez tools list --details`; each plugin's `--help` gives usage."); update binding replaces an existing footer with that line once and otherwise never writes instructions. `ez tools list --details` now prints per plugin `aliases`, `purpose`, `skills`, `skillReads` and one `example` call (optional manifest `example`, else `<alias> --help`).
7
+ - Stop reporting a live plugin call as a stale lease on isolated agents. Leases record their PID namespace; a lease written in the broker container is judged by its call container's running state, so `ez updates apply` no longer refuses during in-flight calls. Apply and recover only queue the job; main activation now drains in-flight calls before switching instead of failing.
8
+ - Keep host-capable native runs alive across a graceful relay restart. The stopping relay hands running host-transport runs to the next relay instead of cancelling them; it adopts and watches each run with its schedule hold, concurrency slot and message delivery, and records its exit status. Nothing is relaunched; a run that cannot be adopted or whose outcome is unknown is recorded failed and interrupted, holding its schedule, and a run that fails adoption is asked to stop. Core upgrades still wait for idle native work; isolated agents, crashes and queued work keep the previous behavior.
9
+ - Continue a scheduled occurrence on its next saved login when Claude stops at a usage/session limit after work began. The next setup starts a fresh native session with the saved prompt plus one factual line naming the run, stopped setup, its native session and reset time. Only a typed quota rejection the run ended on continues; work after a limit, access denial, other failures, cancellation and unknown outcomes still stop without replay. Attempts record `quota-after-work-began` and each Ez-assigned session ID.
10
+ - Stop isolated plugin calls hanging silently before reaching the broker when the caller leaves a socket stdin open (native engine shells, async Node spawns). Pipes and files are still read to EOF; a socket that sends nothing for one second is treated as no input. Remove a uniquely named call container whenever Compose exits non-zero, so a call that stops between create and start no longer strands it in `Created`.
11
+ - Advance scheduled work to its next saved login after a typed authentication rejection before work. Recognize Claude API-error metadata without marking analysis started; account/organization denials, hooks, model/tool work and uncertain outcomes still stop fallback. Preserve the saved chain for the next occurrence.
12
+ - Let scheduled fallback setups name an owner-provisioned native login profile per setup, with no fixed list length. A quota rejection skips later setups on the same login profile while other accounts stay eligible; model/tool/hook work, access denial, cancellation and uncertain failures still stop the chain.
13
+ - Show each scheduled task's frequency and full primary → fallback path (CLI, login profile, provider, model, effort) in Telegram `/tasks` and `/tasks N`.
14
+ - Report the exact AI selection error (missing client models, missing model, unsupported effort) instead of a generic invalid selection, without changing the saved choice.
15
+
16
+ Beta limits: live fresh-host/reboot acceptance, a live host-capable relay recreate during a running native run, two-real-account channel switching and real exhausted-account/provider rejection remain pending. An adopted scheduled occurrence does not advance to fallback setups, and messages a run sends while no relay is up still fail. The conversation close turn is engine-decided and runs only for owner conversations; existing workspaces do not receive the `## Memory` seed note automatically. Plugin manifests may declare `example`; a plugin that does requires core 0.1.0-beta.50 or newer, and older cores refuse that plugin update (manifest validation) and keep the installed version until core upgrades. Publication does not establish an installed-agent upgrade or authorize provider/domain writes.
17
+
3
18
  ## 0.1.0-beta.49
4
19
 
5
20
  - Upgrade compatible plugins while native work continues, draining in-flight plugin calls for activation and preserving rollback. Core updates still wait for idle.
package/README.md CHANGED
@@ -172,7 +172,8 @@ transport, with separate agent workspaces and sessions. See
172
172
 
173
173
  Telegram `/status` shows the running relay and host versions plus installed
174
174
  plugin versions. Its read-only **Scheduled tasks** control lists the paired
175
- owner's active schedules in alphabetical order, with their engine/model/effort,
175
+ owner's active schedules in alphabetical order, with their frequency/timezone
176
+ and ordered CLI/login-profile/model/effort execution path (primary → fallbacks),
176
177
  next occurrence or current queued/running state, and a short preview of the
177
178
  saved invocation prompt; it never creates, changes or runs a task. The agent's `ez status` adds verified plugin
178
179
  runtime states and upgrade job receipts. See [status and upgrades](docs/upgrades.md).
@@ -260,11 +261,14 @@ restart and shutdown. Host-capable agents register the small host CLI transport
260
261
 
261
262
  The selected host CLI runs in this agent's persistent workspace and owns its
262
263
  Markdown/work files. New conversations and AI changes preserve those files.
263
- One compact `AGENTS.md` is seeded with the installer purpose; `MEMORY.md` is
264
- optional. The installing LLM authors the initial brief from the owner's request
264
+ One compact `AGENTS.md` is seeded with the installer purpose, a short note to
265
+ keep durable facts in `memory/` (one fact per file, indexed in `memory/MEMORY.md`)
266
+ and one line pointing at `ez tools list --details`. Every engine loads `AGENTS.md`,
267
+ so they share that memory. When a chat conversation is replaced (new conversation
268
+ or AI/provider change), its engine gets one silent turn to save durable facts. The installing LLM authors the initial brief from the owner's request
265
269
  and known application context; Ez stores it without trying to infer the product
266
- itself. Installed plugin snippets and skill paths come from the bound
267
- `ez tools list --details` locator; no tool inventory file needs maintenance.
270
+ itself. Installed plugin aliases, purposes, skills and examples come from
271
+ `ez tools list --details`; no tool inventory file needs maintenance.
268
272
  Credentials and control state stay outside the mind. Isolated agents use relay
269
273
  mounts as the filesystem boundary. Host-capable file separation is not OS
270
274
  isolation against a process running as the same user.
package/bin/ez CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createConnection } from 'node:net';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { fstatSync } from 'node:fs';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { installedNativeHelp } from '../src/plugins/native-tasks.mjs';
6
7
 
7
8
  const MAX_STDIN_BYTES = 1024 * 1024;
9
+ const SOCKET_STDIN_IDLE_MS = 1000;
8
10
  const RUN_ID = /^[A-Za-z0-9_-]{1,160}$/;
9
11
  const socketPath = process.env.EZ_PLUGIN_BROKER_SOCKET?.trim();
10
12
  const runId = process.env.EZ_RUN_ID?.trim();
@@ -50,18 +52,25 @@ const request = (payload, signal = undefined) => new Promise((resolve, reject) =
50
52
  socket.on('connect', () => socket.write(JSON.stringify(payload) + '\n'));
51
53
  });
52
54
 
53
- const readStdin = async () => {
54
- if (process.stdin.isTTY) return '';
55
+ // Pipes and files always end, so read them to EOF. A socket is how Node-based
56
+ // parents (native engine shells, agent scripts) wire child stdio, and they
57
+ // often pass it on without ever closing it. Waiting for that EOF hung calls
58
+ // before the broker was contacted, so a socket that stays silent means no input.
59
+ const readStdin = () => new Promise((resolve, reject) => {
60
+ if (process.stdin.isTTY) return resolve('');
55
61
  const chunks = [];
56
62
  let bytes = 0;
57
- for await (const chunk of process.stdin) {
63
+ const idle = fstatSync(0).isSocket() ? setTimeout(() => { process.stdin.destroy(); resolve(''); }, SOCKET_STDIN_IDLE_MS) : undefined;
64
+ process.stdin.on('data', chunk => {
65
+ clearTimeout(idle);
58
66
  const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
59
67
  bytes += value.length;
60
- if (bytes > MAX_STDIN_BYTES) throw Error('plugin stdin exceeds the broker limit');
68
+ if (bytes > MAX_STDIN_BYTES) { process.stdin.destroy(); return reject(Error('plugin stdin exceeds the broker limit')); }
61
69
  chunks.push(value);
62
- }
63
- return Buffer.concat(chunks).toString('utf8');
64
- };
70
+ });
71
+ process.stdin.once('end', () => { clearTimeout(idle); resolve(Buffer.concat(chunks).toString('utf8')); });
72
+ process.stdin.once('error', error => { clearTimeout(idle); reject(error); });
73
+ });
65
74
 
66
75
  const invoke = async (args, stdin) => {
67
76
  if (!runId || !RUN_ID.test(runId)) throw Error('EZ_RUN_ID is required for an isolated plugin command');
@@ -3,6 +3,10 @@
3
3
  `menu.ts` owns Telegram controls; `ai.ts` projects installed-client metadata.
4
4
  `ControlStore` stores saved presets, the next-conversation default and current choice.
5
5
  No provider SDK, model fallback, history migration, or second execution loop.
6
+ A missing catalog entry rejects selection without changing the conversation.
7
+ An unsupported effort reports the exact installed model's advertised levels.
8
+ Missing selectable models do not establish that the active native session is
9
+ unavailable, and editing a scheduled task does not change the chat selection.
6
10
 
7
11
  At intake each journal entry receives an immutable preset and conversation ID.
8
12
  Batches cannot cross that boundary. The run copies that choice and invokes the
@@ -141,6 +141,18 @@ An interrupted Telegram poller keeps authorized work and in-flight deliveries
141
141
  alive while it retries intake. Run and delivery records are relay memory: a
142
142
  relay process restart drops in-flight work by design, Telegram redelivery is the
143
143
  intake replay path, and uncertain sends report unknown instead of success.
144
+ Host-capable agents are the exception for running work: their native CLIs run under
145
+ the host transport, so a graceful relay stop (Compose recreate, Docker restart)
146
+ writes those running runs to private `control/run-handoff.json` instead of
147
+ cancelling them. The next relay adopts each one, keeps its concurrency slot and
148
+ schedule hold, accepts its messages and records its exit status; it never
149
+ relaunches a run. A run whose outcome is unknown (no request, claim or result
150
+ record, or an adoption failure) is recorded failed and interrupted, which holds
151
+ its schedule until an explicit edit; the host is asked to cancel it. A request
152
+ the host had not claimed before the relay stopped is failed by the host without
153
+ starting. Records older than 24 hours are ignored, and the file is removed on
154
+ every start. Queued runs and deliveries are still dropped, and messages a run
155
+ sends while no relay is up fail and need a retry.
144
156
  A `409 Conflict` still requires the operator to stop the competing poller; it
145
157
  remains unhealthy after the startup grace. Pending and uncertain deliveries keep
146
158
  their existing receipt semantics; executor stdout is not replayed as a reply.
@@ -196,8 +208,12 @@ The image pins modern Docker Compose, including its standalone executable, so
196
208
  existing operator-created volumes retain their data and driver bindings.
197
209
 
198
210
  The relay-side `ez` client sends only declared aliases, literal argument arrays,
199
- bounded stdin, and the active run ID to the broker. The broker re-reads owner
200
- authority and the registry before every call, pins the discovered revision, and
211
+ bounded stdin, and the active run ID to the broker. It reads a piped or
212
+ redirected stdin to EOF. A socket stdin, which native engine shells and Node
213
+ child processes often leave open, counts as no input unless data arrives within
214
+ one second; write and close it immediately when a command needs input. The
215
+ broker re-reads owner authority and the registry before every call, pins the
216
+ discovered revision, and
201
217
  passes only the installed plugin's `run.application.context.plugins[plugin-id]`
202
218
  object as `EZ_PLUGIN_CONTEXT`. Missing, revoked, cross-agent, expired, or reused
203
219
  admissions fail closed. Timeouts, cancellation and bounded output produce a
@@ -57,14 +57,14 @@ At minimum, provide:
57
57
  machine-readable commands, stable exit codes, and readback for writes.
58
58
 
59
59
  The manager snapshots the package according to `package.json.files` plus the
60
- required manifests and Docker inputs. `ez tools list --details` includes
60
+ required manifests and Docker inputs. `ez tools list --details` prints aliases, purpose, skill paths, one example call
61
+ (the optional manifest `example`, else `<alias> --help`) and
61
62
  `skillReads`: literal command argument arrays for reading each installed skill.
62
63
  Use `ez tools skill <plugin> <declared-path>` to return its text and pinned
63
64
  revision. The isolated executor cannot open private host package paths directly;
64
65
  the authorized broker reads only manifest-declared regular skill files, without
65
66
  copying them into the agent workspace. Skill reads reject symlinks and files over
66
- 64 KiB. The existing generic discovery footer remains accurate; no plugin-specific
67
- usage snippet or per-turn instruction is needed for this read contract.
67
+ 64 KiB. No AGENTS.md text or per-turn instruction is needed for this read contract.
68
68
 
69
69
  Keep one canonical checkout; do not keep
70
70
  permanent packaged copies or a second registry. See the full
package/docs/plugins.md CHANGED
@@ -27,8 +27,7 @@ node /absolute/ezenciel_agents/bin/ezenciel-agents-tools.mjs init \
27
27
  Without `--catalog`, init loads an empty packaged catalog. Keep it empty for
28
28
  initial main onboarding; no sibling repository, broker or provider account is
29
29
  needed. Finish owner pairing and verify an actual Telegram agent reply first.
30
- Init creates a private registry and `tools/bin/ez`, adds a
31
- managed AGENTS.md registry locator, and binds the matching host executor to that bin
30
+ Init creates a private registry and `tools/bin/ez` and binds the matching host executor to that bin
32
31
  folder. Native binaries are linked through; the package's own `bin/ez` is replaced
33
32
  by that bound launcher, while an unrelated existing `ez` collision fails.
34
33
  Both host-bound and isolated `ez --help` list installed native scheduling and delivery helpers
@@ -307,8 +306,10 @@ never a source of network attachment authority.
307
306
 
308
307
  Commands run in one-shot client containers against their own service's volumes;
309
308
  stdin, stdout, stderr, literal arguments and exit codes are preserved. SIGINT/
310
- SIGTERM cancel the Docker call and remove its unique client container. The manager
311
- never retries a provider operation. Service startup/restart is explicit; commands
309
+ SIGTERM cancel the Docker call and remove its unique client container; a Compose
310
+ exit with a non-zero status also removes it, so a call that stopped between
311
+ create and start never remains `Created`. The manager never retries a provider
312
+ operation. Service startup/restart is explicit; commands
312
313
  never implicitly start a stopped provider service. Plugin data does not enter the
313
314
  relay. The manager sends only a whitelist of Docker client environment variables.
314
315
 
@@ -412,10 +413,12 @@ QMD runtimes or edit installed Compose/package files to bypass missing support.
412
413
 
413
414
  ## Generated capability discovery
414
415
 
415
- `ez tools list --details` generates a compact index directly from installed
416
- `ez-plugin.json` manifests: each plugin supplies its `description`, command aliases
417
- and `skills`. Descriptions are limited to 200 characters in this view; full
418
- instructions stay in the skill. Installation, replacement and removal are reflected
416
+ `ez tools list --details` generates a self-sufficient index directly from
417
+ installed `ez-plugin.json` manifests. Per plugin it prints `aliases`, a one-line
418
+ `purpose` (the manifest `description`, at most 200 characters), absolute `skills`
419
+ paths and one `example` call. The optional manifest `example` is one line of at
420
+ most 160 characters starting with a declared command alias; without it the
421
+ example is `ez <first alias> --help`. Full instructions stay in the skill. Installation, replacement and removal are reflected
419
422
  on the next read, without hooks, LLM calls or a cached inventory file.
420
423
 
421
424
  The additive `skillReads` field supplies literal argument arrays for
@@ -424,8 +427,8 @@ installed plugin revision, declared path and skill content. Use it in isolated
424
427
  agents, where host package paths are private. It reads only the installed
425
428
  manifest's skills; arbitrary files, symlinks and files over 64 KiB are rejected.
426
429
 
427
- `ez tools list` retains its alias mapping for existing clients. Native AGENTS.md
428
- contains only the agent-bound discovery shortcut. New workspaces do not seed
430
+ `ez tools list` retains its alias mapping for existing clients. The workspace
431
+ seed writes one static line pointing here; nothing else is written. New workspaces do not seed
429
432
  TOOLS.md, SOUL.md, USER.md, or an `inbox/`/`work/` layout; upgrades preserve
430
433
  legacy notes without rewriting them. Keep owner/account policies in agent
431
434
  instructions or linked policy files, separate from plugin metadata.
@@ -57,7 +57,7 @@ available through `ezenciel-agents-schedule context`.
57
57
 
58
58
  ## Ordered setup fallback
59
59
 
60
- An agent task may list up to three fallback setups after its saved one, for
60
+ An agent task may list ordered fallback setups after its saved one, for
61
61
  example `claude · opus → claude · sonnet → codex · gpt-6-sol`:
62
62
 
63
63
  ```sh
@@ -68,30 +68,58 @@ ezenciel-agents-schedule create --name Daily --cli claude --model opus \
68
68
 
69
69
  `edit` keeps the saved fallbacks unless `--fallback` replaces them or
70
70
  `--clear-fallbacks` removes them. Setups must be distinct and each needs a model.
71
+ There is no fixed list-length limit. Each setup captures its own supported CLI,
72
+ model, effort and optional provider; the installed native client owns valid model
73
+ and effort values. The existing failure rules below apply to every setup.
74
+ Each fallback may include `auth-profile=NAME` to select its own owner-provisioned
75
+ Codex or Claude account. Omitting it selects the default login, independently of
76
+ the primary setup. For four separate Claude accounts, provision and log into
77
+ `claude2`, `claude3` and `claude4`, then use:
78
+
79
+ ```sh
80
+ ezenciel-agents-schedule create --name Daily --cli claude --model opus \
81
+ --cron '0 9 * * *' --timezone Asia/Dubai --text 'Prepare the daily report.' \
82
+ --fallback cli=claude,model=opus,auth-profile=claude2 \
83
+ --fallback cli=claude,model=opus,auth-profile=claude3 \
84
+ --fallback cli=claude,model=opus,auth-profile=claude4
85
+ ```
71
86
 
72
87
  Core advances to the next setup only with typed evidence that nothing ran: the
73
88
  CLI is not installed or executable, or the provider rejected the turn before any
74
89
  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,
90
+ error, unavailable model or bad request), including a missing, expired or revoked
91
+ login (typed authentication failure or HTTP401). Native API-error metadata does
92
+ not count as model/tool work. It stops, without replay, on access
93
+ denial (HTTP403, account or organization restriction), cancellation, relay shutdown,
77
94
  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
95
+ other failure after work began. Evidence comes from structured events only: Codex
79
96
  native-session `codexErrorInfo` and Claude `stream-json` error fields (scheduled
80
97
  Claude runs use `--output-format stream-json`; the host forwards only a compact
81
98
  startup summary). Other engines advance only when their CLI is unavailable.
82
99
 
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.
100
+ The one failure after work began that continues is a Claude usage limit the run
101
+ ended on (for example "You've hit your session limit"): a typed quota rejection
102
+ with no model, tool or hook activity after it. A native session cannot be resumed
103
+ under another login's configuration home, so the next eligible setup starts a fresh
104
+ session with the saved prompt plus one factual line naming the run, the stopped
105
+ setup, its native session and reset time, and that its work may be partly applied.
106
+ The agent reconciles from its own records; core replays nothing else.
107
+
108
+ A login or quota rejection skips the remaining setups in the same scope: the native
109
+ client and login profile (`claude`, `codex` including desktop and custom Codex
110
+ providers), `grok`, or the upstream provider for OpenCode/Pi models. A named
111
+ profile selects a separate owner-provisioned account and is eligible for its own
112
+ attempt; changing only the model in the same profile does not reset its scope.
113
+ Do not provision aliases of the same account to obtain more allowance. Ez does
114
+ not infer account independence from credentials or promise that a shared
115
+ organization's allowance is separate. Each setup runs at most once per occurrence.
88
116
 
89
117
  Every attempt keeps the same run ID, occurrence, schedule revision, workspace,
90
118
  delivery binding and scheduled concurrency slot, in a fresh native session.
91
119
  `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
120
+ (`cli-unavailable`, `login-unavailable`, `quota`, `provider-rejected`, `access-denied`,
121
+ `quota-after-work-began`, `after-work-began`, `uncertain`, or `same-login-scope`/`same-quota-scope`
122
+ for a skipped setup), exit code, reported reset time and the session ID Ez assigned. If every setup is unavailable the run fails with
95
123
  reason `setups-unavailable`, a summary of each setup, and an owner notice on
96
124
  Telegram-bound tasks. Core never purchases credits, enables overage or consumes
97
125
  reset credits; the next regular occurrence runs normally.
@@ -225,10 +253,13 @@ queued occurrence; already-running work continues until explicitly cancelled.
225
253
 
226
254
  `/stop` stops all active work; `cancel RUN_ID` stops one background task. `/cancel`
227
255
  clears queued work. Pause/remove a recurring schedule to prevent future runs.
228
- Stopping the relay also stops its workers. Run and delivery records are relay
229
- memory: a process restart drops in-flight runs and queued deliveries by design,
230
- so the next eligible occurrence dispatches normally and nothing is replayed from
231
- disk. Status labels failed runs as history and shows recent reasons; new failures retain their exit code or interruption cause. Typing indicators stop after 30 seconds even when work continues. A failed run that cannot prove whether an external side effect happened stays visible until the agent inspects the evidence and explicitly edits the schedule. Inspect the task's files, native session and delivery
256
+ Stopping the relay also stops its workers, except on host-capable agents: a
257
+ graceful relay stop hands running host-transport runs to the next relay, which
258
+ adopts them without relaunching (see [Docker runtime](docker-runtime.md)). A
259
+ request the host had not yet claimed when the relay stopped is failed by the host
260
+ without starting. Run and delivery records are otherwise relay memory: a process
261
+ restart drops in-flight runs and queued deliveries by design, so the next
262
+ eligible occurrence dispatches normally and nothing is replayed from disk. Status labels failed runs as history and shows recent reasons; new failures retain their exit code or interruption cause. Typing indicators stop after 30 seconds even when work continues. A failed run that cannot prove whether an external side effect happened stays visible until the agent inspects the evidence and explicitly edits the schedule. Inspect the task's files, native session and delivery
232
263
  receipts before deciding whether to resume. A clock cannot reconstruct an
233
264
  in-flight process or prove whether an external side effect happened.
234
265
 
@@ -310,7 +341,13 @@ The prompt controls diagnosis, authorized recovery and quiet notification behavi
310
341
  The Telegram Scheduled tasks menu lists enabled schedules that still have a pending
311
342
  occurrence or a queued/running occurrence. Finished one-time tasks, paused
312
343
  schedules and revisions stopped for review are hidden. Each entry shows the
313
- effective engine/model/effort, next occurrence in UTC (or queued/running state),
344
+ frequency with the cron timezone, the saved primary and fallback setups in order
345
+ (CLI, login profile, model and effort), next occurrence in UTC (or queued/running state),
314
346
  and the first sentence of its saved invocation
315
347
  prompt, limited to 140 characters. This is a read-only view; history and full
316
- prompts remain available through the scheduling CLI.
348
+ prompts remain available through the scheduling CLI. `/tasks N` shows the same
349
+ frequency and execution path with the saved instructions. Common minute/hourly,
350
+ daily and weekday rules have plain-language labels; other calendar rules show
351
+ the exact cron expression and timezone. Interval rules show their fixed period,
352
+ and one-time tasks show `Once`. This describes the schedule, not a guarantee of
353
+ work when admission/preflight conditions are unmet.
package/docs/setup.md CHANGED
@@ -261,8 +261,8 @@ node /absolute/ezenciel_agents/bin/ezenciel-agents-tools.mjs init \
261
261
 
262
262
  This binds a private `ez` and preserves native command access. The default catalog
263
263
  is empty. Do not supply a plugin catalog during first-time main onboarding.
264
- Initialization
265
- adds discovery instructions to the mind's AGENTS.md. Verify `tools/bin/ez plugins
264
+ The seeded
265
+ AGENTS.md already points at `ez tools list --details`. Verify `tools/bin/ez plugins
266
266
  available` before the first agent turn. Also execute the agent-bound
267
267
  `ezenciel-agents-message --help` through the selected CLI sandbox and verify
268
268
  its actual tool output, so absent launchers or blocked execution are detected
@@ -22,14 +22,14 @@ node /absolute/package/bin/ezenciel-agents-tools.mjs init --standalone \
22
22
  /absolute/private/company-tools/bin/ez plugins list
23
23
  ```
24
24
 
25
- Init starts nothing, uses an empty catalog by default, preserves existing
26
- workspace notes and adds a managed registry locator to AGENTS.md. A registry
25
+ Init starts nothing, uses an empty catalog by default and never writes
26
+ workspace instructions. A registry
27
27
  cannot be replaced by rerunning init. Keep the package at its original path:
28
28
  the launcher imports it. Status reports `main: null` without a relay binding;
29
29
  automated software upgrades currently require a relay deployment.
30
30
 
31
- Use `ez tools list --details` for generated installed-plugin descriptions, help shortcuts and absolute skill paths. Read only the relevant skill.
32
- Add that instruction to its existing project instructions without replacing them.
31
+ Use `ez tools list --details` for each installed plugin's aliases, purpose, skill paths and one example call. Read only the relevant skill.
32
+ Add "Tools: run `ez tools list --details`; each plugin's `--help` gives usage." to the project's existing instructions, with `ez` resolving to the returned launcher.
33
33
  Use the absolute launcher, or prepend its bin directory to that session's PATH.
34
34
  Never overwrite another global `ez`; it may belong to a different installation.
35
35
  A standalone company registry remains explicit even when invoked from a different
package/docs/upgrades.md CHANGED
@@ -255,19 +255,15 @@ handbook from workspace `AGENTS.md` (and existing `AGENTS.override.md`).
255
255
  Personal text outside that block is preserved byte-for-byte. Do not reinsert
256
256
  coding, KISS, contribution or repair loops into operating minds.
257
257
 
258
- The package-owned guidance is the managed `<!-- ez tools -->` block: the bound
259
- `ez` launcher, `ez --help`, `ez tools list --details`, and one routing hint for
260
- foreground owner turns to prefer `ezenciel-agents-schedule create --now` when work
261
- can continue independently without blocking interaction. The hint tells scheduled
262
- turns to execute their assigned work directly instead of rescheduling themselves.
263
- Inventory and full scheduler usage are generated on read. Plugin registry init and update binding refresh that block.
264
- Malformed markers and symlinks fail visibly rather than overwriting personal work.
265
- Upgrade and rollback render the block through the exact package root being activated,
266
- so an already-running package cannot leave stale guidance behind.
267
-
268
- Codex, Grok and OpenCode discover workspace instructions natively. Claude
269
- receives the native `--append-system-prompt-file` binding to `AGENTS.md`, alongside
270
- its own normal instructions. Fresh and resumed owner input is literal; ez does
258
+ Ez writes no managed guidance after the initial workspace seed. Update binding
259
+ (init with a host config, `enable-updates`, upgrade and rollback) replaces a former
260
+ `<!-- ez tools -->` footer once with the seeded static line
261
+ "Tools: run `ez tools list --details`; each plugin's `--help` gives usage." and
262
+ otherwise leaves `AGENTS.md` untouched. Malformed markers and symlinks fail visibly
263
+ rather than overwriting personal work. Inventory and usage are generated on read.
264
+
265
+ Codex, Grok, OpenCode and Claude Code discover workspace `AGENTS.md` natively
266
+ (Claude Code loads it when the workspace has no `CLAUDE.md`). Fresh and resumed owner input is literal; ez does
271
267
  not surround it with policies, tool recipes, repair instructions or history.
272
268
  The native engine owns instruction loading and its context/token overhead.
273
269
  Already running sessions retain their current context until native reload.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.49",
3
+ "version": "0.1.0-beta.50",
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.",
@@ -5,8 +5,8 @@ import path from 'node:path'
5
5
  const start = '<!-- ez shared guidance: begin -->'
6
6
  const end = '<!-- ez shared guidance: end -->'
7
7
 
8
- // Drop the obsolete package-owned handbook. Discovery lives in the managed
9
- // ez tools locator. Personal text outside the markers stays byte-for-byte.
8
+ // Drop the obsolete package-owned handbook. Discovery is seeded once and
9
+ // generated by `ez tools list --details`. Personal text outside the markers stays byte-for-byte.
10
10
  export async function installAgentGuidance(workspace: string): Promise<void> {
11
11
  for (const name of ['AGENTS.md', 'AGENTS.override.md']) {
12
12
  const file = path.join(workspace, name)
package/src/ai.ts CHANGED
@@ -12,7 +12,6 @@ import { AUTH_PROFILE_CLIS, claudeAuthEnvironment, cliHome, isAuthProfile, listA
12
12
  export type AiPreset = { id: string; name: string; cli: string; provider?: string; authProfile?: string; model?: string; effort?: string }
13
13
  // Optional ordered fallbacks are tried only for a scheduled occurrence that never started work.
14
14
  export type ExecutionChoice = { sessionId: string; preset: AiPreset; fallbacks?: AiPreset[] }
15
- export const MAX_FALLBACKS = 3
16
15
  export type ModelChoice = { cli: string; provider?: string; authProfile?: string; model?: string; name: string; efforts: string[] }
17
16
  type Engine = { cli?: string; authProfile?: string }
18
17
  // A native conversation belongs to one client and one credential home.
@@ -34,7 +33,7 @@ export const isPreset = (p: unknown): p is AiPreset => {
34
33
  export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
35
34
  const c = v as ExecutionChoice | undefined
36
35
  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))))
36
+ (c.fallbacks === undefined || (Array.isArray(c.fallbacks) && c.fallbacks.length > 0 && c.fallbacks.every(isPreset))))
38
37
  }
39
38
  export const presetLabel = (p: AiPreset) => `${p.cli}${p.authProfile ? `@${p.authProfile}` : ''}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
40
39
  // OpenCode encodes its provider in the native provider/model identifier.
@@ -264,6 +263,21 @@ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env):
264
263
  return validateOpencodeProviders([...new Set(raw.split(',').map((p) => p.trim()).filter(Boolean))])
265
264
  }
266
265
 
266
+ // Shared by native menus and application controls; a rejected choice never mutates state.
267
+ export const catalogSelection = (p: {cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown}, catalog: ModelChoice[]): ModelChoice => {
268
+ const candidate = {...p, id: 'selection', name: 'Selection'}
269
+ if (!isPreset(candidate)) throw new Error('Invalid AI selection fields')
270
+ const model = catalog.find(m => sameEngine(m, candidate) && m.provider === candidate.provider && m.model === candidate.model)
271
+ if (!model) {
272
+ if (!catalog.some(m => sameEngine(m, candidate)))
273
+ throw new Error(`No models advertised for ${candidate.cli}${candidate.authProfile ? `@${candidate.authProfile}` : ''} in the installed client catalog; current selection unchanged.`)
274
+ throw new Error(`Model ${candidate.model ?? '(client default)'} is not in the installed client catalog for ${candidate.cli}; current selection unchanged.`)
275
+ }
276
+ if (candidate.effort !== undefined && !model.efforts.includes(candidate.effort))
277
+ throw new Error(`Effort ${candidate.effort} is not supported for ${candidate.cli} ${candidate.model ?? '(client default)'}. Supported efforts: ${model.efforts.join(', ') || 'none (omit effort)'}; current selection unchanged.`)
278
+ return model
279
+ }
280
+
267
281
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
268
282
  assertEffort(p.effort, p.model, p.cli)
269
283
  if (!isPreset(p)) throw new Error('This CLI is not installed.')
@@ -273,9 +287,7 @@ export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], ava
273
287
  if (!catalog.some((m) => sameEngine(m, p))) throw new Error('This auth profile is not provisioned for the selected CLI.')
274
288
  return
275
289
  }
276
- const model = catalog.find((m) => sameEngine(m, p) && m.provider === p.provider && m.model === p.model)
277
- if (!model || (p.effort && !model.efforts.includes(p.effort)))
278
- throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
290
+ catalogSelection(p, catalog)
279
291
  }
280
292
 
281
293
  export type AuthProfileStatus = { cli: string; authProfile: string | null; provisioned: boolean; sharesHostLogin: boolean
@@ -2,11 +2,11 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse }
2
2
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
3
3
  import { mkdir, readFile, writeFile, rename, open, unlink, stat, chown } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
- import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
5
+ import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, type SessionState, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
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, sameChoice, sameEngine, type AiPreset, type ModelChoice } from './ai.js'
9
+ import { isPreset, sameChoice, catalogSelection, 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'
@@ -97,6 +97,8 @@ export class ApplicationChannel {
97
97
  constructor(private options: {
98
98
  controlDir: string; workspace?: string; isolation?: string; initial: AiPreset
99
99
  wake: () => void
100
+ // Queues the replaced conversation's silent close turn (session-close.ts).
101
+ closeSession?: (session: SessionState | null) => Promise<void>
100
102
  cancel: (id: string) => Promise<void>
101
103
  speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
102
104
  createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
@@ -155,7 +157,7 @@ export class ApplicationChannel {
155
157
  const owner = await this.sharedBinding(bindingId)
156
158
  const controls = this.options.aiControls
157
159
  if (!controls) throw new Error('Application controls unavailable')
158
- const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
160
+ const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown}
159
161
  if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
160
162
  !(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
161
163
  const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
@@ -168,7 +170,11 @@ export class ApplicationChannel {
168
170
  const live = (await this.bindings.list()).find(item=>item.bindingId===bindingId)
169
171
  if (!live?.shareTelegram || !sameOwner(owner, live.owner)) throw new Error('Application authority revoked')
170
172
  }}
171
- if (value.action === 'new') await control.resetSession(expected, guard)
173
+ if (value.action === 'new') {
174
+ const previous = await control.getActiveSession()
175
+ await control.resetSession(expected, guard)
176
+ await this.options.closeSession?.(previous)
177
+ }
172
178
  if (value.action === 'switch') {
173
179
  if (typeof value.sessionId !== 'string') throw new Error('Invalid application conversation')
174
180
  // switchSession enforces the same visibility/engine restrictions as /chats.
@@ -181,8 +187,7 @@ export class ApplicationChannel {
181
187
  await controls.select(preset, expected, guard)
182
188
  }
183
189
  if (value.action === '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
- if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
190
+ const model = catalogSelection(value, await controls.catalog())
186
191
  await this.sharedBinding(bindingId)
187
192
  const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
188
193
  await this.sharedBinding(bindingId)
@@ -231,8 +236,7 @@ export class ApplicationChannel {
231
236
  await controls.validate(preset)
232
237
  }
233
238
  if (value.action==='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
- if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
239
+ const model = catalogSelection(value, await controls.catalog())
236
240
  preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
237
241
  }
238
242
  await control.changeApplicationSession(hashedScope,guard,preset)
package/src/executor.ts CHANGED
@@ -500,6 +500,15 @@ export const startExecutorJob = async (
500
500
 
501
501
  export const startGrokJob = startExecutorJob
502
502
 
503
+ // Watch a host-transport run a previous relay submitted; it is never resubmitted.
504
+ export const attachHostJob = async (controlDir: string, runId: string): Promise<ChildProcess> => {
505
+ const invocation = executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
506
+ fileURLToPath(new URL('./host-executor-client.ts', import.meta.url)), controlDir, runId, '--attach'])
507
+ const child = spawn(invocation.command, invocation.args, { env: executorEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' })
508
+ await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
509
+ return child
510
+ }
511
+
503
512
  // Spawn a verified registered script with the executor identity, environment
504
513
  // allowlist and workspace confinement. No shell, model, prompt or native session.
505
514
  export const startScriptJob = async (