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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +9 -5
  4. package/bin/ez +16 -7
  5. package/docker/broker-update-smoke.mjs +16 -2
  6. package/docker/upgrade-smoke.mjs +1 -1
  7. package/docs/architecture/ai-selection.md +9 -1
  8. package/docs/docker-runtime.md +46 -2
  9. package/docs/plugin-guide.md +3 -3
  10. package/docs/plugins.md +13 -10
  11. package/docs/scheduling.md +84 -6
  12. package/docs/setup.md +2 -2
  13. package/docs/standalone-cli.md +4 -4
  14. package/docs/upgrades.md +13 -23
  15. package/package.json +1 -1
  16. package/src/agent-guidance.ts +2 -2
  17. package/src/ai-cli.ts +28 -15
  18. package/src/ai.ts +90 -14
  19. package/src/application-channel.ts +18 -14
  20. package/src/auth-profile.ts +53 -0
  21. package/src/codex-session.ts +14 -3
  22. package/src/control-state.ts +28 -21
  23. package/src/delivery-socket.ts +2 -0
  24. package/src/executor.ts +38 -26
  25. package/src/host-executor-client.ts +19 -7
  26. package/src/host-executor.ts +35 -10
  27. package/src/index.ts +320 -122
  28. package/src/menu.ts +8 -7
  29. package/src/plugin-broker.mjs +4 -11
  30. package/src/plugins/manager.mjs +70 -48
  31. package/src/runs.ts +74 -5
  32. package/src/schedule-cli.ts +39 -7
  33. package/src/scheduled-tasks.ts +42 -8
  34. package/src/scheduler.ts +9 -4
  35. package/src/session-close.ts +17 -0
  36. package/src/setup-fallback.ts +159 -0
  37. package/src/task-executor.ts +3 -2
  38. package/src/tools-line.d.mts +1 -0
  39. package/src/tools-line.mjs +3 -0
  40. package/src/updates/binding.mjs +4 -10
  41. package/src/updates/control.mjs +3 -2
  42. package/src/updates/runtime.mjs +79 -41
  43. package/src/updates/supervisor.mjs +27 -6
  44. package/src/workspace.ts +12 -1
  45. package/test/ai.test.ts +48 -2
  46. package/test/application-channel.test.ts +24 -0
  47. package/test/application-controls.test.ts +4 -1
  48. package/test/auth-profiles.test.ts +187 -0
  49. package/test/intake-relay.test.ts +63 -2
  50. package/test/model-policy.test.ts +1 -1
  51. package/test/plugin-broker.test.mjs +51 -1
  52. package/test/plugin-manager.test.mjs +76 -38
  53. package/test/run-handoff.test.ts +132 -0
  54. package/test/scheduled-tasks.test.ts +24 -2
  55. package/test/session-close.test.ts +17 -0
  56. package/test/setup-fallback.test.ts +423 -0
  57. package/test/updates.test.mjs +77 -15
  58. package/test/workspace.test.ts +6 -0
