@jc_stack/ez-agents 0.1.0-beta.44 → 0.1.0-beta.46

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/AGENTS.md CHANGED
@@ -120,8 +120,8 @@ round merely to seek more criticism, tests or hypothetical edge cases.
120
120
  - **Rate limits:** Avoid spamming bubbles to respect Telegram’s 1 message/sec per-chat rate limit.
121
121
 
122
122
  ## 6. Concurrency & Workspace Invariants
123
- - **1 Writer Job per Workspace:** The agent's Markdown folder (`./agent/`) is its mind. Never run concurrent background processes writing to the same workspace simultaneously.
124
- - Main-conversation and owner-scheduled jobs write the same mind workspace one at a time; the delivery socket carries every cross-process enqueue/receipt, never control/ files. Scheduled jobs keep separate native CLI sessions and control-side task state. Restricted contact tasks retain their separate authorization boundary. Delegation decisions and goal persistence belong to the agent/executor; there is no automatic planner or canned chat ACK. Production executor runs have no wall-clock timeout; cancellation is explicit.
123
+ - **Responsive owner channels:** One foreground turn and one owner-scheduled turn may run concurrently; each stream stays serial. Separate native sessions isolate history and engine state, while the agent-owned Markdown workspace is shared. The engine coordinates shared file updates and plugins guard canonical record writes. Do not add a second reply agent, scheduler or execution queue.
124
+ - The delivery socket carries every cross-process enqueue/receipt, never control/ files. Host plugin workspace invocations remain excluded until both native turns finish. Scheduled jobs keep separate native CLI sessions and control-side task state. Restricted contact tasks retain their separate authorization boundary. Delegation decisions and goal persistence belong to the agent/executor; there is no automatic planner or canned chat ACK. Production executor runs have no wall-clock timeout; cancellation is explicit.
125
125
 
126
126
  ## 7. Fail-Closed Authority (Channel Access ≠ Execution)
127
127
  - Incoming messages outside the approved owner binding must **never** spawn the executor. A first DM or group message records a pending request only. Explicitly approved owner groups grant owner access to all human members in that exact chat; bots and anonymous posts are ignored.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.46
4
+
5
+ - Allow private RC/QA candidates to graduate to their matching published beta
6
+ through the existing updater, without permitting a move to an older beta.
7
+
8
+ - Refresh the isolated broker after plugin upgrade or rollback so status reports
9
+ the resulting installed versions rather than its startup inventory. Preserve
10
+ the previous registry snapshot independently during plugin activation.
11
+ Preserve stopped plugins as stopped when Docker retains a created container.
12
+
13
+ - Include the existing Telegram runtime/version summary in authorized private
14
+ channel controls so channel status can show the running relay and host versions.
15
+
16
+ ## 0.1.0-beta.45
17
+
18
+ - Keep owner chat responsive while a scheduled native turn runs. Foreground
19
+ turns and scheduled turns each stay serial, with separate native sessions.
20
+ Host workspace invocations remain excluded until both streams finish.
21
+
22
+ - Expose the existing immediate scheduling command in bounded managed guidance;
23
+ scope the owner-requested preference to foreground turns and prevent scheduled
24
+ work from scheduling itself again. Upgrade and rollback render the selected
25
+ package's guidance while preserving agent-owned text.
26
+ - Support real external agent workspaces and private control directories without
27
+ deployment convenience symlinks, including plugin grants and updater backups.
28
+ - Treat a concurrently released workspace lock as busy without adding retries,
29
+ and allow bounded host initialization beyond the former ten-second window.
30
+ - Preserve failed native Codex turn diagnostics for channel delivery instead of
31
+ losing the failure when a native turn completes without response text.
32
+
3
33
  ## 0.1.0-beta.44
4
34
 
5
35
  - Let isolated native agents read declared installed plugin skills through the
@@ -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
package/docs/local-qa.md CHANGED
@@ -16,7 +16,7 @@ Commit, push and open a PR first — then stage only from that PR's exact commit
16
16
  ```sh
17
17
  node scripts/stage-qa.mjs --source /absolute/feature-checkout \
18
18
  --catalog /absolute/deployment/tools/qa --label beta-12 \
19
- --version 0.1.0-beta.12.qa.1 --flow /absolute/feature-QA.md
19
+ --version 0.1.0-beta.12.rc.1 --flow /absolute/feature-QA.md
20
20
  ```
21
21
 
22
22
  `beta-12` is a local catalog label, distinct from public `0.1.0-beta.12`. The
@@ -64,8 +64,9 @@ environment. Calls have a 30-second timeout and 256 KiB combined output limit;
64
64
  connection frames are limited to 1 MiB. Connection closure aborts pending calls
65
65
  and removes their exact command containers. There is no automatic retry.
66
66
 
67
- Arbitrary calls take the same fail-fast workspace lease as non-scheduled native
68
- jobs. Native work stays pending while a call runs; calls refuse pending/running
67
+ Arbitrary calls take the same fail-fast workspace lease as native jobs. Host
68
+ foreground and scheduled turns share that lease until both finish. Native work
69
+ stays pending while a call runs; calls refuse pending/running
69
70
  native work. Ordinary CLI calls inside an admitted native job do not reacquire
70
71
  the lease. Isolated scheduled work keeps its existing workspace behavior. A stale
71
72
  native `workspace-writer.lock` is recovered at host startup only after its host
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,
@@ -281,6 +286,19 @@ survive command-time Compose regeneration and restarts. Before a routed plugin
281
286
  update, the host adds the inspected candidate revision to that route; both the
282
287
  current and candidate revisions may remain authorized during the transition.
283
288
 
289
+ An isolated broker mounts `host-executor.json` as a read-only file. After the host
290
+ atomically replaces that configuration, recreate the declared broker service
291
+ through the deployment's bound launcher before activating the candidate:
292
+ `ezenciel-agents-docker up -d --no-deps --no-build --force-recreate plugin-broker`.
293
+ This refreshes the file mount without replacing the native relay or its sessions.
294
+ Host-capable executors read the current host file directly. Verify a native plugin
295
+ call and its provider receipt after the configuration change.
296
+
297
+ Before a data-preserving uninstall, record the host-owned routes and any
298
+ operator-created plugin network's name, labels and IPAM configuration. Restore
299
+ that same network configuration before activation if uninstall removed it;
300
+ private volumes alone do not preserve Docker networks.
301
+
284
302
  This is deployment configuration, not a package-descriptor field or an `ez`
285
303
  command: agents and plugins cannot request or alter host networks. The host
286
304
  installer owns the binding and must ensure the selected service is stopped
@@ -1,10 +1,17 @@
1
1
  # Responsive channels
2
2
 
3
- Owner input passes unchanged to the selected engine. The agent decides when to
4
- delegate or schedule long work to remain available. ez queues foreground input
5
- while a foreground run is active; it does not create a separate busy-reply agent.
6
- Owner-scheduled turns use the bound mind workspace under the same single-writer
7
- limit as foreground turns.
3
+ Owner input passes unchanged to the selected engine. In a foreground owner turn,
4
+ when work can continue independently, the global managed guidance tells the agent
5
+ to prefer an immediate scheduled turn so the foreground channel remains available.
6
+ A scheduled turn executes its assigned work directly instead of scheduling itself
7
+ again. The agent still makes that choice; ez does not infer task duration or
8
+ auto-schedule work. ez queues foreground input while a foreground run is active;
9
+ it does not create a separate busy-reply agent.
10
+ Owner-scheduled turns run independently of the foreground channel stream, so a
11
+ long review does not block chat. Each stream runs one turn at a time. They share
12
+ the agent-owned workspace, but use separate native sessions; session isolation
13
+ does not isolate Markdown files. The agent coordinates shared file updates and
14
+ plugins enforce their canonical record-write contracts.
8
15
 
9
16
  Background tasks receive literal task text. Their native sessions contain no
10
17
  generated role instructions or copied identity files; native workspace instructions
@@ -49,10 +49,14 @@ No identity files or role instructions are generated. Existing workspace Markdow
49
49
  provides context, including native `AGENTS.md` discovery and relative links; the
50
50
  engine chooses what to read. Per-run native state stays under `control/`.
51
51
 
52
- One writer runs in the agent workspace: owner schedules and foreground turns
53
- queue behind each other. Foreground inputs queue while another turn runs;
54
- ez does not create another reply agent. The agent can delegate or schedule long
55
- work and return to chat. It decides when to send through the message CLI.
52
+ One foreground turn and one owner-scheduled turn can run at the same time.
53
+ Foreground inputs queue behind foreground work; scheduled turns queue behind
54
+ scheduled work. A long scheduled goal therefore does not block chat. Both streams
55
+ use the existing admission and executor path, with separate native sessions.
56
+ They share the agent-owned Markdown workspace: the agent coordinates shared file
57
+ updates, and plugins enforce canonical record-write guards. The host keeps plugin
58
+ workspace invocations excluded until both native turns finish.
59
+ The agent decides when to send through the message CLI.
56
60
  A recurring schedule has at most one pending or active occurrence. Shared
57
61
  provider resources still need writer coordination. The former four background
58
62
  slots are no longer used for owner schedules.
@@ -150,7 +154,7 @@ Telegram delivery, and a sleep test does not prove native goal persistence.
150
154
 
151
155
  Create a normal recurring schedule with `--every-seconds 900 --when unreviewed-failures --text-file templates/failure-review.md`. The condition advances empty occurrences without launching an executor. It considers only failures belonging to the paired owner. No separate monitor or automatic retry is introduced.
152
156
 
