@jc_stack/ez-agents 0.1.0-beta.45 → 0.1.0-beta.47

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 (50) hide show
  1. package/.dockerignore +2 -0
  2. package/CHANGELOG.md +30 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/Dockerfile +13 -9
  5. package/docker/engine-pnpm-workspace.yaml +4 -0
  6. package/docker/smoke.mjs +1 -1
  7. package/docs/application-channel.md +3 -0
  8. package/docs/architecture/ai-selection.md +8 -2
  9. package/docs/architecture/authority-boundaries.md +2 -2
  10. package/docs/development-and-testing.md +1 -1
  11. package/docs/docker-runtime.md +10 -0
  12. package/docs/plugin-guide.md +1 -1
  13. package/docs/plugins.md +6 -1
  14. package/docs/releasing.md +1 -1
  15. package/docs/scheduling.md +17 -3
  16. package/docs/upgrades.md +7 -1
  17. package/package.json +4 -3
  18. package/pnpm-workspace.yaml +7 -0
  19. package/scripts/smoke-scheduler.ts +4 -1
  20. package/src/ai.ts +20 -2
  21. package/src/application-channel.ts +3 -1
  22. package/src/delivery-socket.ts +15 -1
  23. package/src/host-executor.ts +5 -0
  24. package/src/index.ts +3 -1
  25. package/src/install-tools.mjs +11 -5
  26. package/src/model-policy.ts +4 -0
  27. package/src/plugins/manager.mjs +7 -1
  28. package/src/runs.ts +4 -1
  29. package/src/schedule-cli.ts +12 -3
  30. package/src/scheduler.ts +28 -3
  31. package/src/task-executor.ts +2 -2
  32. package/src/updates/control.mjs +2 -1
  33. package/src/updates/runtime.mjs +15 -6
  34. package/src/updates/supervisor.mjs +23 -13
  35. package/test/ai.test.ts +45 -5
  36. package/test/application-controls.test.ts +3 -0
  37. package/test/channel-owner.test.ts +2 -2
  38. package/test/desktop-bridge.test.ts +2 -1
  39. package/test/failure.test.ts +4 -4
  40. package/test/host-executor.test.ts +4 -4
  41. package/test/install-tools.test.mjs +6 -5
  42. package/test/native-tasks.test.ts +1 -1
  43. package/test/plugin-manager.test.mjs +15 -0
  44. package/test/schedule-cli.test.ts +47 -3
  45. package/test/scheduled-tasks.test.ts +2 -2
  46. package/test/scheduler-host.test.ts +14 -3
  47. package/test/scheduler-relay.test.ts +47 -2
  48. package/test/scheduler.test.ts +21 -1
  49. package/test/tasks.test.ts +1 -1
  50. package/test/updates.test.mjs +68 -8
package/.dockerignore CHANGED
@@ -27,3 +27,5 @@
27
27
  !scripts/trusted-beta.mjs
28
28
  !scripts/generate-publish-caller.mjs
29
29
  !scripts/smoke-busy-reply.ts
30
+
31
+ !pnpm-workspace.yaml
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.47
4
+
5
+ - Trigger an existing scheduled task manually with its saved instructions, model,
6
+ reasoning effort and delivery settings in a fresh session. Preserve recurring
7
+ timing and prevent overlapping schedule runs.
8
+ - Require explicit saved models for scheduled work; reject missing or mismatched
9
+ models before launch. Legacy definitions remain readable for explicit repair.
10
+ - Refresh Codex catalogs through the bound native CLI. Managed builds and upgrades
11
+ resolve latest Codex before cache lookup and supersede stale deployment pins.
12
+ Record the concrete native version; restricted correspondence uses audited
13
+ Codex 0.160.0 and fails closed on unaudited versions.
14
+ - Keep owner channels responsive while upgrades wait for active work to finish.
15
+ - Standardize dependency installs and runtime builds on pinned pnpm.
16
+
17
+ Beta limits: live fresh-host/reboot acceptance and the Daily Leverage VM smoke
18
+ remain pending. Publication does not establish an installed-agent upgrade.
19
+
20
+ ## 0.1.0-beta.46
21
+
22
+ - Allow private RC/QA candidates to graduate to their matching published beta
23
+ through the existing updater, without permitting a move to an older beta.
24
+
25
+ - Refresh the isolated broker after plugin upgrade or rollback so status reports
26
+ the resulting installed versions rather than its startup inventory. Preserve
27
+ the previous registry snapshot independently during plugin activation.
28
+ Preserve stopped plugins as stopped when Docker retains a created container.
29
+
30
+ - Include the existing Telegram runtime/version summary in authorized private
31
+ channel controls so channel status can show the running relay and host versions.
32
+
3
33
  ## 0.1.0-beta.45
4
34
 
5
35
  - Keep owner chat responsive while a scheduled native turn runs. Foreground
package/CONTRIBUTING.md CHANGED
@@ -12,7 +12,7 @@ repository. Public docs describe shipped behavior and explicit limitations.
12
12
  unnecessary behavior first, and explain why remaining code is needed. Change
13
13
  the code, user instructions and focused tests together. Authority,
14
14
  paths, credentials, cancellation and uncertain writes need negative tests.