package/src/scheduler.ts CHANGED
@@ -8,7 +8,7 @@ import { type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './contro
8
8
  import { validApplicationOrigin } from './application-origin.js'
9
9
  import { ApplicationBindings } from './application-channel.js'
10
10
  import { assertId, ownsRun } from './identity.js'
11
- import { type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
11
+ import { type AiPreset, type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
12
12
  import { type Trigger, validateTrigger, nextOccurrence } from './schedule-time.js'
13
13
  import { RunStore, type RunRecord } from './runs.js'
14
14
  import { Scripts, assertScriptArgs, assertScriptId, type ScriptRunRef } from './scripts.js'
@@ -139,8 +139,13 @@ export class Scheduler {
139
139
  if (!input.execution) throw new Error('Schedule needs name, text and an AI selection')
140
140
  const execution: ExecutionChoice = {...input.execution, preset: persistedPreset(input.execution.preset)}
141
141
  if (!input.name || !input.text?.trim() || !isExecutionChoice(execution)) throw new Error('Schedule needs name, text and an AI selection')
142
- assertScheduledModel(execution.preset.model)
143
- assertEffort(execution.preset.effort, execution.preset.model, execution.preset.cli)
142
+ const setups = [execution.preset, ...execution.fallbacks ?? []]
143
+ for (const preset of setups) {
144
+ assertScheduledModel(preset.model)
145
+ assertEffort(preset.effort, preset.model, preset.cli)
146
+ }
147
+ const key = (p: AiPreset) => JSON.stringify([p.cli, p.authProfile, p.provider, p.model, p.effort])
148
+ if (new Set(setups.map(key)).size !== setups.length) throw new Error('Task setups must be distinct')
144
149
  s = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
145
150
  }
146
151
  const now = Date.now()
@@ -272,7 +277,7 @@ export class Scheduler {
272
277
  preflight=checked.receipt
273
278
  if(!checked.admitted) {await atomic(cursor,{next:future,preflight});continue}
274
279
  const current=await this.get(s.id);if(!current.enabled || current.revision!==s.revision)continue
275
- } catch {const prior=await readFile(cursor,'utf8').then(v=>JSON.parse(v)).catch(e=>{if(e.code==='ENOENT')return {};throw e});await atomic(cursor,{next:future,preflight:{...prior.preflight,state:'unavailable',observedAt:new Date().toISOString()}});continue}
280
+ } catch (error) {console.error('Schedule preflight unavailable',s.id,error instanceof Error ? error.message : 'Unknown error');const prior=await readFile(cursor,'utf8').then(v=>JSON.parse(v)).catch(e=>{if(e.code==='ENOENT')return {};throw e});await atomic(cursor,{next:future,preflight:{...prior.preflight,state:'unavailable',observedAt:new Date().toISOString()}});continue}
276
281
  }
277
282
  const dueAt = new Date(next).toISOString()
278
283
  await runs.create({id:scheduledRunId(s,next),
@@ -0,0 +1,17 @@
1
+ import type { SessionState, Owner } from './control-state.js'
2
+ import { ownerId, ownerEpoch } from './control-state.js'
3
+ import type { RunStore, RunRecord } from './runs.js'
4
+
5
+ // A chat conversation has no native end. Before one is replaced, its engine
6
+ // gets one silent turn in that same native session to keep durable facts in
7
+ // the workspace. Ez only transports this literal; the engine decides whether
8
+ // anything is written. No reply mechanism is offered, so nothing is delivered.
9
+ // Owner conversations only: a close turn carries owner authority, so a private
10
+ // application scope (which runs under its binding's narrower authority) gets none.
11
+ export const SESSION_CLOSE_PROMPT = '[conversation closing] This conversation is ending. Save anything a future conversation will need where AGENTS.md says to keep memory; otherwise do nothing.'
12
+
13
+ export async function queueSessionClose(runs: RunStore, owner: Owner | null, session: SessionState | null | undefined): Promise<RunRecord | undefined> {
14
+ if (!owner || !session?.hasStarted || !session.preset || session.applicationScope) return undefined
15
+ return runs.create({ ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner), texts: [SESSION_CLOSE_PROMPT],
16
+ execution: { sessionId: session.sessionId, preset: session.preset }, sessionClose: true })
17
+ }
@@ -0,0 +1,159 @@
1
+ import { presetLabel, presetProvider, type AiPreset, type ExecutionChoice } from './ai.js'
2
+
3
+ // Ordered task setups for one scheduled occurrence. Core advances only on typed
4
+ // evidence: a missing CLI or a provider rejection before any model output or
5
+ // tool item, or a usage limit the run ended on after work began (continued in a
6
+ // fresh session that is told so). Anything else stops the chain.
7
+ export type StartupCategory = 'cli-unavailable' | 'login-unavailable' | 'quota' | 'provider-rejected' | 'access-denied'
8
+ export type StartupRejection = { category: StartupCategory; resetAt?: string }
9
+ export type AttemptCategory = StartupCategory | 'quota-after-work-began' | 'after-work-began' | 'uncertain' | 'same-quota-scope' | 'same-login-scope'
10
+ export type SetupAttempt = {
11
+ preset: AiPreset
12
+ quotaScope: string
13
+ outcome: 'running' | 'completed' | 'failed' | 'cancelled' | 'skipped'
14
+ category?: AttemptCategory
15
+ resetAt?: string
16
+ exitCode?: number | null
17
+ sessionId?: string
18
+ startedAt?: string
19
+ endedAt?: string
20
+ }
21
+
22
+ const categories: StartupCategory[] = ['cli-unavailable', 'login-unavailable', 'quota', 'provider-rejected', 'access-denied']
23
+
24
+ // A failed attempt's category and reset time, and whether the occurrence
25
+ // continues on the next setup. Once work began only a quota limit the run ended
26
+ // on continues; access denial, other failures and unknown outcomes stop.
27
+ export const attemptFailure = ({ workBegan, rejection }: { workBegan: boolean; rejection?: StartupRejection }): { category: AttemptCategory; resetAt?: string; continues: boolean } => {
28
+ const resetAt = rejection?.resetAt ? { resetAt: rejection.resetAt } : {}
29
+ if (!workBegan) return rejection ? { category: rejection.category, ...resetAt, continues: rejection.category !== 'access-denied' } : { category: 'uncertain', continues: false }
30
+ return rejection?.category === 'quota' ? { category: 'quota-after-work-began', ...resetAt, continues: true } : { category: 'after-work-began', continues: false }
31
+ }
32
+
33
+ // Transport fact for a setup that continues an occurrence after an earlier setup
34
+ // stopped at its usage limit once work had begun. Facts only, no guidance.
35
+ export const continuationLine = (runId: string, attempts: SetupAttempt[]): string | undefined => {
36
+ const limited = attempts.filter(a => a.category === 'quota-after-work-began')
37
+ return limited.length ? `[run ${runId} continues: ${limited.map(a => `${presetLabel(a.preset)} began work in native session ${a.sessionId} and stopped at its usage limit${a.resetAt ? ` (resets ${a.resetAt})` : ''}`).join('; ')}. That work may be partly applied.]` : undefined
38
+ }
39
+
40
+ export const setupCandidates = (execution: ExecutionChoice): AiPreset[] => [execution.preset, ...(execution.fallbacks ?? [])]
41
+
42
+ // The owner provisions native profiles for separate provider accounts. Models
43
+ // in one login share its quota scope; another named login may be tried once.
44
+ // OpenCode/Pi still share their upstream provider scope.
45
+ export const quotaScope = (preset: AiPreset): string => {
46
+ if (['opencode', 'pi'].includes(preset.cli)) {
47
+ const provider = preset.cli === 'pi' ? preset.model?.split('/')[0] : presetProvider(preset)
48
+ return provider ? `provider:${provider}` : preset.cli
49
+ }
50
+ const cli = preset.cli === 'codex-gui' ? 'codex' : preset.cli
51
+ return preset.authProfile ? `${cli}@${preset.authProfile}` : cli
52
+ }
53
+
54
+ // The next untried setup, skipping a login that already rejected authentication
55
+ // or reached its quota in this occurrence. Each candidate is tried at most once.
56
+ export const nextSetup = (candidates: AiPreset[], attempts: SetupAttempt[]): { skipped: SetupAttempt[]; preset?: AiPreset } => {
57
+ const exhausted = new Map(attempts.filter(a => ['quota', 'quota-after-work-began', 'login-unavailable'].includes(a.category!))
58
+ .map(a => [a.quotaScope, a.category === 'login-unavailable' ? 'same-login-scope' as const : 'same-quota-scope' as const]))
59
+ const skipped: SetupAttempt[] = []
60
+ for (const preset of candidates.slice(attempts.length)) {
61
+ const scope = quotaScope(preset)
62
+ if (!exhausted.has(scope)) return { skipped, preset }
63
+ const at = new Date().toISOString()
64
+ skipped.push({ preset, quotaScope: scope, outcome: 'skipped', category: exhausted.get(scope), startedAt: at, endedAt: at })
65
+ }
66
+ return { skipped }
67
+ }
68
+
69
+ export class SetupsUnavailableError extends Error {}
70
+
71
+ export const unavailableSummary = (attempts: SetupAttempt[]): string =>
72
+ `Every task setup was unavailable: ${attempts.map(a => `${presetLabel(a.preset)} (${a.category}${a.resetAt ? `, resets ${a.resetAt}` : ''})`).join('; ')}`
73
+
74
+ const epochSeconds = (value: unknown): string | undefined =>
75
+ typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? new Date(value * 1000).toISOString() : undefined
76
+
77
+ const claudeErrors: Record<string, StartupCategory> = {
78
+ rate_limit: 'quota', billing_error: 'quota',
79
+ authentication_failed: 'login-unavailable', oauth_org_not_allowed: 'access-denied', account_on_hold: 'access-denied',
80
+ verification_required: 'access-denied', cloud_credential_error: 'access-denied',
81
+ overloaded: 'provider-rejected', server_error: 'provider-rejected', model_not_found: 'provider-rejected', invalid_request: 'provider-rejected',
82
+ }
83
+
84
+ const httpRejection = (status: unknown): StartupCategory | undefined =>
85
+ typeof status !== 'number' ? undefined : status === 401 ? 'login-unavailable' : status === 429 ? 'quota' :
86
+ status === 403 ? 'access-denied' : [400, 404].includes(status) || status >= 500 ? 'provider-rejected' : undefined
87
+
88
+ // Codex app-server `codexErrorInfo` of a failed turn.
89
+ export const codexRejection = (info: unknown): StartupCategory | undefined => {
90
+ if (info === 'usageLimitExceeded' || info === 'rateLimitExceeded') return 'quota'
91
+ if (info === 'unauthorized') return 'login-unavailable'
92
+ if (['serverOverloaded', 'internalServerError', 'badRequest', 'flexUnavailable'].includes(info as string)) return 'provider-rejected'
93
+ if (!info || typeof info !== 'object') return undefined
94
+ const status = (Object.values(info)[0] as { httpStatusCode?: unknown } | undefined)?.httpStatusCode
95
+ return httpRejection(status)
96
+ }
97
+
98
+ // The latest reset among exhausted Codex rate-limit windows.
99
+ export const codexResetAt = (limits: any): string | undefined => {
100
+ const resets = [limits?.primary, limits?.secondary].filter(w => w && w.usedPercent >= 100 && typeof w.resetsAt === 'number').map(w => w.resetsAt as number)
101
+ return resets.length ? epochSeconds(Math.max(...resets)) : undefined
102
+ }
103
+
104
+ export const startupLine = (state: { workBegan: boolean; rejection?: StartupRejection }): string =>
105
+ JSON.stringify({ type: 'ez.startup', workBegan: state.workBegan, ...(state.rejection ? { rejection: state.rejection } : {}) })
106
+
107
+ // Reads structured stdout only: Ez transport summaries (`ez.startup`) and
108
+ // Claude stream-json envelopes. Model text is never inspected. Any event that
109
+ // is not known pre-work metadata counts as work, so ambiguity never advances.
110
+ // The rejection is the one the run ended on: later work supersedes it, except
111
+ // an access denial, which always stops the chain.
112
+ export const startupObserver = () => {
113
+ let buffer = '', workBegan = false, rejection: StartupRejection | undefined
114
+ const reject = (value: StartupRejection) => { if (rejection?.category !== 'access-denied') rejection = value }
115
+ const work = () => { workBegan = true; if (rejection?.category !== 'access-denied') rejection = undefined }
116
+ const line = (text: string) => {
117
+ if (!text.trim()) return
118
+ let event: any
119
+ try { event = JSON.parse(text) } catch { work(); return }
120
+ // A summary is the sender's current state; one without a rejection means
121
+ // work superseded an earlier one.
122
+ if (event?.type === 'ez.startup') {
123
+ if (event.workBegan === true) workBegan = true
124
+ const r = event.rejection, reset = typeof r?.resetAt === 'string' ? Date.parse(r.resetAt) : NaN
125
+ // Re-render the reset time: it reaches a continuation prompt, and
126
+ // Date.parse also accepts arbitrary words around a date.
127
+ if (r && categories.includes(r.category) && (r.resetAt === undefined || Number.isFinite(reset)))
128
+ reject({ category: r.category, ...(r.resetAt === undefined ? {} : { resetAt: new Date(reset).toISOString() }) })
129
+ else if (rejection?.category !== 'access-denied') rejection = undefined
130
+ return
131
+ }
132
+ if (event?.type === 'thread.started' || event?.type === 'result' ||
133
+ (event?.type === 'system' && event.subtype === 'init')) return
134
+ if (event?.type === 'rate_limit_event') {
135
+ const info = event.rate_limit_info
136
+ if (info?.status === 'rejected') reject({ category: 'quota', ...(epochSeconds(info.resetsAt) ? { resetAt: epochSeconds(info.resetsAt) } : {}) })
137
+ return
138
+ }
139
+ // Native API retry/error metadata is not model output. Do not inspect error
140
+ // prose; only typed HTTP status can establish a rejected startup.
141
+ if (event?.type === 'system' && ['api_error', 'api_retry'].includes(event.subtype)) {
142
+ const category = httpRejection(event.subtype === 'api_retry' ? event.error_status : event.error?.status)
143
+ if (category) { reject({ category }); return }
144
+ }
145
+ const category = event?.type === 'assistant' && typeof event.error === 'string' ? claudeErrors[event.error] : undefined
146
+ if (category) reject({ category, ...(category === 'quota' && rejection?.resetAt ? { resetAt: rejection.resetAt } : {}) })
147
+ else work()
148
+ }
149
+ return {
150
+ write: (chunk: string) => {
151
+ buffer += chunk
152
+ const lines = buffer.split('\n')
153
+ buffer = lines.pop() ?? ''
154
+ for (const text of lines) line(text)
155
+ },
156
+ result: () => { line(buffer); buffer = ''; return { workBegan, rejection } },
157
+ summary: () => startupLine({ workBegan, rejection }),
158
+ }
159
+ }
@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
8
8
  import { readRun } from './delivery-socket.js'
9
9
  import {ApplicationBindings} from './application-channel.js'
10
10
  import { Tasks } from './tasks.js'
11
+ import { provisionedHome } from './auth-profile.js'
11
12
  import { executorEnvironment, executorInvocation, terminateJob, type ExecutorOptions } from './executor.js'
12
13
 
13
14
  // This adapter is deliberately version-pinned: a new native tool default needs
@@ -58,8 +59,8 @@ export async function startTaskExecutor(options: ExecutorOptions) {
58
59
  const catalogInvocation = executorInvocation('codex', ['debug', 'models', '--bundled'])
59
60
  const catalog = await promisify(execFile)(catalogInvocation.command, catalogInvocation.args, { env: environment, maxBuffer: 4 * 1024 * 1024 })
60
61
  await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
61
- const auth = process.env.EZ_ISOLATION === 'isolated'
62
- ? join(options.controlDir, 'cli', 'codex', 'auth.json')
62
+ const auth = process.env.EZ_ISOLATION === 'isolated' || options.authProfile !== undefined
63
+ ? join(await provisionedHome(options.controlDir, 'codex', options.authProfile), 'auth.json')
63
64
  : join(homedir(), '.codex', 'auth.json')
64
65
  await symlink(auth, join(home, 'auth.json'))
65
66
  const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
@@ -0,0 +1 @@
1
+ export declare const TOOLS_LINE: string
@@ -0,0 +1,3 @@
1
+ // The only package-written discovery text: seeded once into a new workspace
2
+ // and left in place of a retired `ez tools` footer. Inventory is generated on read.
3
+ export const TOOLS_LINE = 'Tools: run `ez tools list --details`; each plugin\'s `--help` gives usage.'
@@ -1,16 +1,9 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { fileURLToPath, pathToFileURL } from 'node:url';
4
- import { atomic } from '../plugins/manager.mjs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { atomic, bindToolDiscovery } 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
-
14
7
  export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new URL('../../',import.meta.url))) {
15
8
  home=await fs.realpath(home);hostConfig=await fs.realpath(hostConfig);
16
9
  const config=await read(path.join(home,'config.json')),host=await read(hostConfig),deploymentDir=path.dirname(hostConfig),agent=host.agents[0];
@@ -28,6 +21,7 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
28
21
  const dest=path.join(bin,name);await fs.rm(dest,{force:true});
29
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:${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});
30
23
  }