153
- `failures [--all] [--limit N]` returns failedAt, reason, exit code, native session, captured error and runtime versions. Capture keeps at most 4 KiB of redacted stderr; historical failures are not backfilled. `run RUN_ID` reads an owned run. `review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT` records the investigation without rewriting execution history. A stale timestamp is rejected; a later failure needs a new review. Restricted reply, external and isolated-task callers cannot review failures. An attention review is handed off, not repeatedly relaunched; another new failure wakes the next review.
157
+ `failures [--all] [--limit N]` returns failedAt, reason, exit code, native session, captured error and runtime versions. Capture keeps at most 4 KiB of redacted stderr, including a failed native Codex turn's error message when supplied. A failure without error details remains explicitly unknown; historical failures are not backfilled. `run RUN_ID` reads an owned run. `review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT` records the investigation without rewriting execution history. A stale timestamp is rejected; a later failure needs a new review. Restricted reply, external and isolated-task callers cannot review failures. An attention review is handed off, not repeatedly relaunched; another new failure wakes the next review.
154
158
 
155
159
  The prompt controls diagnosis, authorized recovery and quiet notification behavior. Inspect prior effects and receipts before retrying anything. A failed review run itself remains visible as a new failure for the next occurrence.
156
160
 
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
@@ -245,10 +249,15 @@ handbook from workspace `AGENTS.md` (and existing `AGENTS.override.md`).
245
249
  Personal text outside that block is preserved byte-for-byte. Do not reinsert
246
250
  coding, KISS, contribution or repair loops into operating minds.
247
251
 
248
- The package-owned locator is the managed `<!-- ez tools -->` block: the bound
249
- `ez` launcher, `ez --help`, and `ez tools list --details`. Inventory is
250
- generated on read. Plugin registry init and update binding refresh that locator.
252
+ The package-owned guidance is the managed `<!-- ez tools -->` block: the bound
253
+ `ez` launcher, `ez --help`, `ez tools list --details`, and one routing hint for
254
+ foreground owner turns to prefer `ezenciel-agents-schedule create --now` when work
255
+ can continue independently without blocking interaction. The hint tells scheduled
256
+ turns to execute their assigned work directly instead of rescheduling themselves.
257
+ Inventory and full scheduler usage are generated on read. Plugin registry init and update binding refresh that block.
251
258
  Malformed markers and symlinks fail visibly rather than overwriting personal work.
259
+ Upgrade and rollback render the block through the exact package root being activated,
260
+ so an already-running package cannot leave stale guidance behind.
252
261
 
253
262
  Codex, Grok and OpenCode discover workspace instructions natively. Claude
254
263
  receives the native `--append-system-prompt-file` binding to `AGENTS.md`, alongside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.44",
3
+ "version": "0.1.0-beta.46",
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.",
@@ -8,10 +8,10 @@ import {parseArgs} from 'node:util';
8
8
  import {digest, extract, version, newer} from '../src/updates/artifact.mjs';
9
9
 
10
10
  const {values:v}=parseArgs({options:Object.fromEntries(['source','catalog','label','version','flow'].map(k=>[k,{type:'string'}]))});
11
- if(!['source','catalog','label','version','flow'].every(k=>v[k]))throw Error('Required: --source CHECKOUT --catalog DIRECTORY --label beta-12 --version 0.1.0-beta.12.qa.1 --flow QA.md');
11
+ if(!['source','catalog','label','version','flow'].every(k=>v[k]))throw Error('Required: --source CHECKOUT --catalog DIRECTORY --label beta-12 --version 0.1.0-beta.12.rc.1 --flow QA.md');
12
12
  if(!/^beta-[1-9]\d*$/.test(v.label))throw Error('Label must be beta-N');
13
13
  version(v.version);
14
- if(!/^\d+\.\d+\.\d+-beta\.\d+\.qa\.[1-9]\d*$/.test(v.version))throw Error('Use a distinct private version: X.Y.Z-beta.N.qa.BUILD');
14
+ if(!/^\d+\.\d+\.\d+-beta\.\d+\.rc\.[1-9]\d*$/.test(v.version))throw Error('Use a distinct private version: X.Y.Z-beta.N.rc.M');
15
15
  const source=await fs.realpath(v.source),catalog=path.resolve(v.catalog);
16
16
  const run=(cmd,args,cwd=source)=>execFileSync(cmd,args,{cwd,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
17
17
  if(run('git',['status','--porcelain']))throw Error('Commit the reviewed source before staging QA');
@@ -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) {
@@ -1,4 +1,5 @@
1
1
  import { executionDefaults } from './model-policy.js'
2
+ import { redactFailure } from './failure.js'
2
3
  import { spawn, type ChildProcess } from 'node:child_process'
3
4
  import { createInterface } from 'node:readline'
4
5
  import { fileURLToPath } from 'node:url'
@@ -62,7 +63,12 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
62
63
  if(message.method==='thread/goal/cleared')settled(null)
63
64
  if(message.method==='turn/completed'){
64
65
  if(activeTurn===message.params.turn.id)activeTurn=undefined
65
- if(message.params.turn.status!=='completed'){finish(message.params.turn.status==='interrupted'?130:1);return}
66
+ if(message.params.turn.status!=='completed'){
67
+ if(message.params.turn.status==='interrupted')finish(130)
68
+ else fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
69
+ ? message.params.turn.error.message : 'Native turn failed without error details')))
70
+ return
71
+ }
66
72
  // Completion of a turn is not completion of a native goal. The native
67
73
  // app-server remains running and owns any automatic next turn.
68
74
  void request('thread/goal/get',{threadId}).then(result=>settled(result.goal)).catch(fail)
@@ -51,7 +51,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
51
51
  let catalogRefresh: Promise<void> | undefined
52
52
  const locks: string[] = []
53
53
  const sharedWorkspaces = new Map<HostBinding, string>()
54
- const activeWorkspaces = new Set<HostBinding>()
54
+ const activeWorkspaces = new Map<HostBinding, { lanes: Set<boolean>; release?: () => Promise<void> }>()
55
55
  const additionalWorkspaces = new Map<HostBinding, string[]>()
56
56
  const catalog = async (agent: HostBinding) => {
57
57
  const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
@@ -158,13 +158,27 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
158
158
  }
159
159
  const sharedWorkspace=sharedWorkspaces.get(agent)
160
160
  const base=path.join(directory,id)
161
- if (activeWorkspaces.has(agent)) continue
162
- const releaseWorkspace = agent.toolsHome
163
- ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
164
- if (agent.toolsHome && !releaseWorkspace) continue
165
- activeWorkspaces.add(agent)
161
+ const scheduled = Boolean(run?.scheduled)
162
+ let workspace = activeWorkspaces.get(agent)
163
+ if (workspace?.lanes.has(scheduled)) continue
164
+ if (!workspace) {
165
+ const release = agent.toolsHome
166
+ ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
167
+ if (agent.toolsHome && !release) continue
168
+ workspace = { lanes: new Set(), release }
169
+ activeWorkspaces.set(agent, workspace)
170
+ }
171
+ workspace.lanes.add(scheduled)
172
+ // Keep plugin workspace invocations excluded until both streams finish.
173
+ const releaseWorkspace = async () => {
174
+ workspace.lanes.delete(scheduled)
175
+ if (!workspace.lanes.size) {
176
+ activeWorkspaces.delete(agent)
177
+ await workspace.release?.()
178
+ }
179
+ }
166
180
  try { await rename(base+'.request.json',base+'.running.json') }
167
- catch (error) { activeWorkspaces.delete(agent); await releaseWorkspace?.(); throw error }
181
+ catch (error) { await releaseWorkspace(); throw error }
168
182
  const task=(async()=>{
169
183
  let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
170
184
  let cancellation: ReturnType<typeof setInterval> | undefined
@@ -220,7 +234,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
220
234
  await rm(base+'.process.json',{force:true})
221
235
  await rm(base+'.cancel',{force:true})
222
236
  active.delete(base)
223
- try { await releaseWorkspace?.() } finally { activeWorkspaces.delete(agent) }
237
+ await releaseWorkspace()
224
238
  }
225
239
  })()
226
240
  tasks.add(task); void task.finally(()=>tasks.delete(task))
package/src/index.ts CHANGED
@@ -100,7 +100,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
100
100
  }
101
101
 