15
- 4. Run `pnpm verify`, `npm run release:check` and `git diff --check`.
15
+ 4. Run `pnpm verify`, `pnpm run release:check` and `git diff --check`.
16
16
  Packaging/runtime changes also need the Docker checks in docs/releasing.md.
17
17
  5. Open a PR explaining the problem, resulting behavior, verification and limits.
18
18
  Include a short sanitized reproduction. State which tests were not run.
package/Dockerfile CHANGED
@@ -3,8 +3,12 @@ FROM node:22.22.0-bookworm-slim@sha256:dd9d21971ec4395903fa6143c2b9267d048ae01ca
3
3
  WORKDIR /app
4
4
  RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git util-linux ffmpeg docker.io && rm -rf /var/lib/apt/lists/*
5
5
  COPY --from=compose-bin /docker-compose /usr/local/bin/docker-compose
6
- RUN corepack enable && corepack prepare pnpm@10.30.3 --activate
7
- COPY package.json ./
6
+ ENV PNPM_HOME=/opt/pnpm COREPACK_HOME=/opt/corepack COREPACK_DEFAULT_TO_LATEST=0
7
+ ENV PATH=/opt/pnpm:$PATH
8
+ RUN corepack enable && corepack prepare pnpm@10.30.3 --activate && pnpm config set --global global-bin-dir /usr/local/bin
9
+ RUN mkdir -p /opt/pnpm/global/5
10
+ COPY docker/engine-pnpm-workspace.yaml /opt/pnpm/global/5/pnpm-workspace.yaml
11
+ COPY package.json pnpm-workspace.yaml ./
8
12
  COPY docker/pnpm-lock.yaml ./pnpm-lock.yaml
9
13
  RUN pnpm install --frozen-lockfile --ignore-scripts
10
14
  COPY . .
@@ -12,27 +16,27 @@ FROM dependencies AS test
12
16
  RUN pnpm verify
13
17
  FROM dependencies AS runtime
14
18
  # Isolated agents run this CLI in the relay; do not bind-mount the
15
- # operator's ~/.codex. Pin the reviewed CLI for repeatable release images.
16
- ARG CODEX_CLI_VERSION=0.153.4
17
- RUN npm install -g @openai/codex@${CODEX_CLI_VERSION} && command -v codex
19
+ # operator's ~/.codex. Managed builds resolve latest to a concrete cache key.
20
+ ARG CODEX_CLI_VERSION=latest
21
+ RUN pnpm add --global --ignore-scripts @openai/codex@${CODEX_CLI_VERSION} && command -v codex && codex --version
18
22
  # Isolated agents run the selected CLI in the relay; host-capable agents reuse
19
23
  # the host installation instead. OpenCode carries no task-runner pin because
20
24
  # restricted messaging tasks stay on the audited Codex above.
21
25
  # Pin the reviewed CLI for repeatable release images.
22
26
  ARG OPENCODE_CLI_VERSION=1.18.32
23
- RUN npm install -g opencode-ai@${OPENCODE_CLI_VERSION} && command -v opencode && opencode --version
27
+ RUN pnpm add --global --allow-build=opencode-ai@${OPENCODE_CLI_VERSION} opencode-ai@${OPENCODE_CLI_VERSION} && command -v opencode && opencode --version
24
28
  # Isolated agents select pi from the relay menu; the binary must resolve on
25
29
  # the relay PATH just like codex/opencode above.
26
30
  # Pin the reviewed CLI for repeatable release images.
27
31
  ARG PI_CLI_VERSION=0.87.1
28
- RUN npm install -g @earendil-works/pi-coding-agent@${PI_CLI_VERSION} && command -v pi
32
+ RUN pnpm add --global --ignore-scripts @earendil-works/pi-coding-agent@${PI_CLI_VERSION} && command -v pi
29
33
  RUN chmod +x docker/entrypoint.sh bin/ez bin/ezenciel-agents* && mkdir -p /state/control /state/home /workspace && chown node:node /state/control /state/home /workspace
30
34
  RUN node -e 'for (const [name, target] of Object.entries(require("./package.json").bin)) require("node:fs").symlinkSync("/app/" + target, "/usr/local/bin/" + name); require("node:fs").symlinkSync("/app/bin/ez", "/usr/local/bin/ez")'
31
35
  # Build identity for relay status (package version stays release-owned).
32
36
  ARG BUILD_TAG=""
33
37
  ARG BUILD_SHA=""
34
- RUN node -e 'require("node:fs").writeFileSync("/app/build.json",JSON.stringify({tag:process.env.BUILD_TAG||"",sha:process.env.BUILD_SHA||""})+"\n")'
35
- ENV HOME=/state/home EZ_AGENT_WORKSPACE=/workspace EZ_CONTROL_DIR=/state/control EZ_EXECUTOR_CLI=grok PATH=/app/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
38
+ RUN node -e 'require("node:fs").writeFileSync("/app/build.json",JSON.stringify({tag:process.env.BUILD_TAG||"",sha:process.env.BUILD_SHA||"",codexVersion:require("node:child_process").execFileSync("codex",["--version"],{encoding:"utf8"}).trim()})+"\n")'
39
+ ENV HOME=/state/home EZ_AGENT_WORKSPACE=/workspace EZ_CONTROL_DIR=/state/control EZ_EXECUTOR_CLI=grok PATH=/app/bin:/opt/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
36
40
  WORKDIR /workspace
37
41
  ENTRYPOINT ["/app/docker/entrypoint.sh"]
38
42
  CMD ["start"]
@@ -0,0 +1,4 @@
1
+ # Native CLI engines share the same install protections as project dependencies.
2
+ minimumReleaseAge: 10080
3
+ trustPolicy: no-downgrade
4
+ blockExoticSubdeps: true
package/docker/smoke.mjs CHANGED
@@ -34,7 +34,7 @@ try {
34
34
  assert.equal(login.status,0,login.stderr);
35
35
  assert.match(login.stdout,/\/usr\/local\/bin\/ezenciel-agents-application/);
36
36
  assert.match(login.stdout,/\/usr\/local\/bin\/ezenciel-agents-message/);
37
- assert.match(login.stdout,/codex-cli 0\.153\.4/);
37
+ assert.match(login.stdout,/codex-cli \d+\.\d+\.\d+/);
38
38
  const started = run(['run','-d','--name',application,...nonroot,image,'start']);
39
39
  assert.equal(started.status,0,started.stderr);
40
40
  let ready = false;
@@ -58,6 +58,9 @@ the ordinary last-selection-wins behavior. Already-admitted runs keep their
58
58
  captured conversation and AI. Uncertain control responses require a fresh read,
59
59
  not blind repetition of `/new`.
60
60
 
61
+ The scope-control response includes `software`, the same current runtime/version
62
+ summary used by Telegram status. It is read-only and does not create a session.
63
+
61
64
  For private application conversations, `GET /v1/scope-control?scope=<encoded-scope>` returns
62
65
  the same catalog and the current scope's public control ID and preset. The scope
63
66
  is the application's original admission scope, resolved under its authenticated
@@ -36,7 +36,12 @@ References used for the adapter contract:
36
36
  - [Codex non-interactive execution](https://learn.chatgpt.com/docs/non-interactive-mode)
37
37
  - [OpenCode native run events](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/cli/cmd/run.ts)
38
38
 
39
- The catalog is local metadata, not an authentication or billing health check.
39
+ The catalog is native metadata, not an authentication or billing health check.
40
+ Codex refreshes through `codex debug models` with the whitelisted executor
41
+ environment and the execution-bound `CODEX_HOME`; discovery starts no session
42
+ or inference. Only visible model names and supported efforts are projected.
43
+ Unavailable discovery retains the native cache. The installed native version
44
+ still owns which models it advertises; Core never guesses newly released aliases.
40
45
  Grok/Codex/OpenCode support explicit listed model/effort choices. Other installed clients
41
46
  offer their own default only in this slice. OpenCode lists the installed `opencode models`
42
47
  catalog with the whitelisted executor environment, so only models the relay can actually
@@ -46,7 +51,8 @@ Authenticated providers appear only when the agent-bound
46
51
  login); otherwise the free tier lists. `EZ_OPENCODE_PROVIDERS` optionally
47
52
  restricts the catalog to named providers (e.g. `opencode-go`); a set
48
53
  allowlist that matches nothing offers no OpenCode choice rather than falling
49
- back outside it. Refresh by opening the native client;
54
+ back outside it. Codex discovery refreshes directly; for other cache-only
55
+ catalogs, refresh by opening the native client;
50
56
  the relay does not install models, manage subscriptions or guess aliases.
51
57
 
52
58
  Setup initialization and relay startup seed one default choice per installed client.
@@ -109,12 +109,12 @@ after service outages; core revocation blocks sends immediately.
109
109
 
110
110
  ## Native execution and core tools
111
111
 
112
- V1 uses audited Codex CLI **0.153.4** for task work, regardless of the owner's
112
+ V1 uses audited Codex CLI **0.160.0** for task work, regardless of the owner's
113
113
  selected executor. Missing or different versions fail closed; upgrading this pin
114
114
  requires repeating the native tool inventory test. Owner work retains its normal
115
115
  executor. The task runner creates a fresh ephemeral home/session, skips user
116
116
  config, rules and ancestor project instructions, and disables shell, file/image,
117
- browser, apps, hooks, memory and agent spawning tools. A native permissions
117
+ browser, apps, hooks, memory, goals and agent spawning tools. A native permissions
118
118
  profile denies general filesystem access and tool network access. No owner
119
119
  workspace or conversation is passed to this runner. The runner uses the pinned
120
120
  CLI's bundled model catalog with task-specific tool defaults: direct MCP calls,
@@ -2,7 +2,7 @@
2
2
 
3
3
  Use Node 22+ and pnpm 10.30.3: `pnpm install --frozen-lockfile`, then
4
4
  `pnpm verify`. Tests use temporary synthetic state; TypeScript checks source and
5
- tests. Use `npm run release:check` for package contents. See CONTRIBUTING.md for
5
+ tests. Use `pnpm run release:check` for package contents. See CONTRIBUTING.md for
6
6
  PR expectations and [release checks](releasing.md) for Docker and clean-host QA.
7
7
 
8
8
  For the cross-repository fixture, place the reviewed WhatsApp source beside this
@@ -162,6 +162,16 @@ control state, then remove only the verified orphaned `control-state.lock` and
162
162
  restart the single relay. Never remove a lock while a writer might still be live.
163
163
  Model catalog metadata is exported from the selected host CLI without credentials.
164
164
  The AI menu stays within that CLI. No automatic executor fallback is performed.
165
+ Codex discovery invokes `codex debug models` in the agent's bound `CODEX_HOME`
166
+ and projects only visible model names and supported reasoning efforts. Older
167
+ clients without that command or unavailable discovery retain the native cache.
168
+ The native CLI owns availability: a new model may require a CLI upgrade as well
169
+ as a catalog refresh. Managed builds and upgrades resolve npm Codex `latest` before cache lookup and
170
+ record the concrete version in the build/update receipt and image identity. Old
171
+ deployment Codex pins are superseded. Host installations remain operator-owned.
172
+ Restricted correspondence retains its audited native version gate and fails closed
173
+ when the normal CLI is newer than the audited version. Direct Docker builds use
174
+ `latest`; use `--no-cache` to refresh native acquisition on a reused builder.
165
175
 
166
176
  ## Secrets and plugins
167
177
 
@@ -97,7 +97,7 @@ baseline is:
97
97
  ```sh
98
98
  pnpm install --frozen-lockfile
99
99
  pnpm verify
100
- npm run release:check
100
+ pnpm run release:check
101
101
  git diff --check
102
102
  ```
103
103
 
package/docs/plugins.md CHANGED
@@ -157,6 +157,11 @@ snapshots. Reinstallation reuses that data. Plain install does not replace a dif
157
157
  compatible upgrades under saved policy; see [upgrades](upgrades.md). Manual
158
158
  uninstall/reinstall remains available but does not promise schema rollback.
159
159
 
160
+ Installed package files retain their reviewed modes (0644 for ordinary files,
161
+ 0755 for executables and nested directories), including under restrictive umasks
162
+ or inherited default ACLs. The snapshot root remains private (0700); provider
163
+ data and private operation receipts keep their separate permissions.
164
+
160
165
  For another reviewed local package, inspect with `plugins inspect <id> --source
161
166
  /absolute/source`, then install with that source and the returned `--revision
162
167
  sha256:...`. Changed contents fail. To make a reviewed package discoverable without installing,
@@ -329,7 +334,7 @@ only to its authoritative service over the reviewed transport.
329
334
 
330
335
  ## Verification
331
336
 
332
- `npm run verify` runs offline negative/contract tests. Explicit Docker integration:
337
+ `pnpm run verify` runs offline negative/contract tests. Explicit Docker integration:
333
338
  `node docker/plugin-smoke.mjs`. It builds the actual WhatsApp package with the
334
339
  synthetic transport in an isolated snapshot and tests installation, start,
335
340
  registered CLI, literal file paths, idempotency, restart persistence and
package/docs/releasing.md CHANGED
@@ -11,7 +11,7 @@ separate release bot is not required.
11
11
  update plugin manifest version when present. After lockfile changes, copy
12
12
  `pnpm-lock.yaml` to `docker/pnpm-lock.yaml` (npm omits the root lockfile; CI
13
13
  checks this shipping copy is identical). Never overwrite a published version.
14
- 2. Run `pnpm install --frozen-lockfile`, `pnpm verify`, `npm run release:check`,
14
+ 2. Run `pnpm install --frozen-lockfile`, `pnpm verify`, `pnpm run release:check`,
15
15
  `git diff --check`. Review `npm audit --omit=dev` and dependency licenses.
16
16
  Record accepted risks with a reason; never run an unreviewed audit fix.
17
17
  3. Build both Docker targets from a clean checkout:
@@ -13,6 +13,7 @@ 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 trigger SCHEDULE_ID --key owner-smoke-20261003
16
17
  ezenciel-agents-schedule pause SCHEDULE_ID
17
18
  ezenciel-agents-schedule resume SCHEDULE_ID
18
19
  ezenciel-agents-schedule remove SCHEDULE_ID
@@ -36,9 +37,22 @@ the host changes zones. Nonexistent DST wall times are skipped; repeated wall
36
37
  times fire once, at the earlier instant. Search is bounded to eight years.
37
38
  Public-holiday calendars and arbitrary RRULE syntax are not implemented.
38
39
 
39
- New tasks inherit selected engine settings. Explicit `--cli`, `--model` and
40
- `--effort` override those choices; omitted values use native defaults. Edits
41
- preserve existing choices. Historical deferred tasks keep their source context
40
+ New tasks capture selected engine settings. Explicit `--cli`, `--model` and
41
+ `--effort` override those choices. Edits preserve existing choices.
42
+ Core always passes the task's concrete saved model. Creation and edits
43
+ require a model in the captured settings or an explicit `--model`; missing models
44
+ are rejected. Legacy tasks with no saved model remain inspectable, but dispatch
45
+ and executor admission refuse to run them until they are explicitly configured.
46
+ Chat changes and native defaults never supply a missing model at run time.
47
+ To run an existing task outside its cadence, use `trigger ID --key REQUEST_KEY`: it queues
48
+ the task's saved instructions, engine, model, effort and delivery binding in a
49
+ fresh session, without changing its definition or next regular occurrence.
50
+ Model/text/trigger overrides are rejected. Reuse the same request key after an
51
+ uncertain response; it reads back the same run while the relay retains it.
52
+ After a relay restart, inspect native history and provider/canonical receipts
53
+ before issuing another request: the run ledger remains intentionally in memory.
54
+ Paused, stale-owner, overlapping or held tasks and unmet conditions cannot be
55
+ triggered. Historical deferred tasks keep their source context
42
56
  available through `ezenciel-agents-schedule context`.
43
57
 
44
58
  ## Execution and authority
package/docs/upgrades.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Agent-owned software upgrades
2
2
 
3
+ Private `beta.N.rc.M` and `beta.N.qa.M` candidates may upgrade to the matching
4
+ published `beta.N`, despite SemVer's prerelease ordering. All identity, integrity,
5
+ deployment and owner-policy checks still apply; older betas remain rejected.
6
+
3
7
  For an application's independently deployed frontend, backend or embedded gateway,
4
8
  see [managed applications](managed-applications.md). They use their existing
5
9
  deployment tools and a saved maintenance mandate; this package updater inventories
@@ -12,6 +16,8 @@ automatic channel for core and plugins without a saved policy. Existing explicit
12
16
  stable or manual policies are preserved. The owner may select either per target.
13
17
  The main target and installed plugins version independently.
14
18
 
19
+ Queued upgrades leave channel admission open while native work runs. The supervisor pauses admission only after work is idle and releases that pause if a turn raced the check.
20
+
15
21
  The owner chooses the saved update policy. The host supervisor owns
16
22
  interruption-safe replacement. There is one host service per agent, not a second
17
23
  agent or an additional updater daemon. Its replaceable child runs the normal CLI
@@ -228,7 +234,7 @@ Upgrading does not invent an explicit setting for an omitted field.
228
234
  Verify upgrade from the previous supported artifact, retained identity/state,
229
235
  failed-health rollback, and rejection of incompatible candidates. Main runtime
230
236
  changes also need supervisor restart and requesting-process-exit tests. Run
231
- `pnpm verify`, `npm run release:check`, packed Docker builds and
237
+ `pnpm verify`, `pnpm run release:check`, packed Docker builds and
232
238
  `node docker/upgrade-smoke.mjs` with a local `EZ_WHATSAPP_SOURCE` containing the
233
239
  WhatsApp fixture. The smoke uses synthetic transport only. VM installation,
234
240
  agent-led upgrades, restart and real account acceptance remain separate QA gates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.45",
3
+ "version": "0.1.0-beta.47",
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.",
@@ -52,7 +52,8 @@
52
52
  "scripts/assert-local-registry.mjs",
53
53
  ".dockerignore",
54
54
  "scripts/trusted-beta.mjs",
55
- "scripts/generate-publish-caller.mjs"
55
+ "scripts/generate-publish-caller.mjs",
56
+ "pnpm-workspace.yaml"
56
57
  ],
57
58
  "publishConfig": {
58
59
  "access": "public",
@@ -70,7 +71,7 @@
70
71
  "verify": "pnpm test && pnpm build",
71
72
  "smoke:scheduler": "tsx scripts/smoke-scheduler.ts",
72
73
  "smoke": "./bin/ezenciel-agents-docker run --rm --no-deps relay smoke",
73
- "prepublishOnly": "npm run verify",
74
+ "prepublishOnly": "pnpm run verify",
74
75
  "registry:up": "docker compose -f registry/docker-compose.yml up -d",
75
76
  "registry:down": "docker compose -f registry/docker-compose.yml down",
76
77
  "publish:local": "node scripts/publish-local.mjs",
@@ -0,0 +1,7 @@
1
+ # Hold third-party updates for seven days; reviewed first-party releases are exempt.
2
+ minimumReleaseAge: 10080
3
+ minimumReleaseAgeExclude:
4
+ - "@jc_stack/*"
5
+ - "@aifit/*"
6
+ trustPolicy: no-downgrade
7
+ blockExoticSubdeps: true
@@ -10,6 +10,7 @@ import { RunStore } from '../src/runs.js'
10
10
  import { serveDeliverySocket, createLedgerHandler } from '../src/delivery-socket.js'
11
11
  import { packageVersion } from '../src/version.js'
12
12
  import { initialPreset } from '../src/ai.js'
13
+ import { discoverDefaults } from '../src/client-defaults.js'
13
14
  import { initializeWorkspace } from '../src/workspace.js'
14
15
  import type { Update } from 'grammy/types'
15
16
 
@@ -21,7 +22,9 @@ const purpose=join(root,'purpose.md');await writeFile(purpose,'Verify native sch
21
22
  await initializeWorkspace(workspace,purpose)
22
23
  const control=new ControlStore(controlDir,1000),scheduler=new Scheduler(controlDir),runs=new RunStore(controlDir)
23
24
  await control.requestPairing(101,101);await control.approveOwner(101)
24
- const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset(cli))
25
+ const initial=initialPreset(cli)
26
+ const defaults=initial.model ? initial : (await discoverDefaults(workspace,{nativeCodexFallback:true})).find(p=>p.cli===cli)
27
+ const owner=(await control.status()).owner!,execution=await control.captureChoice({...initial,model:defaults?.model,effort:defaults?.effort})
25
28
  const replies:{at:number;text:string}[]=[]
26
29
  const relay=createRelay({workspace,controlDir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:cli,telegramBotToken:'fixture'})
27
30
  // Engine children reach this process's memory ledger through the socket.
package/src/ai.ts CHANGED
@@ -128,7 +128,25 @@ export const readCuratedModels = async (controlDir?: string): Promise<ModelChoic
128
128
  return curated
129
129
  }
130
130
 
131
- export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex'), opencodeRunner?: (args: string[]) => Promise<string>, opencodeDataHome?: string, opencodeAllowlist?: string[], curationDir?: string): Promise<ModelChoice[]> => {
131
+ // Let the installed native client refresh its own catalog. No conversation or
132
+ // prompt is submitted; CODEX_HOME is the same agent binding used for execution.
133
+ export const readCodexCatalog = async (codexHome: string, run?: (args: string[], env: NodeJS.ProcessEnv) => Promise<string>): Promise<unknown> => {
134
+ const env = { ...executorEnvironment(), CODEX_HOME: codexHome }
135
+ const args = ['debug', 'models']
136
+ try {
137
+ const stdout = run ? await run(args, env) : await (async () => {
138
+ const invocation = executorInvocation('codex', args)
139
+ return (await promisify(execFile)(invocation.command, invocation.args, {
140
+ cwd: codexHome, env, timeout: 8000, maxBuffer: 4 * 1024 * 1024,
141
+ })).stdout
142
+ })()
143
+ const catalog = JSON.parse(stdout)
144
+ if (catalog && Array.isArray(catalog.models) && catalog.models.length) return catalog
145
+ } catch { /* Older/offline native clients retain their last known catalog. */ }
146
+ try { return JSON.parse(await readFile(join(codexHome, 'models_cache.json'), 'utf8')) } catch { return {} }
147
+ }
148
+
149
+ export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex'), opencodeRunner?: (args: string[]) => Promise<string>, opencodeDataHome?: string, opencodeAllowlist?: string[], curationDir?: string, codexRunner?: (args: string[], env: NodeJS.ProcessEnv) => Promise<string>): Promise<ModelChoice[]> => {
132
150
  const models: ModelChoice[] = []