31
- await packageToolDiscovery(packageRoot,home,config.workspace);
24
+ // Idempotent: retires a former `ez tools` footer, never writes guidance.
25
+ await bindToolDiscovery(home,config.workspace);
32
26
  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.'};
33
27
  }
@@ -191,7 +191,8 @@ export async function submit(home,id,automatic) {
191
191
  if(next.old.root!==job.previousRoot||next.old.pkg.version!==job.previousVersion)throw Error('Installation changed since preparation');
192
192
  if(digest(await fs.readFile(path.join(dir,'candidate.tgz')))!==job.sha256)throw Error('Candidate changed');
193
193
  job.status='queued';job.automatic=automatic;await atomic(path.join(dir,'job.json'),job);return job;
194
- });
194
+ // Queueing writes only the job; activation drains plugin calls before switching.
195
+ },{allowInvocations:true,waitMs:5000});
195
196
  }
196
197
  export async function retryRecovery(home,id) {
197
198
  const {directory}=await state(home),dir=jobPath(home,id);
@@ -201,7 +202,7 @@ export async function retryRecovery(home,id) {
201
202
  if((await jobs(home)).some(j=>['queued','applying'].includes(j.status)))throw Error('An upgrade is already pending');
202
203
  await requireSupervisor(directory);
203
204
  job.status='queued';job.recoveryRequested=true;await atomic(path.join(dir,'job.json'),job);return {id,status:job.status,recoveryRequested:true};
204
- });
205
+ },{allowInvocations:true,waitMs:5000});
205
206
  }
