@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37
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/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +96 -0
- package/Dockerfile +20 -11
- package/README.md +10 -13
- package/THIRD_PARTY_NOTICES.md +7 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +41 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/ai-selection.md +10 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +98 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/ai-cli.ts +1 -1
- package/src/ai.ts +131 -5
- package/src/application-channel.ts +78 -7
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/config.ts +12 -27
- package/src/control-state.ts +175 -8
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +81 -24
- package/src/files.ts +38 -11
- package/src/host-executor.ts +33 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +98 -54
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +8 -3
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +131 -23
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +1 -0
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +220 -1
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +2 -2
- package/test/config.test.ts +38 -43
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +120 -12
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +87 -3
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +136 -2
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +93 -13
- package/test/workspace.test.ts +4 -1
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -43
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -5
- package/templates/agent-guidance.md +0 -17
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
package/src/plugins/manager.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { createHash, randomUUID, randomBytes } from 'node:crypto';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
|
+
import { callDeliverySocket } from '../delivery-socket-client.mjs';
|
|
8
9
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
9
10
|
|
|
10
11
|
const reserved = new Set(['status','updates','plugins','tools','message','owner','approval','react','setup','help','version']);
|
|
@@ -21,10 +22,24 @@ export async function atomic(file, value) {
|
|
|
21
22
|
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
22
23
|
await fs.writeFile(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600,flag:'wx'});
|
|
23
24
|
await fs.rename(tmp,file);
|
|
25
|
+
await stewardOwned(file);
|
|
26
|
+
}
|
|
27
|
+
// Files the broker creates as root must stay usable by the agent owner:
|
|
28
|
+
// adopt the owning uid/gid of the containing directory. Off root this is a no-op.
|
|
29
|
+
export async function stewardOwned(file) {
|
|
30
|
+
if (process.geteuid?.() !== 0) return;
|
|
31
|
+
const parent = await fs.stat(path.dirname(file));
|
|
32
|
+
await fs.chown(file, parent.uid, parent.gid);
|
|
24
33
|
}
|
|
25
34
|
// Only a registry locator lives in native instructions. Inventory is generated on read.
|
|
26
35
|
export async function bindToolDiscovery(home,workspace) {
|
|
27
36
|
home=await fs.realpath(home);workspace=await fs.realpath(workspace);
|
|
37
|
+
let launcher=path.join(home,'bin','ez');
|
|
38
|
+
const config=await json(path.join(home,'config.json')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error;});
|
|
39
|
+
if(config?.hostConfig) {
|
|
40
|
+
const host=await json(config.hostConfig);
|
|
41
|
+
if(host.isolation==='isolated') launcher='ez';
|
|
42
|
+
}
|
|
28
43
|
const start='<!-- ez tools: begin -->',end='<!-- ez tools: end -->';
|
|
29
44
|
for(const name of ['AGENTS.md','AGENTS.override.md']) {
|
|
30
45
|
const file=path.join(workspace,name);
|
|
@@ -34,7 +49,7 @@ export async function bindToolDiscovery(home,workspace) {
|
|
|
34
49
|
const prior=stat?await fs.readFile(file,'utf8'):'';
|
|
35
50
|
const from=prior.indexOf(start),to=prior.indexOf(end);
|
|
36
51
|
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');
|
|
37
|
-
const block=start+'\nInstalled
|
|
52
|
+
const block=start+'\nInstalled capabilities: `'+launcher+' --help` and `'+launcher+' tools list --details`. Use this bound launcher; read the matching skill when needed.\n'+end;
|
|
38
53
|
const next=from<0?prior+'\n'+block+'\n':prior.slice(0,from)+block+prior.slice(to+end.length);
|
|
39
54
|
if(next===prior)continue;
|
|
40
55
|
const tmp=file+'.'+randomUUID()+'.tmp';
|
|
@@ -48,7 +63,7 @@ async function activeInvocations(home) {
|
|
|
48
63
|
if(!/^[0-9a-f-]{36}\.json$/.test(file))throw Error('Invalid command invocation lease; inspect before recovery');
|
|
49
64
|
const lease=await json(path.join(directory,file));
|
|
50
65
|
if(!Number.isSafeInteger(lease.pid)||lease.pid<1||typeof lease.container!=='string'||!lease.container)throw Error('Invalid command invocation lease; inspect before recovery');
|
|
51
|
-
try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH')throw error;}
|
|
66
|
+
try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH'&&error.code!=='EPERM')throw error;}
|
|
52
67
|
const state=await run(['container','inspect',lease.container],{capture:true});
|
|
53
68
|
if(state.code===0)throw Error(`Stale command invocation lease; inspect container ${lease.container} before recovery`);
|
|
54
69
|
if(!/No such (?:object|container)/i.test(state.stderr+state.stdout))throw Error('Unable to verify a stale command invocation lease; inspect Docker before recovery');
|
|
@@ -59,6 +74,7 @@ async function activeInvocations(home) {
|
|
|
59
74
|
async function invocationLease(home,container) {
|
|
60
75
|
const directory=invocationDirectory(home);await privateDir(directory);
|
|
61
76
|
const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container}),{mode:0o600,flag:'wx'});
|
|
77
|
+
await stewardOwned(file);
|
|
62
78
|
return async()=>{await fs.rm(file,{force:true});};
|
|
63
79
|
}
|
|
64
80
|
export async function locked(home, fn, {allowInvocations=false}={}) {
|
|
@@ -202,10 +218,16 @@ async function hostFolderRoots(config, record, home) {
|
|
|
202
218
|
if (candidates.length !== 1) throw Error('Host folder binding does not belong to this registry');
|
|
203
219
|
const agent = candidates[0], deployment = path.dirname(hostConfig);
|
|
204
220
|
if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
|
|
205
|
-
realpathSync(agent.workspace) !== config.workspace ||
|
|
206
|
-
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir) ||
|
|
221
|
+
realpathSync(agent.workspace) !== config.workspace ||
|
|
207
222
|
childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
|
|
208
223
|
throw Error('Invalid host folder binding deployment');
|
|
224
|
+
// The isolated broker receives only the exact workspace, control directory,
|
|
225
|
+
// tools home, and host config mounts. It must not require the host
|
|
226
|
+
// deployment's convenience mind/control symlinks (or a deployment mount).
|
|
227
|
+
if (process.env.EZ_DOCKER_COMPOSE !== 'standalone' &&
|
|
228
|
+
(realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
229
|
+
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
|
|
230
|
+
throw Error('Invalid host folder binding deployment');
|
|
209
231
|
const configured = agent.pluginFolderRoots;
|
|
210
232
|
if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
|
|
211
233
|
throw Error('Invalid host folder roots');
|
|
@@ -250,8 +272,7 @@ export async function hostNetworkBindings(config, record, home) {
|
|
|
250
272
|
if (candidates.length !== 1) throw Error('Host network binding does not belong to this registry');
|
|
251
273
|
const agent = candidates[0], deployment = path.dirname(hostConfig);
|
|
252
274
|
if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
|
|
253
|
-
realpathSync(agent.workspace) !== config.workspace ||
|
|
254
|
-
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir) ||
|
|
275
|
+
realpathSync(agent.workspace) !== config.workspace ||
|
|
255
276
|
childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
|
|
256
277
|
throw Error('Invalid host network binding deployment');
|
|
257
278
|
const configured = agent.pluginNetworkBindings;
|
|
@@ -259,6 +280,12 @@ export async function hostNetworkBindings(config, record, home) {
|
|
|
259
280
|
throw Error('Invalid host network bindings');
|
|
260
281
|
const route = configured?.[record.manifest?.id];
|
|
261
282
|
if (route === undefined) return [];
|
|
283
|
+
if (process.env.EZ_DOCKER_COMPOSE === 'standalone') {
|
|
284
|
+
throw Error('Isolated broker cannot use host network bindings');
|
|
285
|
+
}
|
|
286
|
+
if (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
287
|
+
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir))
|
|
288
|
+
throw Error('Invalid host network binding deployment');
|
|
262
289
|
keys(route, ['revisions', 'bindings']);
|
|
263
290
|
const trusted = await snapshot(record.source);
|
|
264
291
|
if (record.revision !== trusted.revision || record.source !== trusted.source ||
|
|
@@ -312,14 +339,24 @@ export async function compose(config, record, secrets={}, home) {
|
|
|
312
339
|
return [key,(value.prefix||'')+secrets[value.secret]+(value.suffix||'')];
|
|
313
340
|
}))}:{}),...(s.command?{command:s.command}:{})};
|
|
314
341
|
}
|
|
315
|
-
|
|
342
|
+
// Keep the persisted descriptor portable between the host supervisor's
|
|
343
|
+
// Compose v2 and the isolated broker's bundled Compose v1. Project identity
|
|
344
|
+
// is always supplied with --project-name, so a top-level name is unnecessary.
|
|
345
|
+
return attachShared({version:'3.8',services,volumes,...(Object.keys(networks).length?{networks}:{})}, record);
|
|
346
|
+
}
|
|
347
|
+
async function refreshRecordCompose(home, config, record) {
|
|
348
|
+
await checkFolders(config, record, home);
|
|
349
|
+
const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(error=>{if(error.code==='ENOENT')return {};throw error;});
|
|
350
|
+
await atomic(record.compose, await compose(config, record, secrets, home));
|
|
316
351
|
}
|
|
317
352
|
function dockerEnv() {
|
|
318
|
-
return Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','DOCKER_HOST','DOCKER_CONTEXT','DOCKER_CONFIG','BUILDX_CONFIG'].filter(k=>process.env[k]!==undefined).map(k=>[k,process.env[k]]));
|
|
353
|
+
return Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','DOCKER_HOST','DOCKER_CONTEXT','DOCKER_CONFIG','BUILDX_CONFIG','EZ_DOCKER_COMPOSE'].filter(k=>process.env[k]!==undefined).map(k=>[k,process.env[k]]));
|
|
319
354
|
}
|
|
355
|
+
const dockerInvocation = argv => process.env.EZ_DOCKER_COMPOSE === 'standalone' && argv[0] === 'compose'
|
|
356
|
+
? {command:'docker-compose',args:argv.slice(1)} : {command:'docker',args:argv};
|
|
320
357
|
export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,timeoutMs=0,maxBytes=Infinity}={}) {
|
|
321
358
|
return new Promise((resolve,reject)=>{
|
|
322
|
-
const child=spawn(
|
|
359
|
+
const invocation=dockerInvocation(argv),child=spawn(invocation.command,invocation.args,{env:dockerEnv(),stdio:capture?['pipe','pipe','pipe']:['inherit','inherit','inherit']});
|
|
323
360
|
let stdout='',stderr='',cancelled=false,killTimer,bytes=0,failure;
|
|
324
361
|
if(capture) {
|
|
325
362
|
const collect=(b,err)=>{bytes+=b.length;if(bytes>maxBytes){failure=Error('Command output limit exceeded');cancel('SIGTERM');return;}if(err)stderr+=b;else stdout+=b;};
|
|
@@ -368,6 +405,53 @@ async function checked(args) {
|
|
|
368
405
|
const r=await run(args,{capture:true});if(r.code) throw Error(r.stderr||r.stdout||`Docker failed (${r.code})`);return r.stdout;
|
|
369
406
|
}
|
|
370
407
|
const composeArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
|
|
408
|
+
// Application context is opaque domain data. A command container receives only
|
|
409
|
+
// the object addressed to its installed plugin ID, never core state or another
|
|
410
|
+
// plugin's context. The current run and binding are re-authorized before it is
|
|
411
|
+
// forwarded to Docker.
|
|
412
|
+
const ownerKey = owner => owner?.id || (Number.isSafeInteger(owner?.telegramUserId) && Number.isSafeInteger(owner?.telegramChatId)
|
|
413
|
+
? `telegram:${owner.telegramUserId}:${owner.telegramChatId}` : undefined);
|
|
414
|
+
const ownerEpoch = owner => owner?.generation || owner?.pairedAt;
|
|
415
|
+
const readJson = async file => JSON.parse(await fs.readFile(file,'utf8'));
|
|
416
|
+
// Run records live in relay memory; this manager stays plain Node, so it asks
|
|
417
|
+
// the relay through the shared delivery socket client. A relay that is down
|
|
418
|
+
// reads like a missing run: no context is forwarded.
|
|
419
|
+
const readRun = async (socketPath,runId) => callDeliverySocket(socketPath,{op:'get',payload:{runId}}).catch(error=>{
|
|
420
|
+
if(/Unknown run/.test(error.message)||/Delivery relay unavailable/.test(error.message))return undefined;
|
|
421
|
+
throw error;
|
|
422
|
+
});
|
|
423
|
+
// This manager is intentionally runnable as plain Node. Read only the current
|
|
424
|
+
// run and existing application binding rather than importing the TypeScript
|
|
425
|
+
// relay, and fail closed on missing/revoked ownership.
|
|
426
|
+
const authorizedApplicationRun = async (controlDir,runId,socketPath) => {
|
|
427
|
+
if(!path.isAbsolute(controlDir)||!/^[A-Za-z0-9_-]+$/.test(runId))throw Error('Plugin context requires a bound active run');
|
|
428
|
+
const run=await readRun(socketPath||path.join(controlDir,'delivery.sock'),runId);
|
|
429
|
+
const application=run?.application ?? run?.telegramApplication;
|
|
430
|
+
if(!application?.context||run?.id!==runId||run.status!=='running'||typeof application.bindingId!=='string')return undefined;
|
|
431
|
+
const state=await readJson(path.join(controlDir,'control-state.json'));
|
|
432
|
+
const bindings=await readJson(path.join(controlDir,'application-bindings.json'));
|
|
433
|
+
const binding=Array.isArray(bindings)?bindings.find(candidate=>candidate?.bindingId===application.bindingId):undefined;
|
|
434
|
+
if(!binding?.owner||!state?.owner||ownerKey(binding.owner)!==ownerKey(state.owner)||ownerEpoch(binding.owner)!==ownerEpoch(state.owner)||
|
|
435
|
+
run.ownerId!==ownerKey(state.owner)||run.ownerEpoch!==ownerEpoch(state.owner))throw Error('Application authority revoked');
|
|
436
|
+
return application;
|
|
437
|
+
};
|
|
438
|
+
export async function applicationPluginContext(pluginId, environment=process.env) {
|
|
439
|
+
const rawDir=environment.EZ_CONTROL_DIR?.trim(),runId=environment.EZ_RUN_ID?.trim();
|
|
440
|
+
if(!rawDir||!runId)return undefined;
|
|
441
|
+
if(!path.isAbsolute(rawDir))throw Error('Plugin context requires a bound active run');
|
|
442
|
+
const controlDir=await fs.realpath(rawDir).catch(()=>undefined);
|
|
443
|
+
if(!controlDir)return undefined;
|
|
444
|
+
const application=await authorizedApplicationRun(controlDir,runId,environment.EZ_DELIVERY_SOCKET?.trim()||path.join(rawDir,'delivery.sock'));
|
|
445
|
+
const contexts=application?.context?.plugins;
|
|
446
|
+
if(!contexts||typeof contexts!=='object'||Array.isArray(contexts)||!Object.hasOwn(contexts,pluginId))return undefined;
|
|
447
|
+
const context=contexts[pluginId];
|
|
448
|
+
if(!context||typeof context!=='object'||Array.isArray(context))throw Error('Plugin application context must be an object');
|
|
449
|
+
const checkDepth=(value,level=0)=>{if(level>10)throw Error('Plugin application context is too deeply nested');if(value&&typeof value==='object')for(const entry of Object.values(value))checkDepth(entry,level+1);};
|
|
450
|
+
checkDepth(context);
|
|
451
|
+
const encoded=JSON.stringify(context);
|
|
452
|
+
if(Buffer.byteLength(encoded)>16*1024)throw Error('Plugin application context is too large');
|
|
453
|
+
return encoded;
|
|
454
|
+
}
|
|
371
455
|
export async function registry(home) {
|
|
372
456
|
const r=await json(path.join(home,'registry.json'));
|
|
373
457
|
if(r.schemaVersion!==1 || r.owner!==home || !r.plugins || !r.commands) throw Error('Corrupt registry');
|
|
@@ -378,7 +462,7 @@ export async function registry(home) {
|
|
|
378
462
|
return r;
|
|
379
463
|
}
|
|
380
464
|
// The lock protects admission and compose refresh, never a persistent connection.
|
|
381
|
-
export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false}={}) {
|
|
465
|
+
export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false,environment=process.env}={}) {
|
|
382
466
|
strings(args);
|
|
383
467
|
return locked(home,async()=>{
|
|
384
468
|
const config=await json(path.join(home,'config.json')),r=await registry(home);
|
|
@@ -389,8 +473,25 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
|
|
|
389
473
|
const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
390
474
|
await atomic(record.compose,await compose(config,record,secrets,home));
|
|
391
475
|
const container=`${record.project}-call-${randomUUID()}`;
|
|
392
|
-
|
|
393
|
-
|
|
476
|
+
// The application context must reach the command container as an
|
|
477
|
+
// environment variable. A top-level `compose --env-file` only feeds
|
|
478
|
+
// variable interpolation in both Compose v1 and v2, so the container never
|
|
479
|
+
// receives it; a one-shot override file sets the service environment
|
|
480
|
+
// directly and keeps the value out of the process argument list.
|
|
481
|
+
const context=await applicationPluginContext(record.manifest.id,environment);
|
|
482
|
+
let contextFile;
|
|
483
|
+
if(context!==undefined){
|
|
484
|
+
await privateDir(path.join(home,'plugin-context'));
|
|
485
|
+
contextFile=path.join(home,'plugin-context',`${randomUUID()}.json`);
|
|
486
|
+
// Compose interpolates every file it loads, including this override, so
|
|
487
|
+
// an opaque `$` in the context must be escaped as `$$` to reach the
|
|
488
|
+
// container byte-for-byte.
|
|
489
|
+
const override={services:{[binding.service]:{environment:{EZ_PLUGIN_CONTEXT:context.replaceAll('$',()=> '$$')}}}};
|
|
490
|
+
await fs.writeFile(contextFile,JSON.stringify(override,null,2)+'\n',{mode:0o600,flag:'wx'});
|
|
491
|
+
}
|
|
492
|
+
const invocationRelease=invocation?await invocationLease(home,container):undefined;
|
|
493
|
+
const release=(invocationRelease||contextFile)?(async()=>{try{if(contextFile)await fs.rm(contextFile,{force:true});}finally{await invocationRelease?.();}}):undefined;
|
|
494
|
+
return {container,plugin:record.manifest.id,revision:record.revision,contextFile,argv:[...composeArgs(record),...(contextFile?['--file',contextFile]:[]),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])],release};
|
|
394
495
|
},{allowInvocations:true});
|
|
395
496
|
}
|
|
396
497
|
export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
|
|
@@ -556,7 +657,7 @@ export async function main(args) {
|
|
|
556
657
|
await atomic(latest.compose, await compose(currentConfig, latest, secrets, home));
|
|
557
658
|
// Persist the binding before recreating clients; start can recover an interrupted recreation.
|
|
558
659
|
await atomic(path.join(home, 'registry.json'), current);
|
|
559
|
-
await checked([...composeArgs(latest), 'up', '-d', '--wait']);
|
|
660
|
+
await checked([...composeArgs(latest), 'up', '-d', ...(process.env.EZ_DOCKER_COMPOSE === 'standalone' ? [] : ['--wait'])]);
|
|
560
661
|
return emit({ ok: true, plugin: name, shared: key, ...result });
|
|
561
662
|
});
|
|
562
663
|
}
|
|
@@ -570,18 +671,25 @@ export async function main(args) {
|
|
|
570
671
|
return emit({ok:true,path:dest});
|
|
571
672
|
}
|
|
572
673
|
if(args.length)throw Error('Unknown lifecycle arguments');
|
|
573
|
-
if(action==='logs') return
|
|
574
|
-
|
|
674
|
+
if(action==='logs'||action==='status') return locked(home,async()=>{
|
|
675
|
+
const current=await registry(home),latest=current.plugins[name];
|
|
676
|
+
if(latest?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
|
|
677
|
+
const currentConfig=await json(path.join(home,'config.json'));
|
|
678
|
+
// A host-side Compose v2 operation may have rewritten this file with
|
|
679
|
+
// fields that the broker's bundled Compose v1 does not understand.
|
|
680
|
+
await refreshRecordCompose(home,currentConfig,latest);
|
|
681
|
+
if(action==='logs')return emit({plugin:name,logs:await checked([...composeArgs(latest),'logs','--tail','100','--no-color'])});
|
|
682
|
+
const ps = process.env.EZ_DOCKER_COMPOSE === 'standalone' ? ['ps', '--all'] : ['ps', '--all', '--format', 'json'];
|
|
683
|
+
const output=await checked([...composeArgs(latest),...ps]);
|
|
684
|
+
return emit({plugin:name,installedVersion:latest.manifest.version,containers:output});
|
|
685
|
+
});
|
|
575
686
|
if(!['start','stop','uninstall'].includes(action))throw Error('Unknown lifecycle command');
|
|
576
687
|
return locked(home,async()=>{
|
|
577
|
-
const current=await registry(home)
|
|
578
|
-
if(
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
await atomic(record.compose,await compose(currentConfig,current.plugins[name],secrets,home));
|
|
583
|
-
}
|
|
584
|
-
await checked([...composeArgs(record),...(action==='start'?['up','-d','--wait']:action==='stop'?['stop']:['down'])]);
|
|
688
|
+
const current=await registry(home),latest=current.plugins[name];
|
|
689
|
+
if(latest?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
|
|
690
|
+
const currentConfig=await json(path.join(home,'config.json'));
|
|
691
|
+
await refreshRecordCompose(home,currentConfig,latest);
|
|
692
|
+
await checked([...composeArgs(latest),...(action==='start'?['up','-d',...(process.env.EZ_DOCKER_COMPOSE === 'standalone'?[]:['--wait'])]:action==='stop'?['stop']:['down'])]);
|
|
585
693
|
if(action==='uninstall') {delete current.plugins[name];for(const [alias,owner] of Object.entries(current.commands))if(owner===name)delete current.commands[alias];await atomic(path.join(home,'registry.json'),current);}
|
|
586
694
|
emit({ok:true,plugin:name,action,dataPreserved:true});
|
|
587
695
|
});
|
|
@@ -20,8 +20,8 @@ export async function nativeTaskBinding(home,environment=process.env) {
|
|
|
20
20
|
const matches=host.agents.filter(a=>a.toolsHome===home&&a.workspace===workspace);
|
|
21
21
|
if(matches.length!==1||typeof matches[0].controlDir!=='string'||!path.isAbsolute(matches[0].controlDir))throw Error('Native task binding does not match owning agent');
|
|
22
22
|
const controlDir=await fs.realpath(matches[0].controlDir);
|
|
23
|
-
const env=Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR'].filter(k=>environment[k]!==undefined).map(k=>[k,environment[k]]));
|
|
24
|
-
return {cwd:workspace,env:{...env,EZ_CONTROL_DIR:controlDir,EZ_AGENT_WORKSPACE:workspace,EZ_EXECUTOR_CLI:host.cli}};
|
|
23
|
+
const env=Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','EZ_DELIVERY_SOCKET'].filter(k=>environment[k]!==undefined).map(k=>[k,environment[k]]));
|
|
24
|
+
return {cwd:workspace,env:{...env,EZ_CONTROL_DIR:controlDir,EZ_AGENT_WORKSPACE:workspace,EZ_EXECUTOR_CLI:host.cli,EZ_DELIVERY_SOCKET:env.EZ_DELIVERY_SOCKET??path.join(matches[0].controlDir,'delivery.sock')}};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export async function nativeTasks(home,args,{signal,command='schedule',deliveryContext}={}) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RunRecord } from './runs.js'
|
|
2
|
+
|
|
3
|
+
// Relay/host overhead converts a run's trigger fields into the one slim suffix
|
|
4
|
+
// the core appends to literal input. The core itself never reads the ledger.
|
|
5
|
+
export const runPromptSuffix = (run: RunRecord | null | undefined): string => {
|
|
6
|
+
if (!run || run.taskId) return ''
|
|
7
|
+
if (run.messageId === undefined && !run.application && !run.delivery) return ''
|
|
8
|
+
const scope = run.application?.scope ?? run.delivery?.scope
|
|
9
|
+
const channel = run.scheduled ? 'schedule' : run.application || run.delivery ? 'application' : run.messageId !== undefined ? 'chat' : 'trigger'
|
|
10
|
+
const contact = run.messageId !== undefined ? ` message ${run.messageId}` : scope !== undefined ? ` scope ${JSON.stringify(scope)}` : ''
|
|
11
|
+
return `\n\n[${channel}${contact}] Reply via ezenciel-agents-message --text "..."; stdout is not delivered.`
|
|
12
|
+
}
|
package/src/react.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadControlConfig } from './config.js'
|
|
2
2
|
import { normalizeReactionEmoji, TELEGRAM_REACTIONS } from './reaction.js'
|
|
3
|
-
import {
|
|
3
|
+
import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
|
|
4
4
|
|
|
5
5
|
const runId = process.env.EZ_RUN_ID?.trim()
|
|
6
6
|
const args = process.argv.slice(2).filter((arg) => arg !== '--')
|
|
@@ -28,6 +28,8 @@ if (!emoji) {
|
|
|
28
28
|
process.exit(1)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const
|
|
32
|
-
const item = await
|
|
33
|
-
|
|
31
|
+
const controlDir = loadControlConfig().controlDir
|
|
32
|
+
const item = await callDeliverySocket(socketPathFor(controlDir), {
|
|
33
|
+
op: 'enqueue', payload: { kind: 'reaction', runId, emoji },
|
|
34
|
+
}) as { outbox_id: string; id: string; emoji?: string }
|
|
35
|
+
console.log(JSON.stringify({ ok: true, run: runId, outbox_id: item.id, emoji: item.emoji ?? emoji }))
|