133
151
  const record = (value: unknown): Record<string, unknown> =>
134
152
  value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
@@ -147,7 +165,7 @@ export const readModels = async (home = homedir(), available = installed, codexH
147
165
  }
148
166
  }
149
167
  if (await available('codex')) {
150
- const cache = await json(join(codexHome, 'models_cache.json'))
168
+ const cache = record(await readCodexCatalog(codexHome, codexRunner))
151
169
  for (const entry of Array.isArray(cache.models) ? cache.models : []) {
152
170
  const info = record(entry)
153
171
  if (info.visibility !== 'list' || !safe(info.slug)) continue
@@ -11,6 +11,7 @@ import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
11
11
  import { assertEffort } from './model-policy.js'
12
12
  import { ApprovalStore } from './approval.js'
13
13
  import { Tasks } from './tasks.js'
14
+ import { softwareStatus } from './software-status.js'
14
15
  import { SpeechCreditsDepletedError } from './audio.js'
15
16
 
16
17
  const hash = (value: string) => createHash('sha256').update(value).digest('hex')
@@ -72,7 +73,7 @@ export class ApplicationChannel {
72
73
  private admissions = Promise.resolve()
73
74
  readonly bindings: ApplicationBindings
74
75
  constructor(private options: {
75
- controlDir: string; workspace?: string; initial: AiPreset
76
+ controlDir: string; workspace?: string; isolation?: string; initial: AiPreset
76
77
  wake: () => void
77
78
  cancel: (id: string) => Promise<void>
78
79
  speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
@@ -180,6 +181,7 @@ export class ApplicationChannel {
180
181
  await this.sharedBinding(bindingId, false)
181
182
  return {ai:{...ai, selectedId:session?.preset?.id ?? ai.selectedId,
182
183
  presets:session?.preset ? [...ai.presets.filter(item=>item.id!==session.preset!.id),session.preset] : ai.presets},
184
+ software: await softwareStatus(this.options.controlDir, this.options.isolation),
183
185
  models, activeSessionId:session?.sessionId ?? null, sessions:session ? [{id:session.sessionId,title:sessionTitle(session),cli:session.cli,archived:false}] : []}
184
186
  }
185
187
  async changeScopeControls(bindingId: string, scope: string, input: unknown) {
@@ -8,6 +8,7 @@ import { ApprovalStore } from './approval.js'
8
8
  import { deliveredMessages } from './message-history.js'
9
9
  import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
10
10
  import { telegramOwner } from './control-state.js'
11
+ import { Scheduler } from './scheduler.js'
11
12
 
12
13
  // Stateless pipe transport. The relay owns the runs/outbox ledger in process
13
14
  // memory; engine children, plugin children and the host executor reach it
@@ -27,6 +28,7 @@ export { deliverySocketPath }
27
28
  export type DeliverySocketOp =
28
29
  | { op: 'ping' }
29
30
  | { op: 'status' }
31
+ | { op: 'triggerSchedule'; payload: { scheduleId: string; revision: string; key: string; callerRunId?: string } }
30
32
  | { op: 'enqueue'; payload: Record<string, unknown> }
31
33
  | { op: 'wait'; payload: { id: string; timeoutMs?: number } }
32
34
  | { op: 'get'; payload: { runId: string } }
@@ -208,6 +210,19 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
208
210
  queued: all.filter((run) => run.status === 'queued').length,
209
211
  }
210
212
  }
213
+ case 'triggerSchedule': {
214
+ const {scheduleId,revision,key,callerRunId}=op.payload ?? {}
215
+ if (!nonEmptyString(scheduleId) || !nonEmptyString(revision) || !nonEmptyString(key)) throw new Error('Task ID, revision and request key are required')
216
+ if (callerRunId !== undefined) {
217
+ const caller=await requireOwnerExecution(controlDir,callerRunId)
218
+ if (caller.replyOnly || (caller.scheduled && caller.scheduled.pairedAt !== (await readOnlyOwner(controlDir))?.pairedAt)) throw new Error('Scheduling requires an active owner-authorized run')
219
+ }
220
+ const owner=await readOnlyOwner(controlDir)
221
+ if (!owner) throw new Error('Pair an owner before scheduling')
222
+ const run=await new Scheduler(controlDir).trigger(scheduleId,revision,key,owner,runs)
223
+ hooks.wake()
224
+ return run
225
+ }
211
226
  case 'enqueue': {
212
227
  const envelope = (op.payload ?? {}) as Record<string, unknown>
213
228
  if (envelope.kind === 'owner') {
@@ -351,4 +366,3 @@ export const deliverySocketAlive = (socketPath: string, timeoutMs = 2000): Promi
351
366
  // closed DeliverySocketOp union.
352
367
  export const callDeliverySocket = (socketPath: string, op: DeliverySocketOp, timeoutMs = 130_000): Promise<unknown> =>
353
368
  callSocket(socketPath, op, timeoutMs)
354
-
@@ -13,6 +13,7 @@ import { readModels, validateSelection, validateOpencodeProviders, type ModelCho
13
13
  import type { ChildProcess } from 'node:child_process'
14
14
  import { packageVersion } from './version.js'
15
15
  import { installedPluginVersions } from './software-status.js'
16
+ import { assertScheduledModel } from './model-policy.js'
16
17
  import { processSnapshot } from './process-tree.js'
17
18
 
18
19
  export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
@@ -194,6 +195,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
194
195
  await new Tasks(agent.controlDir).authorize(run, false)
195
196
  } else await authorizeRun(agent.controlDir, path.basename(base))
196
197
  const opts=request.options as ExecutorOptions
198
+ if (run?.scheduled) {
199
+ assertScheduledModel(run.execution?.preset.model)
200
+ if (opts.model !== run.execution!.preset.model) throw new Error('Scheduled run model does not match its saved task model')
201
+ }
197
202
  const cli = opts.cli || installation.cli
198
203
  resolveExecutor(cli)
199
204
  const provider = cli === 'codex' && opts.provider
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { loadConfig, type Config } from './config.js'
26
26
  import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
27
27
  import { ApprovalStore } from './approval.js'
28
28
  import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.js'
29
+ import { assertScheduledModel } from './model-policy.js'
29
30
  import { RunStore, type RunRecord } from './runs.js'
30
31
  import { splitTelegramText } from './reply.js'
31
32
  import { markdownToTelegramHtml, escapeHtml } from './format.js'
@@ -100,7 +101,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
100
101
  }
101
102
 
102
103
  const applicationChannel = new ApplicationChannel({
103
- controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
104
+ controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
104
105
  aiControls: aiMenu,
105
106
  speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
106
107
  geminiApiKey: config.geminiApiKey,
@@ -240,6 +241,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
240
241
  ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
241
242
  : await control.executionSession(started.execution!)
242
243
  const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
244
+ if (run.scheduled) assertScheduledModel(selected.model)
243
245
  const { child, cleanup } = await launch(texts, {
244
246
  workspace: config.workspace,
245
247
  timeoutMs: config.executorTimeoutMs,
@@ -114,6 +114,11 @@ export async function migrateLedger({ deployment } = {}) {
114
114
  return { ok: true, deployment: directory, changed: true, port, overlay };
115
115
  }
116
116
 
117
+ export async function latestCodexVersion(invoke,manager={command:"npm",args:[]}) {
118
+ const version=JSON.parse(await invoke(manager.command,[...manager.args,'view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/']));
119
+ if(typeof version!=='string'||!/^\d+\.\d+\.\d+$/.test(version))throw Error('Invalid latest Codex version');
120
+ return version;
121
+ }
117
122
  async function fingerprintOf(source,invoke) {
118
123
  const listing=JSON.parse(await invoke('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:source}));
119
124
  const hash=createHash('sha256');
@@ -144,8 +149,9 @@ async function buildIdentity(source,label,invoke) {
144
149
  export async function build({home=defaultHome(),source=root,label},invoke=run) {
145
150
  source=await fs.realpath(source);
146
151
  const identity=await buildIdentity(source,label,invoke);
152
+ const codexVersion=await latestCodexVersion(invoke);
147
153
  const fingerprint=await fingerprintOf(source,invoke);
148
- const artifact=createHash('sha256').update(JSON.stringify([fingerprint,identity.label??'',identity.sha??''])).digest('hex');
154
+ const artifact=createHash('sha256').update(JSON.stringify([fingerprint,identity.label??'',identity.sha??'',codexVersion])).digest('hex');
149
155
  const image='ezenciel-agents:install-'+artifact.slice(0,24);
150
156
  const dir=path.join(path.resolve(home),'builds',artifact);await fs.mkdir(dir,{recursive:true,mode:0o700});
151
157
  const lock=path.join(dir,'lock'),receipt=path.join(dir,'status.json'),log=path.join(dir,'build.log');
@@ -155,14 +161,14 @@ export async function build({home=defaultHome(),source=root,label},invoke=run) {
155
161
  await handle.writeFile(JSON.stringify({pid:process.pid,source,image,...identity}));
156
162
  const prior=await read(receipt).catch(absent);
157
163
  if(prior?.state==='completed')try{if(await invoke('docker',['image','inspect','--format','{{.Id}}',image])===prior.imageId)return {...prior,reused:true};}catch{/* Image was removed; rebuild under the same exclusive lock. */}
158
- await atomic(receipt,{state:'building',pid:process.pid,source,image,log,...identity});
159
- console.error(JSON.stringify({state:'building',image,log,...identity}));
160
- const buildArgs=identity.label?['--build-arg',`BUILD_TAG=${identity.label}`,'--build-arg',`BUILD_SHA=${identity.sha}`]:[];
164
+ await atomic(receipt,{state:'building',pid:process.pid,source,image,log,codexVersion,...identity});
165
+ console.error(JSON.stringify({state:'building',image,log,codexVersion,...identity}));
166
+ const buildArgs=['--build-arg',`CODEX_CLI_VERSION=${codexVersion}`,...(identity.label?['--build-arg',`BUILD_TAG=${identity.label}`,'--build-arg',`BUILD_SHA=${identity.sha}`]:[])];
161
167
  const tags=[image,...(identity.label?[`ezenciel-agents:${identity.label}`]:[])];
162
168
  const output=await fs.open(log,'a',0o600);
163
169
  try{await invoke('docker',['build','--progress','plain','--target','runtime',...buildArgs,...tags.flatMap(t=>['-t',t]),source],{log:output.fd,timeout:3600000});}finally{await output.close();}
164
170
  if(await fingerprintOf(source,invoke)!==fingerprint)throw Error('Package changed during build; keep the source stable and retry');
165
- const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log,...identity};await atomic(receipt,result);return result;
171
+ const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log,codexVersion,...identity};await atomic(receipt,result);return result;
166
172
  }catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message,...identity});throw e;}
167
173
  finally{await handle.close();await fs.rm(lock);}
168
174
  }
@@ -3,6 +3,10 @@ export const allowedEffort = (effort?: string, _model?: string, _cli?: string) =
3
3
  export function assertEffort(effort?: string, model?: string, cli?: string) {
4
4
  if (!allowedEffort(effort,model,cli)) throw new Error('Invalid reasoning effort')
5
5
  }
6
+ export function assertScheduledModel(model: unknown): asserts model is string {
7
+ if (typeof model !== 'string' || !/^[a-zA-Z0-9_./:-]{1,160}$/.test(model))
8
+ throw new Error('Scheduled tasks require a saved explicit model; configure the task with --model')
9
+ }
6
10
  export function executionDefaults<T extends { model?: string; effort?: string }>(cli: string, options: T): T {
7
11
  assertEffort(options.effort,options.model,cli)
8
12
  return options
@@ -553,7 +553,13 @@ export async function install(home,config,name,source,revision) {
553
553
  const base=path.join(home,'packages',name),target=path.join(base,revision.slice(7));await privateDir(base);
554
554
  const stage=path.join(base,`.stage-${randomUUID()}`);await privateDir(stage);
555
555
  try {
556
- for(const [relative,file] of p.files) {const dest=path.join(stage,relative);await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});await fs.writeFile(dest,file.data,{mode:file.mode,flag:'wx'});}
556
+ for(const [relative,file] of p.files) {
557
+ const dest=path.join(stage,relative);await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});
558
+ // Package modes are part of the reviewed snapshot; umask/default ACLs
559
+ // must not make Docker COPY inputs unreadable to the runtime user.
560
+ for(let dir=path.dirname(dest);dir!==stage;dir=path.dirname(dir))await fs.chmod(dir,0o755);
561
+ await fs.writeFile(dest,file.data,{mode:file.mode,flag:'wx'});await fs.chmod(dest,file.mode);
562
+ }
557
563
  // A previously interrupted install may leave a snapshot. Never silently replace it.
558
564
  try { await fs.rename(stage,target); } catch(error) {
559
565
  if(!['EEXIST','ENOTEMPTY'].includes(error.code)) throw error;
package/src/runs.ts CHANGED
@@ -141,7 +141,7 @@ export class RunStore {
141
141
  taskId?: string
142
142
  application?: ApplicationOrigin
143
143
  delivery?: { bindingId: string; scope: string }
144
- }): Promise<RunRecord> {
144
+ }, exclusiveSchedule = false): Promise<RunRecord> {
145
145
  const store = runsFor(this.controlDir)
146
146
  if (input.id) {
147
147
  assertId(input.id)
@@ -152,6 +152,9 @@ export class RunStore {
152
152
  return existing
153
153
  }
154
154
  }
155
+ if (exclusiveSchedule && input.scheduled && [...store.values()].some(run =>
156
+ run.scheduled?.id === input.scheduled!.id && ['queued','running'].includes(run.status)))
157
+ throw new Error('Schedule already has a queued or running occurrence')
155
158
  const run: RunRecord = {
156
159
  version: input.taskId ? 2 : 1,
157
160
  taskId: input.taskId,