206
207
  export async function command(home,args) {
207
208
  const [action,...rest]=args;
@@ -7,7 +7,7 @@ import { pathToFileURL } from 'node:url';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { parseEnv } from 'node:util';
9
9
  import { tmpdir } from 'node:os';
10
- import { atomic, compose, snapshot, checkFolders } from '../plugins/manager.mjs';
10
+ import { atomic, compose, snapshot, checkFolders, locked, publishBrokerPlugins } from '../plugins/manager.mjs';
11
11
  import { read, state, eligibility, jobPath, cleanupStaleBackups } from './control.mjs';
12
12
  import { bindUpdates } from './binding.mjs';
13
13
  import { extract, digest } from './artifact.mjs';
@@ -82,24 +82,44 @@ export async function refreshBroker(config, run=execute, image) {
82
82
  const current=(await run('docker',[...args,'ps','-q','plugin-broker'],{stdoutOnly:true,timeout:15000})).trim();
83
83
  if(!/^[a-f0-9]{12,64}$/i.test(current)||(await run('docker',['inspect','--format','{{.Image}}',current],{stdoutOnly:true,timeout:15000})).trim()!==image)
84
84
  throw Error('Recreated broker image does not match the intended installed image');
85
- const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
86
- const home=path.dirname(hostConfig)===config.deploymentDir
87
- ? (await read(hostConfig)).agents?.find(agent=>agent.workspace===config.workspace)?.toolsHome : undefined;
88
- if(typeof home!=='string'||!path.isAbsolute(home))throw Error('Missing broker registry binding');
89
- const files=[hostConfig,path.join(home,'registry.json'),path.join(home,'config.json')];
90
- const hashes=await Promise.all(files.map(async file=>digest(await fs.readFile(file))));
91
- // Supply trusted read-only code so rollback can check an older image that
92
- // predates this diagnostic. Its validators still come from that image.
93
- const diagnostic=await fs.readFile(new URL('../../docker/broker-readiness.mjs',import.meta.url),'utf8');
94
- const probe=JSON.parse(await run('docker',[...args,'exec','-T','plugin-broker','node','--import','/app/node_modules/tsx/dist/loader.mjs',
95
- '--input-type=module','-e',diagnostic,...hashes],{stdoutOnly:true,timeout:60000}));
96
- if(probe.version!==1||probe.ready!==true||probe.hostSha256!==hashes[0]||probe.registrySha256!==hashes[1]||probe.configSha256!==hashes[2])
97
- throw Error('Invalid isolated broker readiness readback');
98
- // A concurrent writer invalidates the readback; never silently accept it.
99
- for(let i=0;i<files.length;i++)if(digest(await fs.readFile(files[i]))!==hashes[i])throw Error('Broker binding changed during verification');
100
- return probe;
85
+ return verifyBroker(config,run);
101
86
  }finally {await fs.rm(temporary,{recursive:true,force:true});}
102
87
  }
