@jc_stack/ez-agents 0.1.0-beta.51 → 0.1.0-beta.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +4 -1
- package/docker/isolated-executor-smoke.mjs +35 -0
- package/docs/architecture/ai-selection.md +6 -2
- package/docs/docker-runtime.md +48 -0
- package/docs/releasing.md +1 -0
- package/docs/scheduling.md +124 -16
- package/docs/upgrades.md +19 -1
- package/package.json +1 -1
- package/src/ai-cli.ts +31 -5
- package/src/ai.ts +17 -1
- package/src/codex-session.ts +23 -2
- package/src/control-state.ts +173 -8
- package/src/delivery-socket.ts +18 -1
- package/src/executor.ts +11 -4
- package/src/host-executor.ts +17 -6
- package/src/index.ts +358 -269
- package/src/run-telemetry.ts +163 -0
- package/src/running-work.ts +123 -0
- package/src/runs.ts +7 -2
- package/src/schedule-cli.ts +55 -16
- package/src/scheduler.ts +126 -29
- package/src/setup-fallback.ts +57 -18
- package/src/updates/runtime.mjs +2 -1
- package/src/updates/supervisor.mjs +64 -19
- package/test/chat-fallback.test.ts +358 -0
- package/test/codex-session.test.ts +1 -1
- package/test/control-state.test.ts +62 -0
- package/test/executor.test.ts +2 -1
- package/test/host-executor.test.ts +41 -0
- package/test/run-handoff.test.ts +54 -0
- package/test/running-work.test.ts +175 -0
- package/test/schedule-cli.test.ts +55 -6
- package/test/scheduler.test.ts +128 -1
- package/test/script-schedules.test.ts +3 -3
- package/test/setup-fallback.test.ts +211 -14
- package/test/updates.test.mjs +53 -5
- package/test/upgrade-pause.test.ts +65 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.58
|
|
4
|
+
|
|
5
|
+
Versions 0.1.0-beta.53 through 0.1.0-beta.57 were used only by private QA candidates; this release follows the newest installed candidate (0.1.0-beta.58.rc.1) so it can upgrade in place.
|
|
6
|
+
|
|
7
|
+
- Add an owner chat/application fallback chain (`ezenciel-agents-ai fallbacks`), captured when work is queued, with the scheduled-fallback safeguards: advance only on typed unavailability, rejection before work or a usage limit, never after cancellation, access denial, revoked authority or an uncertain outcome. Same-CLI logins keep the turn's model and effort, and each login keeps its own native session per conversation.
|
|
8
|
+
- Add opt-in per-attempt run telemetry (`ezenciel-agents-ai telemetry on`): reported model, deduplicated token counts, tool counts/durations and error categories with labelled coverage gaps, never prompts, tool data, credentials or costs.
|
|
9
|
+
- Make schedule edits safe for queued and running work. `edit` now requires `--expected-revision` and rejects a stale revision. It applies only while the task is idle, or with `--stage` saves the change for future occurrences while queued and running ones finish on their captured settings, including fallbacks and quota retries. Edits, dispatch, trigger, pause, resume and remove serialize on a per-task lock, so a due occurrence cannot land between the check and the write. Edit needs the running relay. A root invocation of the schedule CLI runs as the control directory's owner, so its definitions stay readable by the relay and it cannot follow agent-planted symlinks.
|
|
10
|
+
- Hold new native turn admission while a completed or rolled-back core update drains for supervisor reload. Retain queued requests and reopen only after the replacement worker is ready.
|
|
11
|
+
- Preserve scheduled fallback handling when a compatible relay upgrade adopts a running native session, including continuation after a terminal usage limit without replaying its work.
|
|
12
|
+
- Reuse the last successful same-model native login across scheduled tasks and relay restarts; rotate through configured logins and skip known exhausted scopes until their provider reset.
|
|
13
|
+
- Keep scheduled work queued when every saved CLI login reaches its usage limit, instead of failing on each tick. Known resets wake the occurrence; unknown resets back off from 5 minutes to hourly. Codex reports native rate limits, and Claude credits-required responses wait for an explicit retry. `ezenciel-agents-schedule retry [RUN_ID]` and `/retry_quota [RUN_ID]` clear the relevant cooldowns after a manual reset or added credits.
|
|
14
|
+
- Add `ezenciel-agents-schedule running [--json]`, a read-only view of queued and running current-owner foreground turns and scheduled runs with their engine, timing and schedule origin. It exports no prompts, messages or native output.
|
|
15
|
+
- Restore isolated Codex chat replies: the isolated executor derives the Codex sandbox setting from its trusted deployment environment, never from an incoming job.
|
|
16
|
+
- Keep unfinished Claude background work alive in print mode until completion or explicit cancellation, instead of being killed after 10 idle minutes.
|
|
17
|
+
|
|
18
|
+
Beta limits: live fresh-host/reboot acceptance and live installed-agent QA of the chat fallback chain, run telemetry and safe schedule edits remain pending; they are covered by synthetic relay, CLI and container tests. A Codex chat setup advances only when its CLI is unavailable, so put Codex last in a chat chain. On its first turn, a fallback login's linked session does not see the conversation's earlier native history. Run telemetry and quota-waiting runs live in relay memory and reset on restart; saved-login cooldowns persist. Callers of `ezenciel-agents-schedule edit` must now pass `--expected-revision`, and edit needs the running relay. Publication does not establish an installed-agent upgrade or authorize provider/domain writes.
|
|
19
|
+
|
|
20
|
+
## 0.1.0-beta.52
|
|
21
|
+
|
|
22
|
+
- Restore isolated executor startup with existing named volumes and a private, root-owned read-only container binding. Host file ownership and permissions remain unchanged.
|
|
23
|
+
- Start a missing isolated executor when native requests are queued, while retaining the live-run drain and single-worker ownership checks.
|
|
24
|
+
|
|
25
|
+
|
|
3
26
|
## 0.1.0-beta.51
|
|
4
27
|
|
|
5
28
|
- Keep native runs alive during handoff-compatible core upgrades. The retiring executor finishes existing work while its replacement accepts new requests; the relay adopts runs without replay and preserves schedule holds, cancellation and settled delivery receipts. Isolated agents migrate once while idle to a separate, unprivileged executor container; later compatible upgrades preserve its running generation. Older runtimes without the handoff contract retain the full native-work drain.
|
package/README.md
CHANGED
|
@@ -333,4 +333,7 @@ The core `ezenciel-agents-schedule` CLI accepts instruction text for one-off dat
|
|
|
333
333
|
intervals and timezone-aware cron schedules. `create --now` delegates a task to a
|
|
334
334
|
separate CLI session queued with owner turns in the bound workspace. Long work has
|
|
335
335
|
no production wall-clock timeout; goals and subagents remain native executor
|
|
336
|
-
features.
|
|
336
|
+
features. Use `ezenciel-agents-schedule running` for a read-only view of queued
|
|
337
|
+
and running foreground/scheduled work, or add `--json` for an agent-readable
|
|
338
|
+
snapshot. Native child status and tool activity are unavailable; old or missing
|
|
339
|
+
telemetry does not imply idle or complete. See [scheduling, recovery and QA](docs/scheduling.md).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Synthetic private binding and existing-volume acceptance; no provider access.
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import {randomUUID} from 'node:crypto';
|
|
6
|
+
import {execute} from '../src/updates/runtime.mjs';
|
|
7
|
+
import {executorBindingArchive,executorVolumes} from '../src/updates/supervisor.mjs';
|
|
8
|
+
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-executor-smoke-'));
|
|
9
|
+
const project='ez-executor-smoke-'+randomUUID().slice(0,8),volume=project+'-existing';
|
|
10
|
+
const file=path.join(root,'compose.json'),image=process.env.EZ_RELAY_IMAGE||'ezenciel-agents:local';
|
|
11
|
+
const binding={agents:[{workspace:'/synthetic',binDir:'/app/bin'}]};
|
|
12
|
+
const code=`const fs=require('fs'),assert=require('assert/strict');const p='/run/executor.json',s=fs.statSync(p),d=fs.statSync('/run');assert.equal(process.getuid(),1001);assert.equal(s.uid,0);assert.equal(s.gid,1000);assert.equal(s.mode&511,288);assert.equal(d.uid,0);assert.equal(d.mode&18,0);assert.deepEqual(JSON.parse(fs.readFileSync(p)),${JSON.stringify(binding)});for(const fn of [()=>fs.writeFileSync(p,'changed'),()=>fs.chmodSync(p,384),()=>fs.unlinkSync(p)])assert.throws(fn,e=>['EACCES','EPERM','EROFS'].includes(e.code));assert.equal(fs.readFileSync('/ipc/marker','utf8'),'retained');console.log('Private readonly binding and retained volume passed');`;
|
|
13
|
+
const worker={image,user:'1001:1000',network_mode:'none',cap_drop:['ALL'],security_opt:['no-new-privileges:true'],entrypoint:['node','-e',code],volumes:[{type:'volume',source:'ipc',target:'/ipc'}]};
|
|
14
|
+
const args=['compose','--project-name',project,'--file',file];
|
|
15
|
+
try {
|
|
16
|
+
await execute('docker',['volume','create',volume]);
|
|
17
|
+
await execute('docker',['run','--rm','--network','none','--user','0:0','--mount',`type=volume,source=${volume},target=/ipc`,'--entrypoint','node',image,'-e',"require('fs').writeFileSync('/ipc/marker','retained')"]);
|
|
18
|
+
await fs.writeFile(file,JSON.stringify({services:{executor:worker},volumes:executorVolumes({volumes:{ipc:{name:volume}}},worker)}),{mode:0o600});
|
|
19
|
+
await execute('docker',[...args,'create','--no-build','executor']);
|
|
20
|
+
const cid=(await execute('docker',[...args,'ps','-aq','executor'],{stdoutOnly:true})).trim();
|
|
21
|
+
await execute('docker',['cp','-',`${cid}:/run`],{input:executorBindingArchive(binding,1000)});
|
|
22
|
+
console.log(await execute('docker',['start','-a',cid]));
|
|
23
|
+
const exit=(await execute('docker',['inspect','--format','{{.State.ExitCode}}',cid],{stdoutOnly:true})).trim();
|
|
24
|
+
if(exit!=='0')throw Error('Executor binding acceptance failed');
|
|
25
|
+
await execute('docker',[...args,'down']);
|
|
26
|
+
const retained=(await execute('docker',['volume','inspect','--format','{{.Name}}',volume],{stdoutOnly:true})).trim();
|
|
27
|
+
if(retained!==volume)throw Error('External volume identity changed');
|
|
28
|
+
// Rejected archive input must fail cleanly, with no uncaught stdin EPIPE.
|
|
29
|
+
await execute('docker',['cp','-',`${project}-missing:/run`],{input:Buffer.alloc(1024*1024)}).then(()=>{throw Error('Missing container copy succeeded');},()=>{});
|
|
30
|
+
console.log('PASS: unprivileged executor reads only its private binding; writes, chmod and unlink fail; external volume preserved.');
|
|
31
|
+
} finally {
|
|
32
|
+
await execute('docker',[...args,'down']).catch(()=>{});
|
|
33
|
+
await execute('docker',['volume','rm',volume]).catch(()=>{});
|
|
34
|
+
await fs.rm(root,{recursive:true,force:true});
|
|
35
|
+
}
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
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
|
-
No provider SDK,
|
|
5
|
+
No provider SDK, history migration, or second execution loop. The only model
|
|
6
|
+
fallback is an owner-saved ordered chain captured with each turn and advanced by
|
|
7
|
+
the relay's existing attempt loop on typed evidence
|
|
8
|
+
([chat fallback chain](../docker-runtime.md#chat-fallback-chain)).
|
|
6
9
|
A missing catalog entry rejects selection without changing the conversation.
|
|
7
10
|
An unsupported effort reports the exact installed model's advertised levels.
|
|
8
11
|
Missing selectable models do not establish that the active native session is
|
|
@@ -19,7 +22,8 @@ and native history; subsequent turns pass the new settings to the native engine.
|
|
|
19
22
|
Changing client, provider or auth profile starts a separate native conversation.
|
|
20
23
|
A conversation records its client and auth profile when created; launch rejects a
|
|
21
24
|
choice for another profile, so a native session never resumes under another
|
|
22
|
-
account
|
|
25
|
+
account; a fallback login uses its own linked native session for the
|
|
26
|
+
conversation. Provisioned profiles appear as additional catalog entries for their
|
|
23
27
|
client (see [auth profiles](../docker-runtime.md#auth-profiles)). Old bindings
|
|
24
28
|
remain available so accepted work can finish and owners can reopen their chats.
|
|
25
29
|
|
package/docs/docker-runtime.md
CHANGED
|
@@ -286,6 +286,9 @@ On macOS host jobs, Seatbelt also denies sibling directories of the bound
|
|
|
286
286
|
workspace (a tenant farm), while allowing explicitly shared and additional
|
|
287
287
|
workspace grants and leaving `$HOME` readable for the host CLI.
|
|
288
288
|
Codex then uses `danger-full-access` so it does not apply a nested Seatbelt.
|
|
289
|
+
The isolated executor derives the Codex sandbox setting from its trusted
|
|
290
|
+
deployment environment, never from an incoming job. Host workers and restricted
|
|
291
|
+
tasks retain their existing native sandbox.
|
|
289
292
|
|
|
290
293
|
## Claude Code auth
|
|
291
294
|
|
|
@@ -338,6 +341,51 @@ the profile entries in Choose AI, or `ezenciel-agents-schedule create ...
|
|
|
338
341
|
profile starts a new native conversation, and a run whose request names another
|
|
339
342
|
profile than its captured choice is rejected before launch.
|
|
340
343
|
|
|
344
|
+
## Chat fallback chain
|
|
345
|
+
|
|
346
|
+
Chat and application turns can name an ordered chain after the selected AI, for
|
|
347
|
+
example Claude default → three other Claude logins → Codex:
|
|
348
|
+
|
|
349
|
+
```sh
|
|
350
|
+
ezenciel-agents-ai fallbacks --fallback cli=claude,auth-profile=claude2 \
|
|
351
|
+
--fallback cli=claude,auth-profile=claude3 --fallback cli=claude,auth-profile=claude4 \
|
|
352
|
+
--fallback cli=codex,model=gpt-6.1-sol,effort=xhigh
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
An entry for the selected AI's CLI without provider, model or effort keeps the
|
|
356
|
+
turn's own model and effort, so every role keeps its settings on other logins.
|
|
357
|
+
Each queued turn captures the chain with its choice; later edits apply to later
|
|
358
|
+
turns only. `fallbacks` alone shows the resulting order and `--clear` removes it.
|
|
359
|
+
Profiles must be provisioned and models advertised before they are saved.
|
|
360
|
+
|
|
361
|
+
The rules of [ordered setup fallback](scheduling.md#ordered-setup-fallback)
|
|
362
|
+
apply: core advances only on typed evidence that a setup was unavailable or
|
|
363
|
+
rejected before work, or stopped at its usage limit (the next setup is told the
|
|
364
|
+
work may be partly applied). Access denial, other failures after work began,
|
|
365
|
+
unknown outcomes, owner stop and revoked owner or application authority end the
|
|
366
|
+
turn without replay. A login cooling down after a recorded quota rejection is
|
|
367
|
+
skipped while another is available; when all are, they are tried in order.
|
|
368
|
+
Codex chat turns report no typed usage-limit rejection, so a Codex setup
|
|
369
|
+
advances only when its CLI is unavailable; put Codex last in the chain.
|
|
370
|
+
Each login keeps its own native session for the conversation, created on first
|
|
371
|
+
use and resumed on later turns; a session never resumes under another login. A
|
|
372
|
+
fallback login's first turn does not see the conversation's earlier native
|
|
373
|
+
history; durable context lives in the workspace.
|
|
374
|
+
|
|
375
|
+
## Run telemetry
|
|
376
|
+
|
|
377
|
+
`ezenciel-agents-ai telemetry on` (off by default) records, for every attempt
|
|
378
|
+
started afterwards, the model the client reported, input/output/cache-read/
|
|
379
|
+
cache-write token counts, tool call counts, errors and observed durations by
|
|
380
|
+
tool name, and error categories. They appear in each attempt of
|
|
381
|
+
`ezenciel-agents-schedule run RUN_ID`. Counts come from structured client
|
|
382
|
+
events: repeated per-message usage is counted once, a client's cumulative total
|
|
383
|
+
replaces partial sums, and `coverage`/`gaps` label anything missing or partial,
|
|
384
|
+
such as a resumed Codex session whose total may include earlier turns. Prompts,
|
|
385
|
+
model text, tool arguments and results, credentials and reasoning are never
|
|
386
|
+
read into telemetry, and no cost or historical usage is derived. Records stay
|
|
387
|
+
in relay memory with the run and reset on restart.
|
|
388
|
+
|
|
341
389
|
## OpenCode auth
|
|
342
390
|
|
|
343
391
|
Isolated agents run the image OpenCode with `XDG_DATA_HOME` set to the
|
package/docs/releasing.md
CHANGED
|
@@ -22,6 +22,7 @@ separate release bot is not required.
|
|
|
22
22
|
audit advances. Baseline CI does not validate a newly published native version.
|
|
23
23
|
Managed installs and upgrades keep their existing native version selection.
|
|
24
24
|
Main relay: `EZ_RELAY_IMAGE=ez-release-runtime node docker/smoke.mjs`.
|
|
25
|
+
Isolated executor: `EZ_RELAY_IMAGE=ez-release-runtime node docker/isolated-executor-smoke.mjs`.
|
|
25
26
|
The WhatsApp Compose overlay has no separate relay image; verify its
|
|
26
27
|
provider integration with the synthetic plugin smoke in `docs/plugins.md`.
|
|
27
28
|
4. Create an artifact with `npm pack --ignore-scripts`. Inspect its file list,
|
package/docs/scheduling.md
CHANGED
|
@@ -13,6 +13,8 @@ ezenciel-agents-schedule create --name Research --now \
|
|
|
13
13
|
--text '/goal Complete the authorized research objective. Save evidence, verify the outcome, and send the owner the result.'
|
|
14
14
|
ezenciel-agents-schedule list
|
|
15
15
|
ezenciel-agents-schedule runs
|
|
16
|
+
ezenciel-agents-schedule running
|
|
17
|
+
ezenciel-agents-schedule running --json
|
|
16
18
|
ezenciel-agents-schedule trigger SCHEDULE_ID --key owner-smoke-20261003
|
|
17
19
|
ezenciel-agents-schedule pause SCHEDULE_ID
|
|
18
20
|
ezenciel-agents-schedule resume SCHEDULE_ID
|
|
@@ -20,13 +22,75 @@ ezenciel-agents-schedule remove SCHEDULE_ID
|
|
|
20
22
|
ezenciel-agents-schedule cancel RUN_ID
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
Use `--text-file` for longer instructions. `edit ID
|
|
24
|
-
with a new revision; use the same trigger/name/text
|
|
25
|
-
|
|
25
|
+
Use `--text-file` for longer instructions. `edit ID --expected-revision REVISION`
|
|
26
|
+
replaces the complete schedule with a new revision; use the same trigger/name/text
|
|
27
|
+
flags as `create`. `REVISION` is the `revision` printed by `show`, `list`,
|
|
28
|
+
`create` or a previous `edit`. If the schedule changed since that revision, the
|
|
29
|
+
edit is rejected and nothing is written: read it again and reapply the change.
|
|
30
|
+
It keeps the paused/enabled state: an edited paused task stays paused until `resume`.
|
|
26
31
|
An edit keeps a future pending occurrence when its trigger is unchanged;
|
|
27
32
|
otherwise the edited rule begins at its next future occurrence. Editing does
|
|
28
33
|
not replay missed work from an old start date. Use `--now` to request a new
|
|
29
34
|
one-time run explicitly. `show` reports the pending occurrence used by dispatch.
|
|
35
|
+
|
|
36
|
+
An edit never disrupts an occurrence that is already queued or running:
|
|
37
|
+
|
|
38
|
+
- Without `--stage`, it applies only while the task is idle. When an occurrence
|
|
39
|
+
is queued (including a quota wait) or running, it is rejected with those run IDs
|
|
40
|
+
and nothing changes. Edit again after they finish, or `cancel RUN_ID` first.
|
|
41
|
+
- With `--stage`, the new revision is saved for future occurrences. The queued or
|
|
42
|
+
running occurrences listed in `continuingRunIds` finish with the instructions,
|
|
43
|
+
engine, model, effort, fallbacks, delivery binding and revision they captured,
|
|
44
|
+
including fallback advancement and quota retries. The next occurrence of the new
|
|
45
|
+
revision waits for them, as usual for one task. A continuing occurrence that is
|
|
46
|
+
interrupted (or fails, for a `--when unreviewed-failures` schedule) holds the
|
|
47
|
+
new revision until the next explicit edit.
|
|
48
|
+
|
|
49
|
+
The compare, idle check and write happen under the task's lock, which dispatch,
|
|
50
|
+
`trigger`, `pause`, `resume`, `remove` and quota `retry` also take; a due occurrence therefore
|
|
51
|
+
lands either before the edit (and is protected as above) or after it, on the new
|
|
52
|
+
revision. Edit needs the running relay to read queued and running occurrences;
|
|
53
|
+
an unavailable relay rejects the edit rather than assuming the task is idle.
|
|
54
|
+
`list`, `show`, `create`, `pause`, `resume`, `remove` and script registration keep
|
|
55
|
+
working with the relay stopped.
|
|
56
|
+
|
|
57
|
+
Definitions are private files owned by the agent runtime user. A root invocation,
|
|
58
|
+
such as `docker compose exec relay ezenciel-agents-schedule ...`, first switches to
|
|
59
|
+
the user and group that own the control directory, so saved definitions stay
|
|
60
|
+
readable by the relay and root never writes through paths the agent controls.
|
|
61
|
+
|
|
62
|
+
`running` gives one read-only view of **queued and running current-owner
|
|
63
|
+
foreground turns (Telegram/application) and scheduled runs** in the bound relay.
|
|
64
|
+
It shows run IDs,
|
|
65
|
+
schedule names/IDs/revisions, the saved or latest attempted engine/model, start and
|
|
66
|
+
elapsed times, and the last recorded relay activity. `--json` returns the same
|
|
67
|
+
sanitized metadata with `schemaVersion`, `observedAt`, `counts`, `coverage` and
|
|
68
|
+
`runs`, suitable for asking the agent to summarize what is running. Other commands
|
|
69
|
+
keep their existing JSON output. The relay must be reachable; an unavailable
|
|
70
|
+
relay is an error, never an empty/idle result.
|
|
71
|
+
|
|
72
|
+
Activity means a recorded queue/start, setup attempt or delivery enqueue time;
|
|
73
|
+
it does not measure native tool progress. An old observation does not change a
|
|
74
|
+
run's recorded status. Missing start/model/activity metadata stays unknown, and
|
|
75
|
+
finished runs are omitted. Schedule names are available only when the saved
|
|
76
|
+
definition still matches that run's revision. For ordered fallbacks, the latest
|
|
77
|
+
non-skipped attempt is shown with its recorded outcome. Script runs have no
|
|
78
|
+
engine/model.
|
|
79
|
+
|
|
80
|
+
`origin` and `scheduledChildren` link scheduled work to its recorded originating
|
|
81
|
+
request. An origin may already be finished; its status is unknown when its record
|
|
82
|
+
is outside retained owner coverage. These links are request provenance, not a
|
|
83
|
+
native subagent tree. Native child status and native tool activity are unavailable
|
|
84
|
+
on the current adapters, including Codex, Codex desktop, Claude, Grok, OpenCode
|
|
85
|
+
and Pi. Native sessions, provider quotas and process lists are not queried.
|
|
86
|
+
Prompts, message text, script arguments and raw engine output are not exported.
|
|
87
|
+
|
|
88
|
+
This view covers one relay's retained memory. Ordinary restarts drop the ledger;
|
|
89
|
+
gracefully adopted host runs are the existing exception. Standalone manual
|
|
90
|
+
CLI/desktop sessions, other installations and restricted external/isolated tasks
|
|
91
|
+
are outside this view. Application request context is omitted. Future schedule
|
|
92
|
+
occurrences that have not been queued remain in `list`/`show`.
|
|
93
|
+
|
|
30
94
|
Intervals use `--every-seconds` (minimum 60), optional `--start`, and optional
|
|
31
95
|
`--until`. Cron also supports start/end bounds. All absolute timestamps require
|
|
32
96
|
an explicit offset. `--now` starts on the next relay tick, not synchronously.
|
|
@@ -92,20 +156,33 @@ error, unavailable model or bad request), including a missing, expired or revoke
|
|
|
92
156
|
login (typed authentication failure or HTTP401). Native API-error metadata does
|
|
93
157
|
not count as model/tool work. It stops, without replay, on access
|
|
94
158
|
denial (HTTP403, account or organization restriction), cancellation, relay shutdown,
|
|
95
|
-
|
|
159
|
+
schedule removal, a policy or unknown error, an interrupted transport, or any
|
|
96
160
|
other failure after work began. Evidence comes from structured events only: Codex
|
|
97
161
|
native-session `codexErrorInfo` and Claude `stream-json` error fields (scheduled
|
|
98
162
|
Claude runs use `--output-format stream-json`; the host forwards only a compact
|
|
99
163
|
startup summary). Other engines advance only when their CLI is unavailable.
|
|
100
164
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
with no model, tool or hook activity
|
|
165
|
+
After work begins, only a typed Codex or Claude usage limit, or a Claude
|
|
166
|
+
credits-required rejection, permits continuation. The run must have ended on
|
|
167
|
+
that rejection, with no subsequent model, tool or hook activity. A native session cannot be resumed
|
|
104
168
|
under another login's configuration home, so the next eligible setup starts a fresh
|
|
105
169
|
session with the saved prompt plus one factual line naming the run, the stopped
|
|
106
170
|
setup, its native session and reset time, and that its work may be partly applied.
|
|
107
171
|
The agent reconciles from its own records; core replays nothing else.
|
|
108
172
|
|
|
173
|
+
These same completion and fallback rules apply after a graceful relay upgrade
|
|
174
|
+
adopts a running native session. Adoption attaches to that session; only an
|
|
175
|
+
eligible terminal failure starts the next saved setup.
|
|
176
|
+
|
|
177
|
+
For consecutive primary choices that differ only by native login profile, later
|
|
178
|
+
scheduled runs start with the last successfully completed login and rotate through
|
|
179
|
+
the remaining saved logins if it becomes unavailable. The existing owner control
|
|
180
|
+
state retains that preference and typed quota reset times across relay restarts.
|
|
181
|
+
Other tasks with saved fallbacks for those logins skip an exhausted scope until its reset;
|
|
182
|
+
unknown reset times use progressively spaced checks (5, 15, 30, then 60 minutes). A concurrent older run's
|
|
183
|
+
success does not erase a future reset. Saved models, effort, heterogeneous fallback
|
|
184
|
+
priority and chat selection are preserved; no unconfigured login is added.
|
|
185
|
+
|
|
109
186
|
A login or quota rejection skips the remaining setups in the same scope: the native
|
|
110
187
|
client and login profile (`claude`, `codex` including desktop and custom Codex
|
|
111
188
|
providers), `grok`, or the upstream provider for OpenCode/Pi models. A named
|
|
@@ -113,17 +190,47 @@ profile selects a separate owner-provisioned account and is eligible for its own
|
|
|
113
190
|
attempt; changing only the model in the same profile does not reset its scope.
|
|
114
191
|
Do not provision aliases of the same account to obtain more allowance. Ez does
|
|
115
192
|
not infer account independence from credentials or promise that a shared
|
|
116
|
-
organization's allowance is separate. Each setup runs at most once per
|
|
193
|
+
organization's allowance is separate. Each setup runs at most once per availability check; quota waits retain the earlier attempts.
|
|
117
194
|
|
|
118
195
|
Every attempt keeps the same run ID, occurrence, schedule revision, workspace,
|
|
119
|
-
delivery binding
|
|
196
|
+
delivery binding, in a fresh native session. Quota waits release the scheduled concurrency slot.
|
|
120
197
|
`run RUN_ID` shows `attempts`: each setup, quota scope, outcome, failure category
|
|
121
198
|
(`cli-unavailable`, `login-unavailable`, `quota`, `provider-rejected`, `access-denied`,
|
|
122
|
-
`quota-after-work-began`, `after-work-began`, `uncertain`, or `same-login-scope`/`same-quota-scope`
|
|
123
|
-
for a skipped setup), exit code, reported reset time and the session ID Ez assigned.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
reset
|
|
199
|
+
`quota-after-work-began`, `credits-required`, `credits-required-after-work-began`, `after-work-began`, `uncertain`, or `same-login-scope`/`same-quota-scope`
|
|
200
|
+
for a skipped setup), exit code, reported reset time and the session ID Ez assigned.
|
|
201
|
+
|
|
202
|
+
When all saved logins are quota-blocked, the same occurrence stays `queued` with
|
|
203
|
+
`quotaWait`. A known reset schedules its next availability check. If it passed during native
|
|
204
|
+
shutdown, one prompt probe precedes ordinary backoff. Unknown resets
|
|
205
|
+
back off per login to an hourly maximum. A credits-required rejection waits for
|
|
206
|
+
explicit retry. Other unavailable setups still fail with `setups-unavailable`.
|
|
207
|
+
Waiting consumes no native process or scheduled execution slot. The scheduler
|
|
208
|
+
keeps one pending occurrence per task, so repeated ticks do not accumulate copies.
|
|
209
|
+
Waiting occurrences share one probe per login until native work is accepted.
|
|
210
|
+
`/status` and the queued/running-work view show waiting and the next check.
|
|
211
|
+
|
|
212
|
+
After adding credits or manually resetting a provider limit, use either:
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
ezenciel-agents-schedule retry # wake current queued quota waits
|
|
216
|
+
ezenciel-agents-schedule retry RUN_ID # retry this waiting or prior quota-failed occurrence
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Telegram: `/retry_quota` or `/retry_quota RUN_ID` (`/retry-quota` also works).
|
|
220
|
+
The command works without an inference turn. It clears the cooldowns for the
|
|
221
|
+
occurrence's saved login scopes and requests a new availability check. It never
|
|
222
|
+
adds a login, changes the model or buys credits. A specific prior run is eligible
|
|
223
|
+
only when its failure was entirely quota-related, its schedule is current and
|
|
224
|
+
enabled, and no other occurrence of that task is queued or running. Cancelled,
|
|
225
|
+
interrupted, edited, foreign-owner and other failed runs cannot be retried this way.
|
|
226
|
+
`/retry` continues to handle failed incoming messages.
|
|
227
|
+
|
|
228
|
+
Prior attempts and stopped native-session references remain in the same run.
|
|
229
|
+
After partial work, the engine receives the existing factual continuation line
|
|
230
|
+
and reconciles effects from its own records. Core reconstructs no history.
|
|
231
|
+
Cooldowns persist in owner control state; waiting run records follow the existing
|
|
232
|
+
relay-memory retention contract and do not survive a restart. Core never purchases
|
|
233
|
+
credits, enables overage or consumes reset credits.
|
|
127
234
|
|
|
128
235
|
## Registered script schedules
|
|
129
236
|
|
|
@@ -249,8 +356,9 @@ Schedule definitions and cursors use protected atomic JSON files. A restart
|
|
|
249
356
|
between queue creation and cursor persistence does not duplicate an occurrence.
|
|
250
357
|
An overdue one-time job runs on return. Missed recurring occurrences coalesce into
|
|
251
358
|
one pending run; future runs resume at the next eligible time. A paused queued
|
|
252
|
-
occurrence waits for resume.
|
|
253
|
-
|
|
359
|
+
occurrence waits for resume. Removing a schedule invalidates its queued
|
|
360
|
+
occurrence; already-running work continues until explicitly cancelled. An edit
|
|
361
|
+
never invalidates queued or running work (see `edit` above).
|
|
254
362
|
|
|
255
363
|
`/stop` stops all active work; `cancel RUN_ID` stops one background task. `/cancel`
|
|
256
364
|
clears queued work. Pause/remove a recurring schedule to prevent future runs.
|
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
|
-
Handoff-compatible core upgrades build while native work continues and
|
|
19
|
+
Handoff-compatible core upgrades build while native work continues; activation and the later supervisor reload hold new turn admission. The current native executor retires from claiming new work; the replacement claims new requests while the retiring generation finishes its existing runs. The relay adopts those runs without replay, preserving their schedule holds, cancellation and settled delivery receipts. After completion or rollback, the supervisor holds new turn admission until existing native work finishes and its successor worker is ready. Queued requests remain in the running relay and file transport; they do not block this supervisor reload. OS service process-group cleanup therefore cannot kill a retiring generation or be starved by newly admitted work. 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. Older runtimes without the handoff contract retain the full native-work drain. Isolated agents migrate once after live native work finishes to a separate, unprivileged executor container. Pending requests remain queued for that executor; its private binding is copied into the container as root-owned, group-readable and not writable by the configured runtime user. Existing named volumes are attached as external volumes. It uses the same file transport; later relay/runtime upgrades preserve that executor generation. No native CLI or provider credentials move to the host. 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
|
Compatible main upgrades also finish their advisory workspace/native-client
|
|
22
22
|
backup before closing admission or stopping the relay. Large native histories do
|
|
@@ -63,6 +63,24 @@ restart and verify. Do not recreate the agent or its registry. Subsequent
|
|
|
63
63
|
compatible upgrades are agent-owned. Noncanonical/multi-agent host configuration
|
|
64
64
|
files require migration to one canonical deployment per service first.
|
|
65
65
|
|
|
66
|
+
## Recovering an isolated-executor or supervisor admission defect
|
|
67
|
+
|
|
68
|
+
The post-update reload requires a live relay status response taken after
|
|
69
|
+
in-progress admissions finish. An unavailable or timed-out response keeps
|
|
70
|
+
admission closed and leaves active executors running.
|
|
71
|
+
|
|
72
|
+
If an older supervisor cannot start its isolated executor, or its post-update
|
|
73
|
+
reload admits new work indefinitely, it can prevent its own correction from
|
|
74
|
+
being installed. Use an exact,
|
|
75
|
+
reviewed newer package's `src/updates/supervisor.mjs DEPLOYMENT_DIRECTORY` as the
|
|
76
|
+
existing OS service's temporary entry point. First verify that no native run is
|
|
77
|
+
active. Keep the same service user, environment, registry, deployment and queued
|
|
78
|
+
requests; do not edit published package files or delete claims. This CLI runs the
|
|
79
|
+
same supervisor and retains the active package binding until normal upgrade
|
|
80
|
+
activation. Let existing queued jobs finish, then prepare and apply the corrected
|
|
81
|
+
release through `ez updates`. Restore the retained bootstrap entry point after
|
|
82
|
+
the active package and running services verify the corrected version.
|
|
83
|
+
|
|
66
84
|
## Package-manager preflight and repair
|
|
67
85
|
|
|
68
86
|
Before installing candidate dependencies or stopping services, the supervisor
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.58",
|
|
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
|
@@ -2,15 +2,15 @@ import { parseArgs } from 'node:util'
|
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
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'
|
|
5
|
+
import { authProfileStatus, readModels, presetLabel, presetProvider, resolveFallbacks, sameChoice, sameEngine, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
6
|
+
import { cliHome, provisionedHome } from './auth-profile.js'
|
|
7
7
|
import { ControlStore } from './control-state.js'
|
|
8
8
|
import { resolveIsolation } from './isolation.js'
|
|
9
9
|
|
|
10
|
-
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>'
|
|
11
|
-
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'}}})
|
|
10
|
+
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> | fallbacks [--fallback cli=CLI[,auth-profile=NAME][,provider=ID][,model=MODEL][,effort=EFFORT] ... | --clear] | telemetry [on|off]'
|
|
11
|
+
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'},fallback:{type:'string',multiple:true},clear:{type:'boolean'}}})
|
|
12
12
|
if(values.help){
|
|
13
|
-
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.`)
|
|
13
|
+
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.\nfallbacks sets the ordered chain tried after the selected AI for chat and application turns, captured when work is queued. An entry for the selected AI's CLI without provider, model or effort keeps the turn's own model and effort. Core advances only when a setup is unavailable or rejected before work, or stopped at its usage limit (a Codex chat setup advances only when its CLI is unavailable); each other login resumes its own native session for that conversation. Without options it shows the chain.\ntelemetry on records per-attempt model, token, tool and error-category counts in run records (ezenciel-agents-schedule run RUN_ID); off by default; never prompts, tool arguments/results or costs.`)
|
|
14
14
|
process.exit(0)
|
|
15
15
|
}
|
|
16
16
|
if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
|
|
@@ -29,6 +29,32 @@ else if(positionals[0]==='profile' && positionals[1]==='add'){
|
|
|
29
29
|
: `CLAUDE_CONFIG_DIR=${home} claude auth login (or store a \`claude setup-token\` token in ${home}/oauth-token, mode 600)`
|
|
30
30
|
console.log(JSON.stringify({cli:values.cli,authProfile,home,created:created!==undefined,ownerLogin:login}))
|
|
31
31
|
}
|
|
32
|
+
else if(positionals[0]==='fallbacks'){
|
|
33
|
+
const control=new ControlStore(controlDir,900000)
|
|
34
|
+
if(values.fallback && values.clear)throw new Error('Choose --fallback or --clear')
|
|
35
|
+
if(values.fallback){
|
|
36
|
+
const chain=[]
|
|
37
|
+
for(const [index,spec] of values.fallback.entries()){
|
|
38
|
+
const fields=Object.fromEntries(spec.split(',').map(part=>{const at=part.indexOf('=');if(at<1)throw new Error('Fallback uses cli=CLI[,auth-profile=NAME][,provider=ID][,model=MODEL][,effort=EFFORT]');return [part.slice(0,at),part.slice(at+1)]}))
|
|
39
|
+
if(!fields.cli || Object.keys(fields).some(key=>!['cli','auth-profile','provider','model','effort'].includes(key)))throw new Error('Fallback uses cli=CLI[,auth-profile=NAME][,provider=ID][,model=MODEL][,effort=EFFORT]')
|
|
40
|
+
const preset:AiPreset={id:`fallback-${index+1}`,name:'Fallback',cli:fields.cli,...(fields.provider ? {provider:fields.provider} : {}),...(fields['auth-profile'] ? {authProfile:fields['auth-profile']} : {}),...(fields.model ? {model:fields.model} : {}),...(fields.effort ? {effort:fields.effort} : {})}
|
|
41
|
+
if(preset.authProfile)await provisionedHome(controlDir,preset.cli,preset.authProfile)
|
|
42
|
+
await validateSelection(preset,await catalog())
|
|
43
|
+
chain.push({...preset,name:presetLabel(preset).slice(0,80)})
|
|
44
|
+
}
|
|
45
|
+
await control.setChatFallbacks(chain)
|
|
46
|
+
} else if(values.clear) await control.setChatFallbacks(undefined)
|
|
47
|
+
const state=await control.status()
|
|
48
|
+
const selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
|
|
49
|
+
console.log(JSON.stringify({fallbacks:state.ai?.fallbacks ?? [],...(selected ? {selected:presetLabel(selected),order:[selected,...resolveFallbacks(selected,state.ai?.fallbacks) ?? []].map(presetLabel)} : {}),
|
|
50
|
+
applies:'chat and application work queued afterwards; queued work keeps its captured chain'}))
|
|
51
|
+
}
|
|
52
|
+
else if(positionals[0]==='telemetry'){
|
|
53
|
+
const control=new ControlStore(controlDir,900000)
|
|
54
|
+
if(positionals[1]==='on' || positionals[1]==='off')await control.setRunTelemetry(positionals[1]==='on')
|
|
55
|
+
else if(positionals[1]!==undefined)throw new Error(usage)
|
|
56
|
+
console.log(JSON.stringify({runTelemetry:(await control.status()).runTelemetry===true ? 'on' : 'off',applies:'runs started afterwards'}))
|
|
57
|
+
}
|
|
32
58
|
else if(positionals[0]==='default'){
|
|
33
59
|
const control=new ControlStore(controlDir,900000)
|
|
34
60
|
const state=await control.status()
|
package/src/ai.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { desktopCodexPath } from './desktop-bridge.js'
|
|
|
10
10
|
import { AUTH_PROFILE_CLIS, claudeAuthEnvironment, cliHome, isAuthProfile, listAuthProfiles } from './auth-profile.js'
|
|
11
11
|
|
|
12
12
|
export type AiPreset = { id: string; name: string; cli: string; provider?: string; authProfile?: string; model?: string; effort?: string }
|
|
13
|
-
//
|
|
13
|
+
// Ordered fallback setups (scheduled tasks, or the owner's chat chain captured
|
|
14
|
+
// when work is queued) retain the native rejection/partial-work boundaries.
|
|
14
15
|
export type ExecutionChoice = { sessionId: string; preset: AiPreset; fallbacks?: AiPreset[] }
|
|
15
16
|
export type ModelChoice = { cli: string; provider?: string; authProfile?: string; model?: string; name: string; efforts: string[] }
|
|
16
17
|
type Engine = { cli?: string; authProfile?: string }
|
|
@@ -35,6 +36,21 @@ export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
|
|
|
35
36
|
return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset) &&
|
|
36
37
|
(c.fallbacks === undefined || (Array.isArray(c.fallbacks) && c.fallbacks.length > 0 && c.fallbacks.every(isPreset))))
|
|
37
38
|
}
|
|
39
|
+
// The owner's chat fallback chain for one captured primary choice. A same-client
|
|
40
|
+
// entry without provider, model or effort keeps the primary's settings, so other
|
|
41
|
+
// logins preserve each conversation's or role's model and effort. Entries equal
|
|
42
|
+
// to an earlier setup are dropped.
|
|
43
|
+
export const resolveFallbacks = (primary: AiPreset, chain: AiPreset[] = []): AiPreset[] | undefined => {
|
|
44
|
+
const resolved: AiPreset[] = []
|
|
45
|
+
for (const entry of chain) {
|
|
46
|
+
const inherit = entry.cli === primary.cli && entry.provider === undefined && entry.model === undefined && entry.effort === undefined
|
|
47
|
+
const { provider, model, effort } = inherit ? primary : entry
|
|
48
|
+
const preset: AiPreset = { id: entry.id, name: entry.name, cli: entry.cli, ...(provider ? { provider } : {}),
|
|
49
|
+
...(entry.authProfile ? { authProfile: entry.authProfile } : {}), ...(model ? { model } : {}), ...(effort ? { effort } : {}) }
|
|
50
|
+
if (![primary, ...resolved].some(other => sameChoice(other, preset))) resolved.push({ ...preset, name: presetLabel(preset).slice(0, 80) })
|
|
51
|
+
}
|
|
52
|
+
return resolved.length ? resolved : undefined
|
|
53
|
+
}
|
|
38
54
|
export const presetLabel = (p: AiPreset) => `${p.cli}${p.authProfile ? `@${p.authProfile}` : ''}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
|
|
39
55
|
// OpenCode encodes its provider in the native provider/model identifier.
|
|
40
56
|
export const presetProvider = (p: AiPreset) => p.cli === 'opencode' ? p.model?.split('/')[0] : p.provider
|
package/src/codex-session.ts
CHANGED
|
@@ -6,8 +6,9 @@ import { fileURLToPath } from 'node:url'
|
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { terminateJob, validateCodexProvider, type CodexProviderBinding } from './executor.js'
|
|
8
8
|
import { codexRejection, codexResetAt, startupLine } from './setup-fallback.js'
|
|
9
|
+
import { telemetryObserver } from './run-telemetry.js'
|
|
9
10
|
|
|
10
|
-
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external';codexProvider?:CodexProviderBinding}
|
|
11
|
+
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external';codexProvider?:CodexProviderBinding;telemetry?:boolean}
|
|
11
12
|
type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
|
|
12
13
|
|
|
13
14
|
// Keep Codex's native session alive. Codex itself starts goal continuation turns;
|
|
@@ -19,6 +20,15 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
19
20
|
const child=io.launch?.() ?? spawn('codex',['app-server','--stdio','--disable','memories','--enable','skip_host_skill_discovery'],{cwd:options.workspace,env:process.env,stdio:['pipe','pipe','pipe']})
|
|
20
21
|
const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
|
|
21
22
|
let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false,workBegan=false,resetAt:string|undefined
|
|
23
|
+
// Owner-enabled telemetry: app-server items and usage in the exec event shape.
|
|
24
|
+
const telemetry=options.telemetry ? telemetryObserver() : undefined
|
|
25
|
+
let reported=''
|
|
26
|
+
const observe=(event?:object)=>{
|
|
27
|
+
if(!telemetry)return
|
|
28
|
+
if(event)telemetry.write(JSON.stringify(event)+'\n')
|
|
29
|
+
const line=telemetry.summary();if(line!==reported){reported=line;emit(line)}
|
|
30
|
+
}
|
|
31
|
+
const tools:Record<string,string>={commandExecution:'command_execution',fileChange:'file_change',webSearch:'web_search',mcpToolCall:'mcp_tool_call'}
|
|
22
32
|
let resolveDone!:(code:number)=>void
|
|
23
33
|
const done=new Promise<number>(resolve=>{resolveDone=resolve})
|
|
24
34
|
const pending=new Map<number,{resolve:(value:any)=>void;reject:(error:Error)=>void;timer:ReturnType<typeof setTimeout>}>()
|
|
@@ -33,6 +43,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
33
43
|
const settled=(goal:any)=>{
|
|
34
44
|
if(goal)hadGoal=true
|
|
35
45
|
if(activeTurn || goal?.status==='active')return
|
|
46
|
+
if(goal?.status==='usageLimited')emit(startupLine({workBegan,rejection:{category:'quota',...(resetAt?{resetAt}:{})}}))
|
|
36
47
|
if(goal && goal.status!=='complete'){console.error(`Native goal stopped: ${goal.status}`);finish(1);return}
|
|
37
48
|
if(!goal && hadGoal){fail(new Error('Native goal disappeared without verified completion'));return}
|
|
38
49
|
if(!sawTurn)return
|
|
@@ -64,6 +75,11 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
64
75
|
if(message.method==='account/rateLimits/updated')resetAt=codexResetAt(message.params?.rateLimits) ?? resetAt
|
|
65
76
|
if(message.params?.threadId!==threadId)return
|
|
66
77
|
if(message.method==='turn/started'){activeTurn=message.params.turn.id;sawTurn=true}
|
|
78
|
+
const item=message.params?.item
|
|
79
|
+
if((message.method==='item/started'||message.method==='item/completed') && tools[item?.type])
|
|
80
|
+
observe({type:message.method==='item/started'?'item.started':'item.completed',item:{id:item.id,type:tools[item.type],server:item.server,tool:item.tool,status:['failed','declined'].includes(item.status)?'failed':'completed'}})
|
|
81
|
+
if(message.method==='thread/tokenUsage/updated'){const total=message.params.tokenUsage?.total
|
|
82
|
+
observe({type:'turn.completed',usage:{input_tokens:total?.inputTokens,cached_input_tokens:total?.cachedInputTokens,output_tokens:total?.outputTokens}})}
|
|
67
83
|
// Any item beyond the echoed input means the provider accepted the turn.
|
|
68
84
|
if(message.method==='item/started' && message.params.item?.type!=='userMessage' && !workBegan){workBegan=true;emit(startupLine({workBegan}))}
|
|
69
85
|
if(message.method==='thread/goal/updated')settled(message.params.goal)
|
|
@@ -73,7 +89,9 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
73
89
|
if(message.params.turn.status!=='completed'){
|
|
74
90
|
if(message.params.turn.status==='interrupted')finish(130)
|
|
75
91
|
else {
|
|
76
|
-
|
|
92
|
+
observe({type:'turn.failed'})
|
|
93
|
+
const rejected=codexRejection(message.params.turn.error?.codexErrorInfo)
|
|
94
|
+
const category=!workBegan || rejected==='quota' ? rejected : undefined
|
|
77
95
|
if(category)emit(startupLine({workBegan,rejection:{category,...(category==='quota' && resetAt ? {resetAt} : {})}}))
|
|
78
96
|
fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
|
|
79
97
|
? message.params.turn.error.message : 'Native turn failed without error details')))
|
|
@@ -88,6 +106,8 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
88
106
|
try{
|
|
89
107
|
await request('initialize',{clientInfo:{name:'ezenciel-agents',version:'1'},capabilities:{experimentalApi:true}})
|
|
90
108
|
send({method:'initialized',params:{}})
|
|
109
|
+
// Read native quota metadata even when a rejected turn emits no update.
|
|
110
|
+
await request('account/rateLimits/read',{}).then(limits=>{resetAt=codexResetAt(limits.rateLimits)}).catch(()=>{})
|
|
91
111
|
const result=await request('thread/start',{
|
|
92
112
|
cwd:options.workspace,approvalPolicy:'never',sandbox:options.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write',model:options.model,
|
|
93
113
|
config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[]),...(options.additionalWorkspaces ?? [])],
|
|
@@ -96,6 +116,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
96
116
|
})
|
|
97
117
|
threadId=result.thread?.id
|
|
98
118
|
if(!threadId)throw new Error('Codex did not return a native thread ID')
|
|
119
|
+
telemetry?.model(result.model)
|
|
99
120
|
emit(JSON.stringify({type:'thread.started',thread_id:threadId}))
|
|
100
121
|
await request('turn/start',{threadId,input:[{type:'text',text:options.prompt}],model:options.model,effort:options.effort,
|
|
101
122
|
...(options.codexSandbox === 'external' ? {sandboxPolicy:{type:'externalSandbox',networkAccess:'enabled'}} : {})})
|