102
102
  const applicationChannel = new ApplicationChannel({
103
- controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
103
+ controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
104
104
  aiControls: aiMenu,
105
105
  speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
106
106
  geminiApiKey: config.geminiApiKey,
@@ -205,8 +205,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
205
205
  return
206
206
  }
207
207
  if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
208
- // Owner turns and schedules write the same mind. Keep one native writer.
209
- if (activeChild || background.size) return
208
+ // A long scheduled turn must not occupy the owner's chat stream.
209
+ // Each stream remains serial, with separate native sessions.
210
+ if (run.scheduled ? background.size > 0 : activeChild !== null) return
210
211
  let texts = run.texts
211
212
  if (run.external) {
212
213
  // Availability failures leave durable queued work for a later check.
@@ -41,16 +41,24 @@ export async function stewardOwned(file) {
41
41
  const parent = await fs.stat(path.dirname(file));
42
42
  await fs.chown(file, parent.uid, parent.gid);
43
43
  }
44
- // Only a registry locator lives in native instructions. Inventory is generated on read.
45
- export async function bindToolDiscovery(home,workspace) {
46
- home=await fs.realpath(home);workspace=await fs.realpath(workspace);
44
+ // Native instructions keep only the registry locator and one crucial routing
45
+ // hint. Inventory and full scheduler usage are generated on read.
46
+ export async function toolDiscoveryBlock(home,hostConfig) {
47
+ home=await fs.realpath(home);
47
48
  let launcher=path.join(home,'bin','ez');
48
- const config=await json(path.join(home,'config.json')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error;});
49
+ const config=hostConfig?{hostConfig}:await json(path.join(home,'config.json')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error;});
49
50
  if(config?.hostConfig) {
50
51
  const host=await json(config.hostConfig);
51
52
  if(host.isolation==='isolated') launcher='ez';
52
53
  }
53
54
  const start='<!-- ez tools: begin -->',end='<!-- ez tools: end -->';
55
+ const block=start+'\nUse `'+launcher+'`; run it with `--help` or `tools list --details` to discover installed capabilities, and read the matching skill when needed.\nKeep the channel responsive: during a foreground owner turn, prefer `ezenciel-agents-schedule create --now ...` when work can continue independently. A `[schedule ...]` turn executes its assigned work directly and must not schedule it again.\n'+end;
56
+ if(Buffer.byteLength(block)>600)throw Error('Tool discovery guidance exceeds the 600-byte core budget');
57
+ return {block,start,end};
58
+ }
59
+ export async function bindToolDiscovery(home,workspace) {
60
+ home=await fs.realpath(home);workspace=await fs.realpath(workspace);
61
+ const {block,start,end}=await toolDiscoveryBlock(home);
54
62
  for(const name of ['AGENTS.md','AGENTS.override.md']) {
55
63
  const file=path.join(workspace,name);
56
64
  const stat=await fs.lstat(file).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
@@ -59,7 +67,6 @@ export async function bindToolDiscovery(home,workspace) {
59
67
  const prior=stat?await fs.readFile(file,'utf8'):'';
60
68
  const from=prior.indexOf(start),to=prior.indexOf(end);
61
69
  if((from<0)!==(to<0)||(from>=0&&(to<from||prior.indexOf(start,from+start.length)>=0||prior.indexOf(end,to+end.length)>=0)))throw Error('Malformed tool discovery block');
62
- const block=start+'\nInstalled capabilities: `'+launcher+' --help` and `'+launcher+' tools list --details`. Use this bound launcher; read the matching skill when needed.\n'+end;
63
70
  const next=from<0?prior+'\n'+block+'\n':prior.slice(0,from)+block+prior.slice(to+end.length);
64
71
  if(next===prior)continue;
65
72
  const tmp=file+'.'+randomUUID()+'.tmp';
@@ -226,18 +233,11 @@ async function hostFolderRoots(config, record, home) {
226
233
  if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host folder bindings');
227
234
  const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
228
235
  if (candidates.length !== 1) throw Error('Host folder binding does not belong to this registry');
229
- const agent = candidates[0], deployment = path.dirname(hostConfig);
236
+ const agent = candidates[0];
230
237
  if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
231
238
  realpathSync(agent.workspace) !== config.workspace ||
232
239
  childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
233
240
  throw Error('Invalid host folder binding deployment');
234
- // The isolated broker receives only the exact workspace, control directory,
235
- // tools home, and host config mounts. It must not require the host
236
- // deployment's convenience mind/control symlinks (or a deployment mount).
237
- if (process.env.EZ_DOCKER_COMPOSE !== 'standalone' &&
238
- (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
239
- realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
240
- throw Error('Invalid host folder binding deployment');
241
241
  const configured = agent.pluginFolderRoots;
242
242
  if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
243
243
  throw Error('Invalid host folder roots');
@@ -280,7 +280,7 @@ export async function hostNetworkBindings(config, record, home) {
280
280
  if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host network bindings');
281
281
  const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
282
282
  if (candidates.length !== 1) throw Error('Host network binding does not belong to this registry');
283
- const agent = candidates[0], deployment = path.dirname(hostConfig);
283
+ const agent = candidates[0];
284
284
  if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
285
285
  realpathSync(agent.workspace) !== config.workspace ||
286
286
  childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
@@ -292,11 +292,6 @@ export async function hostNetworkBindings(config, record, home) {
292
292
  if (route === undefined) return [];
293
293
  const isolated = process.env.EZ_DOCKER_COMPOSE === 'standalone';
294
294
  if (isolated && host.isolation !== 'isolated') throw Error('Isolated broker requires an isolated host binding');
295
- // Like folder grants, network grants use the exact mounted identities. The
296
- // broker does not receive the host deployment's convenience symlinks.
297
- if (!isolated && (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
298
- realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
299
- throw Error('Invalid host network binding deployment');
300
295
  keys(route, ['revisions', 'bindings']);
301
296
  const trusted = await snapshot(record.source);
302
297
  if (record.revision !== trusted.revision || record.source !== trusted.source ||
@@ -509,6 +504,7 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
509
504
  if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
510
505
  if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
511
506
  workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);if(hostConfig)hostConfig=await fs.realpath(hostConfig);
507
+ await toolDiscoveryBlock(home,hostConfig);
512
508
  if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
513
509
  catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
514
510
  const sources=await json(catalogFile);
@@ -557,7 +553,13 @@ export async function install(home,config,name,source,revision) {
557
553
  const base=path.join(home,'packages',name),target=path.join(base,revision.slice(7));await privateDir(base);
558
554
  const stage=path.join(base,`.stage-${randomUUID()}`);await privateDir(stage);
559
555
  try {
560
- 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
+ }
561
563
  // A previously interrupted install may leave a snapshot. Never silently replace it.
562
564
  try { await fs.rename(stage,target); } catch(error) {
563
565
  if(!['EEXIST','ENOTEMPTY'].includes(error.code)) throw error;
@@ -23,7 +23,7 @@ export async function recoverNativeLease(home) {
23
23
  export async function workspaceLease(home,owner={kind:'plugin'}) {
24
24
  const file=path.join(home,'workspace-writer.lock'),temp=file+'.'+randomUUID()+'.tmp';
25
25
  await fs.writeFile(temp,JSON.stringify({...owner,pid:process.pid}),{flag:'wx',mode:0o600});
26
- try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;const prior=await leaseOwner(file);if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');return undefined;}
26
+ try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');return undefined;}
27
27
  finally {await fs.rm(temp);}
28
28
  return async()=>{await fs.rm(file);};
29
29
  }
package/src/runs.ts CHANGED
@@ -235,7 +235,7 @@ export class RunStore {
235
235
 
236
236
  async nextQueued(background?: boolean): Promise<RunRecord | undefined> {
237
237
  const queued = [...runsFor(this.controlDir).values()].sort((a,b)=>a.createdAt.localeCompare(b.createdAt)).filter((run) => run.status === 'queued' && (background === undefined || Boolean(run.scheduled) === background))
238
- return queued.find(run => !run.taskId) ?? queued[0]
238
+ return queued.find(run => !run.taskId && !run.scheduled) ?? queued.find(run => !run.taskId) ?? queued[0]
239
239
  }
240
240
 
241
241
  async deliveryStatus(): Promise<{ failed: number; unknown: number }> {
@@ -1,14 +1,21 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { atomic, bindToolDiscovery } from '../plugins/manager.mjs';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { atomic } from '../plugins/manager.mjs';
5
5
  import { read } from './control.mjs';
6
6
 
7
+ async function packageToolDiscovery(packageRoot,home,workspace) {
8
+ const entry=path.join(packageRoot,'src/plugins/manager.mjs');
9
+ const manager=await import(pathToFileURL(entry).href);
10
+ if(typeof manager.bindToolDiscovery!=='function')throw Error('Selected package has no tool discovery binding');
11
+ await manager.bindToolDiscovery(home,workspace);
12
+ }
13
+
7
14
  export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new URL('../../',import.meta.url))) {
8
15
  home=await fs.realpath(home);hostConfig=await fs.realpath(hostConfig);
9
- const config=await read(path.join(home,'config.json')),host=await read(hostConfig),deploymentDir=path.dirname(hostConfig);
10
- if(path.basename(hostConfig)!=='host-executor.json'||host.agents.length!==1||host.agents[0].toolsHome!==home||host.agents[0].workspace!==config.workspace)throw Error('Updates require this agent registry and its own single-deployment host config');
11
- if(await fs.realpath(path.join(deploymentDir,'mind'))!==config.workspace||await fs.realpath(path.join(deploymentDir,'control'))!==host.agents[0].controlDir)throw Error('Noncanonical deployment state paths');
16
+ const config=await read(path.join(home,'config.json')),host=await read(hostConfig),deploymentDir=path.dirname(hostConfig),agent=host.agents[0];
17
+ if(path.basename(hostConfig)!=='host-executor.json'||host.agents.length!==1||agent.toolsHome!==home||agent.workspace!==config.workspace)throw Error('Updates require this agent registry and its own single-deployment host config');
18
+ if(await fs.realpath(agent.workspace)!==agent.workspace||await fs.realpath(agent.controlDir)!==agent.controlDir)throw Error('Noncanonical deployment state paths');
12
19
  packageRoot=await fs.realpath(packageRoot);
13
20
  await fs.mkdir(path.join(home,'updates'),{recursive:true,mode:0o700});
14
21
  await atomic(path.join(home,'config.json'),{...config,hostConfig,packageRoot:packageRoot.replace(/\/$/,''),deploymentDir});
@@ -19,8 +26,8 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
19
26
  await fs.writeFile(path.join(bin,'ez'),`#!${process.execPath}\nimport fs from 'node:fs';import {pathToFileURL} from 'node:url';const c=JSON.parse(fs.readFileSync(${configFile}));const m=await import(pathToFileURL(c.packageRoot+'/src/plugins/manager.mjs'));m.main(['--home',${JSON.stringify(home)},...process.argv.slice(2)]).catch(e=>{console.error(e.message);process.exitCode=1});\n`,{mode:0o700});
20
27
  for(const [name,entry] of Object.entries(pkg.bin)) {
21
28
  const dest=path.join(bin,name);await fs.rm(dest,{force:true});
22
- await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir,EZ_CONTROL_DIR:c.deploymentDir+'/control'}});for(const s of ['SIGTERM','SIGINT'])process.on(s,()=>child.kill(s));child.on('error',e=>{console.error(e.message);process.exitCode=1});child.on('close',c=>process.exitCode=c??1);\n`,{mode:0o700});
29
+ await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir,EZ_CONTROL_DIR:${JSON.stringify(agent.controlDir)}}});for(const s of ['SIGTERM','SIGINT'])process.on(s,()=>child.kill(s));child.on('error',e=>{console.error(e.message);process.exitCode=1});child.on('close',c=>process.exitCode=c??1);\n`,{mode:0o700});
23
30
  }
24
- await bindToolDiscovery(home,config.workspace);
31
+ await packageToolDiscovery(packageRoot,home,config.workspace);
25
32
  return {ok:true,home,deploymentDir,packageRoot,policy:'Automatic compatible beta-channel updates by default; saved stable-only or manual policies take precedence. Local candidates require an explicit owner request.'};
26
33
  }
@@ -10,8 +10,9 @@ export const missing = error => {if(error.code!=='ENOENT')throw error;return nul
10
10
  export const targetId = value => {if(value!=='main'&&!/^[a-z][a-z0-9-]{0,39}$/.test(value))throw Error('Invalid update target');return value;};
11
11
  const jobId=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
12
12
  const compatibilityBuild=/^(.+)\.compat\.\d+$/;
13
+ const qaBuild=/^(\d+\.\d+\.\d+-beta\.\d+)\.(?:rc|qa)\.[1-9]\d*$/;
13
14
  const upgradeable=(candidate,installed)=>{
14
- const match=compatibilityBuild.exec(installed);
15
+ const match=compatibilityBuild.exec(installed)||qaBuild.exec(installed);
15
16
  return newer(candidate,installed)||Boolean(match&&match[1]===candidate);
16
17
  };
17
18
  export const updateHome = home => path.join(home,'updates');
@@ -61,6 +61,10 @@ export async function relayServices(config) {
61
61
  const env = await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(() => '');
62
62
  return parseEnv(env).EZ_EXECUTOR_TRANSPORT === 'local' ? ['relay','plugin-broker'] : ['relay'];
63
63
  }
64
+ async function refreshBroker(config, run) {
65
+ if ((await relayServices(config)).includes('plugin-broker'))
66
+ await run('docker', [...relayArgs(config), 'restart', 'plugin-broker']);
67
+ }
64
68
  const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
65
69
  async function healthEvidence(config,run) {
66
70
  const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
@@ -90,7 +94,7 @@ async function backupVolume(run,record,name,directory) {
90
94
  // Interruptible work is journaled before any running installation is touched.
91
95
  // On restart, recovery restores code/config only, never provider journals.
92
96
  export async function perform(home,job,hooks) {
93
- const run=hooks.execute||execute,{config}=await state(home),dir=jobPath(home,job.id),file=path.join(dir,'job.json');
97
+ const run=hooks.execute||execute,{config,agent}=await state(home),dir=jobPath(home,job.id),file=path.join(dir,'job.json');
94
98
  const save=()=>atomic(file,job);
95
99
  if(job.status==='applying'||job.recoveryRequested)return recover(home,job,hooks);
96
100
  if(job.status!=='queued')throw Error('Job is not queued');
@@ -129,7 +133,8 @@ export async function perform(home,job,hooks) {
129
133
  job.rollback={env:envValue(oldEnv,'EZ_RELAY_IMAGE',oldImage),packageRoot:config.packageRoot};await save();
130
134
  await run('docker',[...relayArgs(config),'stop','relay']);await hooks.stopHost();
131
135
  const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
132
- for(const name of ['mind','control'])await backupStateDirectory(path.join(config.deploymentDir,name),path.join(backup,name));
136
+ await backupStateDirectory(config.workspace,path.join(backup,'workspace'));
137
+ await backupStateDirectory(agent.controlDir,path.join(backup,'control'));
133
138
  for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
134
139
  let env=oldEnv.split(job.previousRoot+path.sep).join(root+path.sep);env=envValue(env,'EZ_RELAY_IMAGE',image);
135
140
  await textAtomic(envFile,env);
@@ -144,8 +149,8 @@ export async function perform(home,job,hooks) {
144
149
  await checkFolders(config,candidate,home);
145
150
  const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
146
151
  for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
147
- const running=Boolean((await run('docker',[...pluginArgs(old),'ps','-q'])).trim());
148
- job.rollback={record:old,registry:r,compose:await read(old.compose),running};await save();
152
+ const running=Boolean((await run('docker',[...pluginArgs(old),'ps','--status','running','-q'])).trim());
153
+ job.rollback={record:old,registry:structuredClone(r),compose:await read(old.compose),running};await save();
149
154
  await run('docker',[...pluginArgs(old),'stop']);
150
155
  const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
151
156
  const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
@@ -155,6 +160,7 @@ export async function perform(home,job,hooks) {
155
160
  r.plugins[job.target]=candidate;
156
161
  for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
157
162
  await atomic(path.join(home,'registry.json'),r);
163
+ await refreshBroker(config,run);
158
164
  job.runtimeVerified=running;
159
165
  }
160
166
  job.status='completed';job.endedAt=new Date().toISOString();await save();
@@ -191,6 +197,7 @@ export async function recover(home,job,hooks) {
191
197
  const b=job.rollback;
192
198
  await run('docker',[...pluginArgs(b.record),'stop']);
193
199
  await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
200
+ await refreshBroker(config,run);
194
201
  if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
195
202
  }
196
203
  job.status='rolled-back';
@@ -20,6 +20,16 @@ export function providerEnvironment(host, environment=process.env) {
20
20
  }
21
21
 
22
22
  const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
23
+ export async function waitForHostHeartbeat(controlDir,child,failure,{attempts=300,intervalMs=100,readHeartbeat=read,wait=sleep}={}) {
24
+ for(let n=0;n<attempts;n++) {
25
+ const error=failure();
26
+ if(error||child.exitCode!==null)throw error||Error('Host transport failed to start');
27
+ const beat=await readHeartbeat(path.join(controlDir,'host-executor/heartbeat.json')).catch(missing);
28
+ if(beat?.pid===child.pid&&Date.now()-beat.at<10000)return;
29
+ await wait(intervalMs);
30
+ }
31
+ throw Error('Host transport heartbeat timeout');
32
+ }
23
33
  export async function idle(control) {
24
34
  const host=await fs.readdir(path.join(control,'host-executor')).catch(e=>{if(e.code==='ENOENT')return [];throw e;});
25
35
  if(host.some(f=>f.endsWith('.running.json')||f.endsWith('.request.json')))return false;
@@ -67,13 +77,7 @@ export async function supervise(deployment,signal,{discover=check}={}) {
67
77
  child=spawn(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'src/host-executor.ts'),path.join(deployment,'host-executor.json')],
68
78
  {env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
69
79
  let error;child.once('error',e=>{error=e;});
70
- for(let n=0;n<100;n++) {
71
- if(error||child.exitCode!==null)throw error||Error('Host transport failed to start');
72
- const beat=await read(path.join(agent.controlDir,'host-executor/heartbeat.json')).catch(missing);
73
- if(beat?.pid===child.pid&&Date.now()-beat.at<10000)return;
74
- await sleep(100);
75
- }
76
- throw Error('Host transport heartbeat timeout');
80
+ await waitForHostHeartbeat(agent.controlDir,child,()=>error);
77
81
  };
78
82
  const pause=path.join(agent.controlDir,'upgrade-pause.json');
79
83
  const beat=setInterval(()=>{void atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()}).catch(()=>{});},1000);
@@ -8,6 +8,7 @@ import { ApplicationChannel, applicationScope } from '../src/application-channel
8
8
  import { ControlStore } from '../src/control-state.js'
9
9
  import { createAiMenu } from '../src/menu.js'
10
10
  import { RunStore } from '../src/runs.js'
11
+ import { softwareStatus } from '../src/software-status.js'
11
12
 
12
13
  test('shared controls validate detected host presets against the host catalog', async t => {
13
14
  const root = await mkdtemp(join(tmpdir(), 'ez-host-controls-'))
@@ -67,6 +68,8 @@ test('private scope reset and model controls preserve running sessions and share
67
68
  ...(body===undefined?{}:{body:JSON.stringify(body)}),
68
69
  })
69
70
  const before = await (await call()).json() as any
71
+ assert.deepEqual(before.software,await softwareStatus(root))
72
+ assert.match(before.software[0],/^Relay: running · /)
70
73
  assert.equal(before.activeSessionId,run.execution!.sessionId)
71
74
  assert.equal(JSON.stringify(before).includes('native-private'),false)
72
75
  assert.equal((await channel.scopeControls(second.bindingId,'exercise')).activeSessionId,null)
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict'
3
3
  import { spawn } from 'node:child_process'
4
4
  import { runCodexSession } from '../src/codex-session.js'
5
5
 
6
- for(const mode of ['external','goal','long-goal','plain','tool-goal','blocked','disconnect','approval','late-limit','early-limit','early-clear','missing-goal'])test(`native Codex session: ${mode}`,async()=>{
6
+ for(const mode of ['external','goal','long-goal','plain','tool-goal','blocked','disconnect','approval','late-limit','early-limit','early-clear','missing-goal','failed','failed-redacted','failed-no-error','interrupted'])test(`native Codex session: ${mode}`,async(t)=>{
7
+ const diagnostics:string[]=[]
8
+ t.mock.method(console,'error',(...args:unknown[])=>diagnostics.push(args.join(' ')))
7
9
  const prompt=mode==='long-goal'?'/goal Complete the research.\n'+'Full workflow context.\n'.repeat(400)+'FINAL_COMPLETION_CRITERION':'test'
8
10
  const requests:string[]=[],output:string[]=[]
9
11
  const program=`
@@ -24,6 +26,10 @@ if(q.method==='turn/start'){
24
26
  send({method:'turn/completed',params:{threadId:'unrelated',turn:{id:'unrelated',status:'completed'}}});start('one');
25
27
  if(${JSON.stringify(mode)}==='disconnect')return process.exit(0);
26
28
  if(${JSON.stringify(mode)}==='approval')return send({id:999,method:'item/commandExecution/requestApproval',params:{threadId:'native-test'}});
29
+ if(['failed','failed-redacted','failed-no-error','interrupted'].includes(${JSON.stringify(mode)})){
30
+ const message=${JSON.stringify(mode)}==='failed-redacted'?'Native provider request failed: Bearer synthetic-private-token https://user:password@example.test/response?api_key=synthetic-private-key':'Native provider request failed';
31
+ return event('turn/completed',{turn:{id:'one',status:${JSON.stringify(mode)}==='interrupted'?'interrupted':'failed',error:${JSON.stringify(mode)}==='failed-no-error'?null:{message}}});
32
+ }
27
33
  if(${JSON.stringify(mode)}==='missing-goal')event('thread/goal/updated',{goal:{status:'active'}});
28
34
  end('one');return;
29
35
  }
@@ -57,9 +63,16 @@ if(q.method==='thread/goal/get'){
57
63
  assert.equal(threadConfig.model_provider,'openrouter')
58
64
  assert.deepEqual(threadConfig['model_providers.openrouter'],{name:'OpenRouter',base_url:'https://openrouter.ai/api/v1',env_key:'OPENROUTER_API_KEY',wire_api:'responses',supports_websockets:false})
59
65
  }
60
- assert.equal(result,['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
66
+ assert.equal(result,mode==='interrupted'?130:['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
61
67
  assert.equal(requests.filter(x=>x==='turn/start').length,1,'transport must not send goal continuation prompts')
62
68
  assert.equal(requests.filter(x=>x==='thread/goal/set').length,0)
63
69
  if(mode==='goal')assert.equal(requests.filter(x=>x==='thread/goal/get').length,2,'must wait for the second turn to complete')
70
+ if(mode==='failed' || mode==='failed-redacted')assert.ok(diagnostics.some(line=>line.includes('Native provider request failed')),'terminal native error must reach existing stderr diagnostics')
71
+ if(mode==='failed-redacted'){
72
+ assert.ok(diagnostics.some(line=>line.includes('https://example.test/response')))
73
+ assert.ok(diagnostics.every(line=>!line.includes('synthetic-private') && !line.includes('user:password')))
74
+ }
75
+ if(mode==='failed-no-error')assert.ok(diagnostics.some(line=>line.includes('Native turn failed without error details')))
76
+ if(mode==='interrupted')assert.equal(diagnostics.length,0,'owner interruption is not a provider failure')
64
77
  assert.equal(JSON.parse(output[0]).thread_id,'native-test')
65
78
  })
@@ -167,7 +167,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
167
167
  let server:Promise<void>|undefined
168
168
  try {
169
169
  const binary=path.join(root,'cli')
170
- await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID==='r_hold')setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,repair:process.env.EZ_REPAIR_ENABLED,args:process.argv.slice(2)}));\n`,{mode:0o700})
170
+ await writeFile(binary,`#!${process.execPath}\nif(['r_hold','tg_43'].includes(process.env.EZ_RUN_ID))setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,repair:process.env.EZ_REPAIR_ENABLED,args:process.argv.slice(2)}));\n`,{mode:0o700})
171
171
  await writeFile(path.join(root,'claude'),await readFile(binary),{mode:0o700})
172
172
  await writeFile(path.join(root,'codex'),await readFile(binary),{mode:0o700})
173
173
  await writeFile(path.join(root,'opencode'),'#!/bin/sh\nexit 0\n',{mode:0o700})
@@ -284,18 +284,32 @@ test('one installed CLI executes two agent bindings with separate minds and sani
284
284
  assert.match(output, /"stream":"exit","code":0/)
285
285
  return output.trim().split('\n').map(line=>JSON.parse(line))
286
286
  }
287
- // Another agent remains independent; this agent's queued schedule and chat
288
- // wait behind its active owner schedule in the same mind.
287
+ // Chat completes while this agent's scheduled turn stays active. A second
288
+ // scheduled turn still waits, and the shared lifecycle lease stays held.
289
289
  await completed(otherDirectory,'r_other_shared')
290
+ const chatEvents=await completed(directory,'tg_42')
290
291
  assert.ok(await readFile(path.join(directory,'r_schedule_queued.request.json')))
291
- assert.ok(await readFile(path.join(directory,'tg_42.request.json')))
292
+ assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
293
+ await ownerRun(agents[0].controlDir,'tg_43')
294
+ await writeFile(path.join(directory,'tg_43.request.json'),JSON.stringify({texts:['Hold chat'],options:{cli:'grok'}}))
295
+ for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_43.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
296
+ await readFile(path.join(directory,'tg_43.running.json'))
292
297
  assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
293
298
  await readFile(path.join(directory,'r_hold.running.json'))
294
299
  await writeFile(path.join(directory,'r_hold.cancel'),'')
295
300
  let heldOutput=''
296
301
  for(let n=0;n<200;n++){heldOutput=await readFile(path.join(directory,'r_hold.events'),'utf8');if(heldOutput.includes('"stream":"exit"'))break;await new Promise(r=>setTimeout(r,20))}
297
302
  assert.match(heldOutput, /"stream":"exit","code":1/)
298
- const [scheduleEvents,chatEvents]=await Promise.all([completed(directory,'r_schedule_queued'),completed(directory,'tg_42')])
303
+ const scheduleEvents=await completed(directory,'r_schedule_queued')
304
+ // When the scheduled lane finishes first, chat still owns the lease.
305
+ assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
306
+ await writeFile(path.join(directory,'tg_43.cancel'),'')
307
+ for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_43.running.json'))}catch{break}await new Promise(r=>setTimeout(r,20))}
308
+ // The running marker is removed before the lifecycle lease is released.
309
+ let unlock
310
+ for(let n=0;n<100;n++){unlock=await workspaceLease(agents[0].toolsHome);if(unlock)break;await new Promise(r=>setTimeout(r,20))}
311
+ assert.ok(unlock)
312
+ await unlock()
299
313
  const scheduled=JSON.parse(scheduleEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
300
314
  const chat=JSON.parse(chatEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
301
315
  assert.equal(scheduled.cwd,await realpath(agents[0].workspace))
@@ -20,7 +20,7 @@ test('local QA staging preserves source and rejects label replacement and dirty
20
20
  git(['init']);git(['add','package.json','feature.txt','run.sh']);
21
21
  git(['-c','user.name=QA','-c','user.email=qa@example.invalid','-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false','commit','-m','fixture']);
22
22
  const script=fileURLToPath(new URL('../scripts/stage-qa.mjs',import.meta.url));
23
- const args=[script,'--source',source,'--catalog',catalog,'--label','beta-12','--version','0.1.0-beta.12.qa.1','--flow',flow];
23
+ const args=[script,'--source',source,'--catalog',catalog,'--label','beta-12','--version','0.1.0-beta.12.rc.1','--flow',flow];
24
24
  const stage=()=>{const prior=process.umask(0o027);try{return execFileSync(process.execPath,args,{stdio:'pipe'}).toString();}finally{process.umask(prior);}};
25
25
  try {
26
26
  const result=JSON.parse(stage()),data=await fs.readFile(path.join(result.directory,result.file));
@@ -28,7 +28,7 @@ test('local QA staging preserves source and rejects label replacement and dirty
28
28
  assert.equal(git(['status','--porcelain']),'');
29
29
  await extract(data,path.join(root,'unpacked'));
30
30
  const built=JSON.parse(await fs.readFile(path.join(root,'unpacked/package.json'),'utf8'));
31
- assert.equal(built.version,'0.1.0-beta.12.qa.1');assert.equal(built.ezQa.commit,result.commit);
31
+ assert.equal(built.version,'0.1.0-beta.12.rc.1');assert.equal(built.ezQa.commit,result.commit);
32
32
  assert.equal(await fs.readFile(path.join(root,'unpacked/feature.txt'),'utf8'),'feature source');
33
33
  assert.equal((await fs.stat(path.join(root,'unpacked'))).mode&0o777,0o755);
34
34
  assert.equal((await fs.stat(path.join(root,'unpacked/feature.txt'))).mode&0o777,0o644);
@@ -66,6 +66,17 @@ test('shared workspace lease excludes concurrent writers and refuses queued nati
66
66
  await fs.mkdir(path.join(dir,'host-executor'));await fs.writeFile(path.join(dir,'host.json'),JSON.stringify({agents:[{toolsHome:dir,workspace:dir,controlDir:dir}]}));await fs.writeFile(path.join(dir,'config.json'),JSON.stringify({hostConfig:path.join(dir,'host.json'),workspace:dir}));await fs.writeFile(path.join(dir,'host-executor','r.request.json'),'{}');await assert.rejects(invokeLease(dir),/pending/);await fs.rm(path.join(dir,'host-executor','r.request.json'));const unlock=await invokeLease(dir);await unlock();
67
67
  }finally{await fs.rm(dir,{recursive:true,force:true});}
68
68
  });
69
+ test('workspace lease contention tolerates a concurrent owner release',async()=>{
70
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-lease-release-'));
71
+ try {
72
+ for(let n=0;n<100;n++) {
73
+ const release=await workspaceLease(dir);assert.ok(release);
74
+ const [,contender]=await Promise.all([release(),workspaceLease(dir)]);
75
+ if(contender)await contender();
76
+ const next=await workspaceLease(dir);assert.ok(next);await next();
77
+ }
78
+ }finally{await fs.rm(dir,{recursive:true,force:true});}
79
+ });
69
80
  test('command output bound and timeout remove exact containers without leaking daemon secrets',async()=>{
70
81
  const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-bound-'));
71
82
  try {
@@ -46,6 +46,21 @@ async function fixture(t) {
46
46
  const call=(...args)=>exec(process.execPath,[bin,'--home',home,...args],{env});
47
47
  return {root,source,home,deploymentDir,workspace,control,hostConfig,fake,log,manifest,deployment,env,call};
48
48
  }
49
+ test('install preserves reviewed package modes under a restrictive umask',async t=>{
50
+ const f=await fixture(t);
51
+ await fs.mkdir(path.join(f.source,'nested'));await fs.writeFile(path.join(f.source,'nested','note.md'),'Synthetic');
52
+ const pkg=JSON.parse(await fs.readFile(path.join(f.source,'package.json'),'utf8'));pkg.files.push('nested');
53
+ await fs.writeFile(path.join(f.source,'package.json'),JSON.stringify(pkg));await fs.chmod(path.join(f.source,'client.mjs'),0o755);
54
+ const p=await snapshot(f.source);await init(f.home,f.workspace);
55
+ const argv=['--home',f.home,'plugins','install','sample','--source',f.source,'--revision',p.revision];
56
+ const script=`process.umask(0o077);const m=await import(${JSON.stringify(new URL('../src/plugins/manager.mjs',import.meta.url).href)});await m.main(${JSON.stringify(argv)});`;
57
+ await exec(process.execPath,['--input-type=module','-e',script],{env:f.env});
58
+ const record=JSON.parse(await fs.readFile(path.join(f.home,'registry.json'),'utf8')).plugins.sample;
59
+ for(const [relative,file]of p.files)assert.equal((await fs.stat(path.join(record.source,relative))).mode&0o777,file.mode,relative);
60
+ assert.equal((await fs.stat(path.join(record.source,'nested'))).mode&0o777,0o755);
61
+ assert.equal((await fs.stat(record.source)).mode&0o777,0o700);
62
+ assert.equal((await snapshot(record.source)).revision,p.revision);
63
+ });
49
64
  test('persistent connection reuses one container and releases registry lock between frames',async t=>{
50
65
  const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
51
66
  await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nif(process.argv[2]==='container')process.exit(0);require('readline').createInterface({input:process.stdin}).on('line',line=>{const f=JSON.parse(line);console.log(JSON.stringify(f.coreResponse?{reply:f.coreResponse}:{coreRequest:{id:f.id,method:'tools.list',params:{}}}));});`,{mode:0o700});
@@ -68,13 +83,15 @@ test('catalog paths resolve relative to the catalog and pin each new agent indep
68
83
  assert.notEqual(first.sample.revision,next.catalog.sample.revision);
69
84
  assert.deepEqual(JSON.parse((await f.call('plugins','available')).stdout),first);
70
85
  });
71
- test('native discovery binding preserves notes and never seeds a tool inventory',async t=>{
86
+ test('native discovery binding preserves notes and adds bounded nonblocking routing guidance',async t=>{
72
87
  const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),notes=path.join(f.workspace,'TOOLS.md');
73
88
  await fs.writeFile(instructions,'Owner mandate\n');
74
89
  await init(f.home,f.workspace);
75
90
  await assert.rejects(fs.access(notes),{code:'ENOENT'});
76
91
  const first=await fs.readFile(instructions,'utf8');assert(first.startsWith('Owner mandate\n'));assert(first.includes(f.home+'/bin/ez'));
77
- assert.match(first,/tools list --details/);assert.match(first,/ --help/);assert.doesNotMatch(first,/ezenciel-agents-message|KISS/);
92
+ assert.match(first,/tools list --details/);assert.match(first,/`--help`/);assert.match(first,/during a foreground owner turn, prefer `ezenciel-agents-schedule create --now \.\.\.`/);assert.match(first,/A `\[schedule \.\.\.\]` turn executes its assigned work directly and must not schedule it again/);assert.doesNotMatch(first,/ezenciel-agents-message|KISS/);
93
+ const managed=first.slice(first.indexOf('<!-- ez tools: begin -->'),first.indexOf('<!-- ez tools: end -->')+'<!-- ez tools: end -->'.length);
94
+ assert.ok(Buffer.byteLength(managed)<=600);
78
95
  await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(instructions,'utf8'),first);
79
96
  await fs.writeFile(notes,'Legacy policy');
80
97
  await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(notes,'utf8'),'Legacy policy');
@@ -91,7 +108,20 @@ test('isolated discovery uses the relay-bound ez client without exposing toolsHo
91
108
  const f=await fixture(t);await fs.mkdir(f.home);const workspace=await fs.realpath(f.workspace),controlDir=await fs.realpath(f.control),toolsHome=await fs.realpath(f.home),host={cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir,binDir:path.join(f.root,'bin'),toolsHome}]};
92
109
  await fs.mkdir(path.join(f.root,'bin'));await fs.writeFile(f.hostConfig,JSON.stringify(host));
93
110
  await init(f.home,f.workspace,undefined,f.hostConfig);
94
- const instructions=await fs.readFile(path.join(f.workspace,'AGENTS.md'),'utf8');assert.match(instructions,/`ez tools list --details`/);assert.doesNotMatch(instructions,new RegExp(f.home.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')));
111
+ const instructions=await fs.readFile(path.join(f.workspace,'AGENTS.md'),'utf8');assert.match(instructions,/Use `ez`/);assert.match(instructions,/`tools list --details`/);assert.doesNotMatch(instructions,new RegExp(f.home.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')));
112
+ });
113
+ test('discovery rejects an over-budget launcher before rewriting instructions',async t=>{
114
+ const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),longHome=path.join(f.root,'h'.repeat(200),'n'.repeat(200));
115
+ await fs.mkdir(longHome,{recursive:true});await fs.writeFile(instructions,'Owner mandate\n');
116
+ await assert.rejects(bindToolDiscovery(longHome,f.workspace),/600-byte core budget/);
117
+ assert.equal(await fs.readFile(instructions,'utf8'),'Owner mandate\n');
118
+ });
119
+ test('standalone init rejects over-budget guidance before creating activation state',async t=>{
120
+ const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),longHome=path.join(f.root,'i'.repeat(200),'j'.repeat(200)),catalog=path.join(f.root,'empty-catalog.json');
121
+ await fs.writeFile(catalog,'{}');await fs.writeFile(instructions,'Owner mandate\n');
122
+ await assert.rejects(initManager(longHome,f.workspace,catalog,undefined,true),/600-byte core budget/);
123
+ assert.deepEqual(await fs.readdir(longHome),[]);
124
+ assert.equal(await fs.readFile(instructions,'utf8'),'Owner mandate\n');
95
125
  });
96
126
  test('installed snippets follow install, upgrade and uninstall without files or Docker reads',async t=>{
97
127
  const f=await fixture(t);await init(f.home,f.workspace);
@@ -290,6 +320,19 @@ test('isolated broker host binding validates exact mounts without deployment sym
290
320
  if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
291
321
  }
292
322
  });
323
+ test('host-capable binding validates an external real workspace without deployment symlinks',async t=>{
324
+ const f=await fixture(t),p=await snapshot(f.source),workspace=path.join(f.root,'external-workspace');
325
+ await fs.rm(f.workspace,{recursive:true});await fs.mkdir(workspace);await fs.mkdir(f.home);
326
+ const binding={service:'sample',network:'sample_default'};
327
+ await fs.writeFile(f.hostConfig,JSON.stringify({cli:'synthetic',agents:[{name:'sample',workspace,controlDir:f.control,binDir:path.join(f.root,'bin'),toolsHome:f.home,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[binding]}}}]}));
328
+ const realWorkspace=await fs.realpath(workspace),config={workspace:realWorkspace,hostConfig:await fs.realpath(f.hostConfig),folders:{sample:[{service:'sample',source:realWorkspace,target:'/data/files'}]}};
329
+ const record={source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment};
330
+ const c=await compose(config,record,{},f.home);
331
+ assert.equal(c.services.sample.volumes.find(volume=>volume.target===realWorkspace).source,realWorkspace);
332
+ assert.equal(c.services.sample.volumes.find(volume=>volume.target==='/data/files').source,realWorkspace);
333
+ const network=Object.values(c.networks).find(candidate=>candidate.name==='sample_default');
334
+ assert.deepEqual(network,{external:true,name:'sample_default'});
335
+ });
293
336
  test('isolated broker allows unbound plugins when another plugin has host binding',async t=>{
294
337
  const f=await fixture(t),p=await snapshot(f.source),isolated=path.join(f.root,'isolated-other'),workspace=path.join(isolated,'workspace'),control=path.join(isolated,'control'),hostConfig=path.join(isolated,'host-executor.json'),previous=process.env.EZ_DOCKER_COMPOSE;
295
338
  await fs.mkdir(f.home,{recursive:true});await fs.mkdir(workspace,{recursive:true});await fs.mkdir(control,{recursive:true});
@@ -11,7 +11,7 @@ import { RunStore } from '../src/runs.js'
11
11
 
12
12
  const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<250;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Host probe timed out')}
13
13
  const stdout=(events:string) => events.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(event=>event.stream==='stdout').map(event=>event.text).join('')
14
- test('host transport serializes owner schedules and chat in the bound mind',async()=>{
14
+ test('host transport keeps chat responsive beside a schedule in the bound mind',async()=>{
15
15
  const root=await mkdtemp(join(tmpdir(),'ez-scheduler-host-')),workspace=join(root,'agent'),controlDir=join(root,'control')
16
16
  await mkdir(workspace);await mkdir(controlDir)
17
17
  await writeFile(join(workspace,'AGENTS.md'),'bound-owner-instructions')
@@ -32,9 +32,7 @@ test('host transport serializes owner schedules and chat in the bound mind',asyn
32
32
  await submit(id)
33
33
  await until(async()=>Boolean(await exists(id+'.process.json')))
34
34
  await submit('tg_1')
35
- await new Promise(resolve=>setTimeout(resolve,300))
36
- assert.ok(await exists('tg_1.request.json'))
37
- assert.equal(await exists('tg_1.events'),'')
35
+ await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
38
36
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
39
37
  await until(async()=>(await exists(id+'.events')).includes('"stream":"stdout"'))
40
38
  const scheduled=JSON.parse(stdout(await exists(id+'.events')))
@@ -47,7 +45,8 @@ test('host transport serializes owner schedules and chat in the bound mind',asyn
47
45
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
48
46
  await ownerRun(controlDir,'tg_2')
49
47
  await submit('tg_2')
50
- assert.ok(await exists('tg_2.request.json'))
48
+ await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
49
+ assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
51
50
  await writeFile(join(dir,id+'.cancel'),'')
52
51
  await until(async()=>(await exists(id+'.events')).includes('"stream":"exit"'))
53
52
  await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
@@ -44,7 +44,50 @@ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is
44
44
  }finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
45
45
  })
46
46
 
47
- test('owner chat queues behind a scheduled CLI in the same workspace; targeted cancellation',async()=>{
47
+ test('Slack application replies beside a schedule and keeps both streams serial',async()=>{
48
+ const dir=await mkdtemp(join(tmpdir(),'ez-slack-scheduled-')),runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
49
+ const children=new Map<string,ReturnType<typeof spawn>>()
50
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async(_texts,options)=>{
51
+ const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform!=='win32'})
52
+ children.set(options.runId,child);await once(child,'spawn')
53
+ if(!options.runId.startsWith('r_schedule_'))await runs.enqueueMessage(options.runId,'Slack reply')
54
+ return {child,cleanup:async()=>{},stdout:''}
55
+ })
56
+ try{
57
+ await control.requestPairing(101,101);await control.approveOwner(101)
58
+ const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok'))
59
+ const due=Date.now()+10000
60
+ for(const id of ['first','second'])await scheduler.save({id,name:id,text:'Long work',trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
61
+ await scheduler.tick(owner,runs,due);await relay.drainSources()
62
+ const scheduled=(await runs.list()).filter(run=>run.scheduled)
63
+ assert.deepEqual(scheduled.map(run=>run.status),['running','queued'])
64
+ const binding=(await relay.applicationChannel.bindings.register('slack','s'.repeat(48),owner))!
65
+ const scope='slack:T_FIXTURE:C_FIXTURE'
66
+ const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'event-1',scope,text:'Top twenty'})
67
+ await until(async()=>children.has(first.id));await relay.drainOutbox()
68
+ assert.equal((await relay.applicationChannel.snapshot(binding.bindingId,first.id)).messages[0].text,'Slack reply')
69
+ assert.equal((await runs.get(scheduled[0].id))?.status,'running')
70
+ const second=await relay.applicationChannel.submit(binding.bindingId,{requestId:'event-2',scope,text:'Next question'})
71
+ await relay.drainSources()
72
+ assert.equal((await runs.get(second.id))?.status,'queued')
73
+ assert.equal(children.size,2)
74
+ // Revoking the channel stops only its foreground work; background authority
75
+ // and the original one-occurrence-per-schedule limit remain intact.
76
+ await relay.applicationChannel.bindings.register('slack',null,owner)
77
+ await relay.drainSources()
78
+ await until(async()=> (await runs.get(first.id))?.status==='cancelled')
79
+ await relay.drainSources()
80
+ assert.equal((await runs.get(second.id))?.status,'cancelled')
81
+ assert.equal((await runs.get(scheduled[0].id))?.status,'running')
82
+ assert.equal((await runs.get(scheduled[1].id))?.status,'queued')
83
+ }finally{
84
+ await relay.stop()
85
+ await until(async()=>!(await runs.list()).some(run=>run.status==='running'))
86
+ await rm(dir,{recursive:true,force:true})
87
+ }
88
+ })
89
+
90
+ test('owner chat replies while a scheduled CLI keeps running; targeted cancellation',async()=>{
48
91
  const dir=await mkdtemp(join(tmpdir(),'ez-scheduler-relay-')), children:ReturnType<typeof spawn>[]=[]
49
92
  const runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
50
93
  const replies:string[]=[], workspaces:string[]=[]
@@ -67,19 +110,18 @@ test('owner chat queues behind a scheduled CLI in the same workspace; targeted c
67
110
  const [background]=await runs.list()
68
111
  assert.equal(background.status,'running')
69
112
  const update:Update={update_id:123,message:{message_id:123,date:0,text:'What is 17 × 19?',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}}
70
- await relay.bot.handleUpdate(update);await relay.drainInbox(true);await relay.drainOutbox()
71
- assert.ok(!replies.includes('323'))
113
+ await relay.bot.handleUpdate(update);await relay.drainInbox(true)
114
+ await until(async()=>children.length===2)
115
+ await relay.drainOutbox()
116
+ assert.ok(replies.includes('323'))
72
117
  assert.equal((await runs.get(background.id))?.status,'running')
73
118
  assert.equal(children[0].exitCode,null)
74
- assert.deepEqual(workspaces,[dir])
119
+ assert.deepEqual(workspaces,[dir,dir])
75
120
  // Pausing future dispatch doesn't kill active work; cancelling this run does.
76
121
  await scheduler.enable('slow',false);await relay.drainSources()
77
122
  assert.equal(children[0].exitCode,null)
78
123
  await scheduler.cancel(background.id);await relay.drainSources()
79
124
  await until(async()=> (await runs.get(background.id))?.status==='cancelled')
80
- await until(async()=>children.length===2)
81
- await relay.drainOutbox()
82
- assert.ok(replies.includes('323'))
83
125
  assert.deepEqual(workspaces,[dir,dir])
84
126
  assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
85
127
  }finally{
@@ -13,7 +13,7 @@ import { perform, environment, packageManager, backupStateDirectory, execute } f
13
13
  import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
14
14
  import { bindUpdates } from '../src/updates/binding.mjs';
15
15
  import { status as runtimeStatus } from '../src/updates/status.mjs';
16
- import { providerEnvironment, queueAutomatic } from '../src/updates/supervisor.mjs';
16
+ import { providerEnvironment, queueAutomatic, waitForHostHeartbeat } from '../src/updates/supervisor.mjs';
17
17
  const exec=promisify(execFile);
18
18
 
19
19
  test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
@@ -42,6 +42,14 @@ test('supervisor forwards only provider keys declared by the agent installation'
42
42
  for(const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'])
43
43
  assert.throws(()=>providerEnvironment({agents:[{codexProviders:[{envKey}]}]},{}),/Reserved Codex provider environment key/);
44
44
  });
45
+ test('host startup gate tolerates initialization beyond the former ten-second window',async()=>{
46
+ let reads=0;
47
+ await waitForHostHeartbeat('/control',{pid:42,exitCode:null},()=>undefined,{
48
+ attempts:120,intervalMs:0,wait:async()=>{},
49
+ readHeartbeat:async()=>++reads===111?{pid:42,at:Date.now()}:null,
50
+ });
51
+ assert.equal(reads,111);
52
+ });
45
53
  const contract=kind=>({protocol:1,kind,stateSchema:1,mainProtocol:1});
46
54
  function tar(entries) {
47
55
  const chunks=[];
@@ -56,7 +64,8 @@ async function fixture(t,kind='main') {
56
64
  const deployment=path.join(root,'agent'),home=path.join(deployment,'tools'),old=path.join(root,'old'),source=path.join(root,'candidate');
57
65
  for(const d of [home,old,source,path.join(home,'bin'),path.join(home,'updates'),path.join(deployment,'mind'),path.join(deployment,'control')])await fs.mkdir(d,{recursive:true});
58
66
  const pkg={name:'@ez-test/example',version:'0.1.0',packageManager:'pnpm@10.30.3',type:'module',files:['bin','src','docker','compose.yaml','compose.whatsapp.yaml','.dockerignore','Dockerfile'],bin:{example:'bin/example.mjs'},ezRelease:contract(kind)};
59
- const files={'package.json':JSON.stringify(pkg),'compose.yaml':'services: {}\n','compose.whatsapp.yaml':'services: {}\n','.dockerignore':'','Dockerfile':'FROM scratch AS runtime','docker/pnpm-lock.yaml':'lockfileVersion: 9.0\n','src/host-executor.ts':'','bin/example.mjs':'#!/usr/bin/env node\nconsole.log("example")','bin/ezenciel-agents.mjs':'console.log("0.1.1")'};
67
+ const guidance=text=>`export async function bindToolDiscovery(_home,workspace){const fs=await import('node:fs/promises');await fs.writeFile(workspace+'/AGENTS.md',${JSON.stringify(text+'\n')});}\n`;
68
+ const files={'package.json':JSON.stringify(pkg),'compose.yaml':'services: {}\n','compose.whatsapp.yaml':'services: {}\n','.dockerignore':'','Dockerfile':'FROM scratch AS runtime','docker/pnpm-lock.yaml':'lockfileVersion: 9.0\n','src/host-executor.ts':'','src/plugins/manager.mjs':guidance('old guidance'),'bin/example.mjs':'#!/usr/bin/env node\nconsole.log("example")','bin/ezenciel-agents.mjs':'console.log("0.1.1")'};
60
69
  let record,target='main';
61
70
  if(kind==='plugin') {
62
71
  target='sample';files['ez-plugin.json']=JSON.stringify({schemaVersion:1,id:'sample',version:pkg.version,commands:{sample:{executable:'bin/example.mjs',args:[]}},skills:[]});
@@ -76,11 +85,19 @@ async function fixture(t,kind='main') {
76
85
  await atomic(record.compose,await compose(config,record,{},home));
77
86
  }
78
87
  await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:record?{sample:record}:{},commands:record?{sample:'sample'}:{}});
79
- await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);
88
+ await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);await fs.writeFile(path.join(source,'src/plugins/manager.mjs'),guidance('candidate guidance'));
80
89
  if(kind==='plugin'){const m=await read(path.join(source,'ez-plugin.json'));m.version=pkg.version;await atomic(path.join(source,'ez-plugin.json'),m);}
81
90
  const pack=async()=>{const entries=[];async function walk(dir,prefix=''){for(const e of await fs.readdir(dir,{withFileTypes:true})){const rel=prefix+e.name;if(e.isDirectory())await walk(path.join(dir,e.name),rel+'/');else entries.push(['package/'+rel,await fs.readFile(path.join(dir,e.name))]);}}await walk(source);const file=path.join(root,'candidate.tgz');await fs.writeFile(file,tar(entries));return file;};
82
91
  return {root,home,old,source,agent,config,record,target,pack};
83
92
  }
93
+ async function externalState(f) {
94
+ const workspace=path.join(f.root,'workspace'),controlDir=path.join(f.root,'control');
95
+ await fs.rename(f.agent.workspace,workspace);await fs.rename(f.agent.controlDir,controlDir);
96
+ f.agent.workspace=workspace;f.agent.controlDir=controlDir;f.config.workspace=workspace;
97
+ await atomic(path.join(f.config.deploymentDir,'host-executor.json'),{cli:'grok',agents:[f.agent]});
98
+ await atomic(path.join(f.home,'config.json'),f.config);
99
+ return f;
100
+ }
84
101
  test('root preparation leaves private plugin updates readable by the installer', {
85
102
  skip:process.platform!=='linux'||process.geteuid?.()!==0,
86
103
  },async t=>{
@@ -259,6 +276,16 @@ test('compatibility bridge hands off any matching canonical beta release',async
259
276
  await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.34'});
260
277
  await assert.rejects(eligibility(f.home,'main',f.source,false),/newer/);
261
278
  });
279
+ test('private RC and QA builds graduate only to their matching beta release',async t=>{
280
+ const f=await fixture(t),prior=await read(path.join(f.old,'package.json')),candidate=await read(path.join(f.source,'package.json'));
281
+ for(const suffix of ['rc.7','qa.4']) {
282
+ await atomic(path.join(f.old,'package.json'),{...prior,version:'0.1.0-beta.46.'+suffix});
283
+ await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.46'});
284
+ assert.equal((await eligibility(f.home,'main',f.source,true)).pkg.version,'0.1.0-beta.46');
285
+ await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.45'});
286
+ await assert.rejects(eligibility(f.home,'main',f.source,false),/newer/);
287
+ }
288
+ });
262
289
  test('reviewed Library beta14 command migration is exact and fail-closed',()=>{
263
290
  const manifest=(version,document)=>({schemaVersion:1,id:'library',version,description:'library',commands:{library:{executable:'bin/ez-library.mjs',args:[]},'library-query':{executable:'bin/ez-library.mjs',args:[],channelQuery:true},...(document?{'library-document':{executable:'bin/ez-library.mjs',args:[],channelQuery:true}}:{})},skills:['skills/library/SKILL.md']});
264
291
  const deployment=(query,document)=>({schemaVersion:3,services:{library:{buildTarget:'runtime',volumes:{data:'/state'},healthcheck:['node','/app/bin/ez-library.mjs','--version'],memoryMiB:4096,cpus:2}},commands:{'library-query':{service:'library',argv:['node','/app/bin/ez-library.mjs',query]},...(document?{'library-document':{service:'library',argv:['node','/app/bin/ez-library.mjs','document']}}:{})},sharedServices:{}});
@@ -267,12 +294,14 @@ test('reviewed Library beta14 command migration is exact and fail-closed',()=>{
267
294
  const unsafe=structuredClone(next);unsafe.deployment.services.library.cpus=4;assert.equal(reviewedPluginDeploymentMigration('library',old,unsafe),undefined);
268
295
  });
269
296
  test('main transaction stages before stopping, pins rollback image, preserves state and rebinds root',async t=>{
270
- const f=await fixture(t),job=await queued(f),r=runtime(f);
297
+ const f=await externalState(await fixture(t)),job=await queued(f),r=runtime(f);
271
298
  await fs.writeFile(path.join(f.agent.workspace,'memory.md'),'retain me');
272
299
  // Mutable preparation files cannot alter the verified archive executed later.
273
300
  await fs.writeFile(path.join(jobPath(f.home,job.id),'package/bin/example.mjs'),'tampered');
274
301
  const result=await perform(f.home,job,r);assert.equal(result.status,'completed');
275
302
  assert.equal(await fs.readFile(path.join(f.agent.workspace,'memory.md'),'utf8'),'retain me');
303
+ assert.equal(await fs.readFile(path.join(jobPath(f.home,job.id),'backup/workspace/memory.md'),'utf8'),'retain me');
304
+ await assert.rejects(fs.access(path.join(f.config.deploymentDir,'mind')));
276
305
  const active=(await read(path.join(f.home,'config.json'))).packageRoot;assert(active.endsWith('/runtime'));
277
306
  assert.notEqual(await fs.readFile(path.join(active,'bin/example.mjs'),'utf8'),'tampered');
278
307
  assert(r.calls.findIndex(c=>c.includes('build'))<r.calls.findIndex(c=>c[0]==='stopHost'));
@@ -281,6 +310,16 @@ test('main transaction stages before stopping, pins rollback image, preserves st
281
310
  const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
282
311
  assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
283
312
  });
313
+ test('main activation and rollback bind guidance from the selected package root',async t=>{
314
+ const success=await fixture(t);await bindUpdates(success.home,path.join(success.config.deploymentDir,'host-executor.json'),success.old);
315
+ assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
316
+ assert.equal((await perform(success.home,await queued(success),runtime(success))).status,'completed');
317
+ assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'candidate guidance\n');
318
+
319
+ const failed=await fixture(t);await bindUpdates(failed.home,path.join(failed.config.deploymentDir,'host-executor.json'),failed.old);
320
+ assert.equal((await perform(failed.home,await queued(failed),runtime(failed,{fail:(_command,args)=>args.includes('up')}))).status,'rolled-back');
321
+ assert.equal(await fs.readFile(path.join(failed.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
322
+ });
284
323
  test('the same archive with different deployment CLI pins gets distinct image bindings',async t=>{
285
324
  const tags=[],hashes=[];
286
325
  for(const pin of ['0.153.4','0.156.1']) {
@@ -343,6 +382,33 @@ test('plugin updates register additive command routes on an unchanged deployment
343
382
  const dispatch=await prepareCommand(f.home,'query',[]);assert.deepEqual(dispatch.argv.slice(-4),['node','sample','/app/bin/example.mjs','query']);
344
383
  assert(r.calls.some(call=>call.includes('up')));
345
384
  });
385
+ test('isolated plugin replacement refreshes broker version inventory after registry activation',async t=>{
386
+ const f=await fixture(t,'plugin');
387
+ await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
388
+ const job=await queued(f),r=runtime(f),execute=r.execute;
389
+ let observed;
390
+ r.execute=async(command,args,options)=>{
391
+ if(args.includes('restart')&&args.at(-1)==='plugin-broker') observed=(await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version;
392
+ return execute(command,args,options);
393
+ };
394
+ assert.equal((await perform(f.home,job,r)).status,'completed');
395
+ assert.equal(observed,'0.1.1');
396
+ assert.equal(r.calls.filter(call=>call.includes('restart')).length,1);
397
+ const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
398
+ assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
399
+ assert.equal(observed,'0.1.0');
400
+ assert.equal(r.calls.filter(call=>call.includes('restart')).length,2);
401
+ });
402
+ test('plugin update preserves a created but never running service as stopped',async t=>{
403
+ const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f),execute=r.execute;
404
+ r.execute=async(command,args,options)=>{
405
+ if(args.includes('ps')) {r.calls.push([command,...args]);return args.includes('--status')?'':'created-container';}
406
+ return execute(command,args,options);
407
+ };
408
+ assert.equal((await perform(f.home,job,r)).status,'completed');
409
+ assert.equal(job.rollback.running,false);
410
+ assert.equal(r.calls.some(call=>call.includes('up')),false);
411
+ });
346
412
  test('plugin command additions ignore object key order but reject collisions and changed routes',async t=>{
347
413
  const f=await fixture(t,'plugin'),deployment=await read(path.join(f.source,'ez-deployment.json'));
348
414
  deployment.services.sample={healthcheck:deployment.services.sample.healthcheck,volumes:deployment.services.sample.volumes,buildTarget:deployment.services.sample.buildTarget};
@@ -366,7 +432,7 @@ test('interrupted activation recovers previous code; rollback failure is explici
366
432
  const retried=await read(path.join(jobPath(f.home,interrupted.id),'job.json'));assert.equal((await perform(f.home,retried,r)).status,'rolled-back');
367
433
  });
368
434
  test('bound dispatch follows active package root and retains private scope',async t=>{
369
- const f=await fixture(t);
435
+ const f=await externalState(await fixture(t));
370
436
  const prior=await fs.readFile(path.join(f.agent.workspace,'TOOLS.md'),'utf8');
371
437
  const bound=await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'));
372
438
  assert.match(bound.policy,/beta-channel/);