88
+ // A plugin update normally leaves this single-file binding unchanged. If an
89
+ // operator replaced it (for example to approve new network pins), recreation
90
+ // remains necessary and must retain the existing full-turn drain.
91
+ export async function brokerNeedsRefresh(config,run=execute) {
92
+ if (!(await relayServices(config)).includes('plugin-broker')) return false;
93
+ const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
94
+ const expected=digest(await fs.readFile(hostConfig));
95
+ const script="const fs=require('fs'),crypto=require('crypto');console.log(crypto.createHash('sha256').update(fs.readFileSync(process.env.EZ_PLUGIN_BROKER_HOST_CONFIG)).digest('hex'));";
96
+ const mounted=(await run('docker',[...relayArgs(config),'exec','-T','plugin-broker','node','-e',script],{stdoutOnly:true,timeout:15000})).trim();
97
+ if(!/^[a-f0-9]{64}$/.test(mounted))throw Error('Invalid broker host binding fingerprint');
98
+ return mounted!==expected;
99
+ }
100
+ // Plugin upgrades change the directory-mounted registry, not the broker's
101
+ // single-file host binding. Verify the live broker without replacing its socket.
102
+ export async function verifyBroker(config,run=execute) {
103
+ if (!(await relayServices(config)).includes('plugin-broker')) return null;
104
+ const args=relayArgs(config);
105
+ const hostConfig=config.hostConfig||path.join(config.deploymentDir,'host-executor.json');
106
+ const home=path.dirname(hostConfig)===config.deploymentDir
107
+ ? (await read(hostConfig)).agents?.find(agent=>agent.workspace===config.workspace)?.toolsHome : undefined;
108
+ if(typeof home!=='string'||!path.isAbsolute(home))throw Error('Missing broker registry binding');
109
+ const files=[hostConfig,path.join(home,'registry.json'),path.join(home,'config.json')];
110
+ const hashes=await Promise.all(files.map(async file=>digest(await fs.readFile(file))));
111
+ // Supply trusted read-only code so rollback can check an older image that
112
+ // predates this diagnostic. Its validators still come from that image.
113
+ const diagnostic=await fs.readFile(new URL('../../docker/broker-readiness.mjs',import.meta.url),'utf8');
114
+ const probe=JSON.parse(await run('docker',[...args,'exec','-T','plugin-broker','node','--import','/app/node_modules/tsx/dist/loader.mjs',
115
+ '--input-type=module','-e',diagnostic,...hashes],{stdoutOnly:true,timeout:60000}));
116
+ if(probe.version!==1||probe.ready!==true||probe.hostSha256!==hashes[0]||probe.registrySha256!==hashes[1]||probe.configSha256!==hashes[2])
117
+ throw Error('Invalid isolated broker readiness readback');
118
+ // A concurrent writer invalidates the readback; never silently accept it.
119
+ for(let i=0;i<files.length;i++)if(digest(await fs.readFile(files[i]))!==hashes[i])throw Error('Broker binding changed during verification');
120
+ return probe;
121
+ }
122
+
103
123
  const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
