@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.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +24 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docker/broker-update-smoke.mjs +16 -2
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/architecture/ai-selection.md +9 -1
- package/docs/docker-runtime.md +46 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +84 -6
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +13 -23
- package/package.json +1 -1
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +90 -14
- package/src/application-channel.ts +18 -14
- package/src/auth-profile.ts +53 -0
- package/src/codex-session.ts +14 -3
- package/src/control-state.ts +28 -21
- package/src/delivery-socket.ts +2 -0
- package/src/executor.ts +38 -26
- package/src/host-executor-client.ts +19 -7
- package/src/host-executor.ts +35 -10
- package/src/index.ts +320 -122
- package/src/menu.ts +8 -7
- package/src/plugin-broker.mjs +4 -11
- package/src/plugins/manager.mjs +70 -48
- package/src/runs.ts +74 -5
- package/src/schedule-cli.ts +39 -7
- package/src/scheduled-tasks.ts +42 -8
- package/src/scheduler.ts +9 -4
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +159 -0
- package/src/task-executor.ts +3 -2
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +79 -41
- package/src/updates/supervisor.mjs +27 -6
- package/src/workspace.ts +12 -1
- package/test/ai.test.ts +48 -2
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/auth-profiles.test.ts +187 -0
- package/test/intake-relay.test.ts +63 -2
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +51 -1
- package/test/plugin-manager.test.mjs +76 -38
- package/test/run-handoff.test.ts +132 -0
- package/test/scheduled-tasks.test.ts +24 -2
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +423 -0
- package/test/updates.test.mjs +77 -15
- 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
|
-
|
|
143
|
-
|
|
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
|
+
}
|
package/src/task-executor.ts
CHANGED
|
@@ -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, '
|
|
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
|
package/src/updates/binding.mjs
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath
|
|
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
|
-
|
|
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
|
}
|
package/src/updates/control.mjs
CHANGED
|
@@ -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;
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
137
|
+
const refreshPluginBroker=await refreshRequirement(pending);
|
|
138
|
+
if(refreshPluginBroker===null)continue;
|
|
139
|
+
const apply=async()=>{
|
|
121
140
|
try {
|
|
122
|
-
|
|
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)
|