104
124
  async function healthEvidence(config,run) {
105
125
  const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
@@ -140,6 +160,17 @@ export async function perform(home,job,hooks) {
140
160
  const next=await eligibility(home,job.target,root,job.automatic);
141
161
  if(next.old.root!==job.previousRoot||next.old.pkg.version!==job.previousVersion)throw Error('Stale upgrade job');
142
162
  job.status='applying';job.root=root;job.startedAt=new Date().toISOString();await save();
163
+ const complete=async()=>{
164
+ job.status='completed';job.endedAt=new Date().toISOString();await save();
165
+ try { job.cleanup=await cleanupStaleBackups(home);await save(); }
166
+ catch(error) { job.cleanupError=error.message;await save().catch(()=>{}); }
167
+ return job;
168
+ };
169
+ const fail=async(error,recoveryHooks)=>{
170
+ let message=error.message;
171
+ if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
172
+ job.error=message;await save();return recover(home,job,recoveryHooks);
173
+ };
143
174
  try {
144
175
  if(job.target==='main') {
145
176
  job.packageManager=await packageManager(root,run);await save();
@@ -183,38 +214,44 @@ export async function perform(home,job,hooks) {
183
214
  job.brokerReadiness=await refreshBroker(config,run,brokerImage);
184
215
  }
185
216
  } else {
186
- const old=next.old.record,r=await read(path.join(home,'registry.json'));
217
+ const old=next.old.record;
187
218
  const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
188
219
  const s=await snapshot(root),candidate={...old,source:root,revision:s.revision,manifest:s.manifest,deployment:s.deployment,sharedRevisions:s.sharedRevisions};
189
220
  for (const key of old.sharedEnabled || []) if (sharedIdentity(old, key).fingerprint !== sharedIdentity(candidate, key).fingerprint) throw Error('Shared worker changed; disable this client and coordinate an explicit shared worker upgrade before updating');
190
221
  await checkFolders(config,candidate,home);
191
222
  const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
192
223
  for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
193
- const running=Boolean((await run('docker',[...pluginArgs(old),'ps','--status','running','-q'])).trim());
194
- job.rollback={record:old,registry:structuredClone(r),compose:await read(old.compose),running};await save();
195
- await run('docker',[...pluginArgs(old),'stop']);
196
- const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
197
- const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
198
- for(const name of volumes)await backupVolume(run,old,name,backup);
199
- await atomic(old.compose,await compose(config,candidate,secrets,home));
200
- if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
201
- r.plugins[job.target]=candidate;
202
- for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
203
- await atomic(path.join(home,'registry.json'),r);
204
- job.brokerReadiness=await refreshBroker(config,run);
205
- job.runtimeVerified=running;
224
+ return await locked(home,async()=>{
225
+ try {
226
+ const current=await eligibility(home,job.target,root,job.automatic);
227
+ if(current.old.root!==job.previousRoot||current.old.pkg.version!==job.previousVersion||JSON.stringify(current.old.record)!==JSON.stringify(old)||JSON.stringify((await state(home)).config)!==JSON.stringify(config))throw Error('Stale upgrade job');
228
+ if(await brokerNeedsRefresh(config,run)&&!hooks.refreshPluginBroker)throw Error('Broker host binding changed; retry the plugin update after native work drains');
229
+ const r=await read(path.join(home,'registry.json'));
230
+ const running=Boolean((await run('docker',[...pluginArgs(old),'ps','--status','running','-q'])).trim());
231
+ job.rollback={record:old,registry:structuredClone(r),compose:await read(old.compose),running};await save();
232
+ await run('docker',[...pluginArgs(old),'stop']);
233
+ const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
234
+ const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
235
+ for(const name of volumes)await backupVolume(run,old,name,backup);
236
+ await atomic(old.compose,await compose(config,candidate,secrets,home));
237
+ if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
238
+ r.plugins[job.target]=candidate;
239
+ for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
240
+ await atomic(path.join(home,'registry.json'),r);
241
+ job.brokerReadiness=hooks.refreshPluginBroker?await refreshBroker(config,run):await verifyBroker(config,run);
242
+ await publishBrokerPlugins(home,agent.controlDir);
243
+ job.runtimeVerified=running;
244
+ return await complete();
245
+ } catch(error) { return fail(error,{...hooks,registryLocked:true}); }
246
+ },{drainInvocations:true,signal:hooks.signal});
206
247
  }
207
- job.status='completed';job.endedAt=new Date().toISOString();await save();
208
- try { job.cleanup=await cleanupStaleBackups(home);await save(); }
209
- catch(error) { job.cleanupError=error.message;await save().catch(()=>{}); }
210
- return job;
211
- }catch(error){
212
- let message=error.message;
213
- if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
214
- job.error=message;await save();return recover(home,job,hooks);
215
- }
248
+ return await complete();
249
+ }catch(error){return fail(error,hooks);}
250
+
216
251
  }
217
252
  export async function recover(home,job,hooks) {
253
+ if(job.target!=='main'&&job.rollback&&!hooks.registryLocked)
254
+ return locked(home,()=>recover(home,job,{...hooks,registryLocked:true}),{drainInvocations:true,signal:hooks.signal});
218
255
  const run=hooks.execute||execute,{config}=await state(home);
219
256
  try {
220
257
  if(job.rollback) {
@@ -239,7 +276,8 @@ export async function recover(home,job,hooks) {
239
276
  const b=job.rollback;
240
277
  await run('docker',[...pluginArgs(b.record),'stop']);
241
278
  await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
242
- job.rollbackBrokerReadiness=await refreshBroker(config,run);
279
+ job.rollbackBrokerReadiness=hooks.refreshPluginBroker?await refreshBroker(config,run):await verifyBroker(config,run);
280
+ await publishBrokerPlugins(home,(await state(home)).agent.controlDir);
243
281
  if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
244
282
  }
245
283
  job.status='rolled-back';
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import { atomic, locked } from '../plugins/manager.mjs';
6
6
  import { callDeliverySocket } from '../delivery-socket-client.mjs';
7
7
  import { state, read, jobs, jobPath, check, prepare, submit, missing, cleanupStaleBackups } from './control.mjs';
8
- import { perform, environment } from './runtime.mjs';
8
+ import { perform, environment, brokerNeedsRefresh } from './runtime.mjs';
9
9
 
10
10
  const reservedProviderKeys=new Set(['HOME','LANG','LC_ALL','LOGNAME','PATH','SHELL','TERM','TMPDIR','USER','CODEX_HOME','NODE_OPTIONS']);
11
11
  const providerEnvironmentKey=value=>{
@@ -52,6 +52,11 @@ export async function withIdleUpgrade(control,job,signal,apply,{isIdle=idle,wait
52
52
  if(!signal.aborted)return await apply();
53
53
  } finally {await fs.rm(pause,{force:true});}
54
54
  }
55
+ // Plugin replacement drains command leases at activation, not native turns.
56
+ export async function withUpgrade(control,job,signal,apply,options) {
57
+ if(job.target==='main'||options?.requiresIdle)return withIdleUpgrade(control,job,signal,apply,options);
58
+ if(!signal.aborted)return apply();
59
+ }
55
60
  export async function queueAutomatic(home,available) {
56
61
  const existing=await jobs(home);
57
62
  if(existing.some(job=>['queued','applying','recovery-required'].includes(job.status)))return null;
@@ -94,6 +99,15 @@ export async function supervise(deployment,signal,{discover=check}={}) {
94
99
  await waitForHostHeartbeat(agent.controlDir,child,()=>error);
95
100
  };
96
101
  const pause=path.join(agent.controlDir,'upgrade-pause.json');
102
+ const refreshRequirement=async job=>{
103
+ if(job.target==='main')return false;
104
+ try {return await brokerNeedsRefresh((await state(home)).config);}
105
+ catch(error) {
106
+ job.status=job.status==='applying'?'recovery-required':'failed';job.error=error.message;
107
+ await atomic(path.join(jobPath(home,job.id),'job.json'),job);
108
+ return null;
109
+ }
110
+ };
97
111
  const beat=setInterval(()=>{void atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()}).catch(()=>{});},1000);
98
112
  try {
99
113
  await atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()});
@@ -107,8 +121,11 @@ export async function supervise(deployment,signal,{discover=check}={}) {
107
121
  await sleep(1200);
108
122
  const interrupted=(await jobs(home)).find(j=>j.status==='applying');
109
123
  if(interrupted){
110
- await atomic(pause,{id:interrupted.id});const result=await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));await fs.rm(pause,{force:true});
111
- if(result.status==='completed'&&interrupted.target==='main')return;
124
+ const refreshPluginBroker=await refreshRequirement(interrupted);
125
+ const result=refreshPluginBroker===null?undefined:interrupted.target==='main'
126
+ ? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));}finally{await fs.rm(pause,{force:true});}})()
127
+ : await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
128
+ if(result?.status==='completed'&&interrupted.target==='main')return;
112
129
  }
113
130
  const isolated=host.isolation==='isolated'
114
131
  if(!isolated && !child)await startHost((await state(home)).config.packageRoot);
@@ -117,15 +134,19 @@ export async function supervise(deployment,signal,{discover=check}={}) {
117
134
  if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
118
135
  const pending=(await jobs(home)).find(j=>j.status==='queued');
119
136
  if(pending) {
120
- const result=await withIdleUpgrade(agent.controlDir,pending,signal,async()=>{
137
+ const refreshPluginBroker=await refreshRequirement(pending);
138
+ if(refreshPluginBroker===null)continue;
139
+ const apply=async()=>{
121
140
  try {
122
- return await locked(home,async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost});});
141
+ const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,signal,refreshPluginBroker});};
142
+ return pending.target==='main'?await locked(home,activate,{drainInvocations:true,signal}):await activate();
123
143
  } catch(error) {
124
144
  // A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
125
145
  const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
126
146
  if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
127
147
  }
128
- });
148
+ };
149
+ const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker});
129
150
  if(result?.status==='completed') {
130
151
  if(pending.target==='main')return;
131
152
  nextCheck=0;
package/src/workspace.ts CHANGED
@@ -2,6 +2,17 @@ import { installAgentGuidance } from './agent-guidance.js'
2
2
  import { link, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { randomUUID } from 'node:crypto'
4
4
  import path from 'node:path'
5
+ import { TOOLS_LINE } from './tools-line.mjs'
6
+
7
+ // Engine-neutral memory: every native CLI loads AGENTS.md, so both read the
8
+ // same plain files. The agent owns the folder and the section after seeding.
9
+ const MEMORY_SEED = `## Memory
10
+
11
+ Keep durable facts (people, preferences, decisions, standing commitments) in
12
+ \`memory/\`, one fact per file, each listed on one line in \`memory/MEMORY.md\`.
13
+ Read \`memory/MEMORY.md\` when a conversation starts. Save or update a fact as
14
+ soon as you learn something a future conversation will need; skip what other
15
+ workspace files already hold.`
5
16
 
6
17
  // Publish each complete seed exclusively. Reinstall never replaces the agent's mind.
7
18
  export const initializeWorkspace = async (workspace: string, purposeFile: string | undefined = process.env.EZ_AGENT_PURPOSE_FILE): Promise<string[]> => {
@@ -22,7 +33,7 @@ export const initializeWorkspace = async (workspace: string, purposeFile: string
22
33
  try {
23
34
  const purpose = (await readFile(purposeFile, 'utf8')).trim()
24
35
  if (!purpose) throw new Error('Purpose file must not be empty.')
25
- await writeFile(temporary, `## Purpose\n\n${purpose}\n`, { mode: 0o600, flag: 'wx' })
36
+ await writeFile(temporary, `## Purpose\n\n${purpose}\n\n${MEMORY_SEED}\n\n${TOOLS_LINE}\n`, { mode: 0o600, flag: 'wx' })
26
37
  try {
27
38
  await link(temporary, target)
28
39
  created.push(name)