@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.36

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 (137) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +67 -0
  5. package/Dockerfile +7 -9
  6. package/README.md +10 -13
  7. package/bin/ez +97 -0
  8. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  9. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  10. package/bin/ezenciel-agents.mjs +1 -1
  11. package/compose.yaml +40 -4
  12. package/docker/entrypoint.sh +1 -1
  13. package/docker/healthcheck.mjs +12 -4
  14. package/docker/run.ts +5 -6
  15. package/docker/smoke.mjs +28 -6
  16. package/docs/application-channel.md +32 -10
  17. package/docs/architecture/authority-boundaries.md +2 -2
  18. package/docs/channel-backend.md +4 -3
  19. package/docs/docker-runtime.md +79 -11
  20. package/docs/local-qa.md +2 -2
  21. package/docs/plugin-guide.md +202 -0
  22. package/docs/plugins.md +48 -2
  23. package/docs/releasing.md +13 -0
  24. package/docs/responsive-channels.md +1 -1
  25. package/docs/scheduling.md +4 -4
  26. package/docs/setup.md +11 -5
  27. package/docs/upgrades.md +46 -23
  28. package/package.json +5 -5
  29. package/scripts/smoke-scheduler.ts +10 -3
  30. package/scripts/smoke.ts +14 -11
  31. package/src/agent-guidance.ts +6 -9
  32. package/src/agent-install.ts +28 -7
  33. package/src/application-channel.ts +78 -7
  34. package/src/application-cli.ts +2 -2
  35. package/src/approval-cli.ts +14 -7
  36. package/src/approval.ts +27 -37
  37. package/src/channel-backend.ts +2 -3
  38. package/src/config.ts +12 -27
  39. package/src/control-state.ts +175 -8
  40. package/src/conversation-menu.ts +6 -17
  41. package/src/delivery-socket-client.d.mts +4 -0
  42. package/src/delivery-socket-client.mjs +104 -0
  43. package/src/delivery-socket.ts +354 -0
  44. package/src/execution-authority.ts +21 -5
  45. package/src/executor.ts +20 -23
  46. package/src/files.ts +38 -11
  47. package/src/host-executor.ts +28 -14
  48. package/src/inbox.ts +15 -49
  49. package/src/index.ts +95 -52
  50. package/src/install-tools.mjs +97 -22
  51. package/src/ledger-endpoint.d.mts +2 -0
  52. package/src/ledger-endpoint.mjs +17 -0
  53. package/src/menu.ts +7 -2
  54. package/src/message-history.ts +13 -18
  55. package/src/message.ts +35 -18
  56. package/src/plugin-broker.mjs +396 -0
  57. package/src/plugins/connection.mjs +2 -2
  58. package/src/plugins/manager.mjs +132 -23
  59. package/src/plugins/native-tasks.mjs +2 -2
  60. package/src/prompt-suffix.ts +12 -0
  61. package/src/react.ts +6 -4
  62. package/src/runs.ts +156 -243
  63. package/src/schedule-cli.ts +29 -10
  64. package/src/scheduled-tasks.ts +53 -10
  65. package/src/scheduler.ts +20 -7
  66. package/src/service.ts +3 -3
  67. package/src/software-status.ts +36 -4
  68. package/src/task-executor.ts +2 -2
  69. package/src/task-workspace.ts +4 -3
  70. package/src/tasks.ts +1 -1
  71. package/src/telegram-provisioning-cli.ts +22 -0
  72. package/src/telegram-provisioning.ts +214 -0
  73. package/src/updates/control.mjs +77 -7
  74. package/src/updates/runtime.mjs +28 -8
  75. package/src/updates/status.mjs +25 -3
  76. package/src/updates/supervisor.mjs +15 -16
  77. package/src/version.ts +20 -0
  78. package/src/workspace.ts +3 -9
  79. package/templates/failure-review.md +1 -1
  80. package/templates/maintainer-purpose.md +1 -1
  81. package/test/agent-guidance.test.ts +14 -80
  82. package/test/agent-install.test.ts +14 -0
  83. package/test/ai.test.ts +36 -0
  84. package/test/application-channel.test.ts +61 -13
  85. package/test/application-only.test.ts +14 -13
  86. package/test/channel-backend.test.ts +3 -7
  87. package/test/channel-delivery.test.ts +4 -1
  88. package/test/channel-owner.test.ts +6 -3
  89. package/test/codex-context.test.ts +2 -2
  90. package/test/config.test.ts +38 -43
  91. package/test/control-state.test.ts +83 -4
  92. package/test/conversation-menu.test.ts +2 -4
  93. package/test/delivery-transport.test.ts +72 -0
  94. package/test/docker-runtime.test.ts +27 -14
  95. package/test/engine-handoff.test.ts +7 -6
  96. package/test/event-sources.test.ts +2 -2
  97. package/test/execution-authority.test.ts +9 -9
  98. package/test/executor.test.ts +10 -11
  99. package/test/failure.test.ts +12 -5
  100. package/test/files.test.ts +2 -2
  101. package/test/helpers/ledger.ts +18 -0
  102. package/test/host-executor.test.ts +74 -0
  103. package/test/inbox.test.ts +8 -10
  104. package/test/install-config.test.ts +1 -0
  105. package/test/install-tools.test.mjs +74 -7
  106. package/test/intake-relay.test.ts +58 -11
  107. package/test/message-history.test.ts +39 -47
  108. package/test/native-tasks.test.ts +1 -1
  109. package/test/plugin-broker.test.mjs +172 -0
  110. package/test/plugin-manager.test.mjs +119 -2
  111. package/test/reaction.test.ts +4 -0
  112. package/test/relay.test.ts +5 -7
  113. package/test/repair-policy.test.ts +1 -4
  114. package/test/schedule-cli.test.ts +14 -9
  115. package/test/scheduled-tasks.test.ts +29 -6
  116. package/test/scheduler-host.test.ts +5 -5
  117. package/test/scheduler-relay.test.ts +15 -9
  118. package/test/security.test.ts +6 -3
  119. package/test/software-status.test.ts +21 -3
  120. package/test/telegram-provisioning.test.ts +160 -0
  121. package/test/updates.test.mjs +93 -13
  122. package/test/workspace.test.ts +4 -1
  123. package/bin/ezenciel-agents-watch.mjs +0 -8
  124. package/compose.workforce-watch.yaml +0 -33
  125. package/docker/recovery.ts +0 -11
  126. package/docs/pagerduty.md +0 -42
  127. package/docs/workforce-watch.md +0 -101
  128. package/src/pagerduty.ts +0 -109
  129. package/src/reply-context.ts +0 -43
  130. package/src/update-attention.ts +0 -18
  131. package/src/workforce-watch-cli.ts +0 -14
  132. package/src/workforce-watch.ts +0 -155
  133. package/templates/agent/AGENTS.md +0 -5
  134. package/templates/agent-guidance.md +0 -17
  135. package/test/pagerduty.test.ts +0 -104
  136. package/test/update-attention.test.ts +0 -20
  137. package/test/workforce-watch.test.ts +0 -180
@@ -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 plugin snippets and skills: `'+path.join(home,'bin','ez')+' tools list --details`. Use this bound launcher for plugin commands; read the relevant skill when needed.\n'+end;
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 || realpathSync(path.join(deployment, 'mind')) !== 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,13 +272,19 @@ 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 || realpathSync(path.join(deployment, 'mind')) !== 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;
258
279
  if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
259
280
  throw Error('Invalid host network bindings');
281
+ if (process.env.EZ_DOCKER_COMPOSE === 'standalone') {
282
+ if (configured && Object.keys(configured).length) throw Error('Isolated broker cannot use host network bindings');
283
+ return [];
284
+ }
285
+ if (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
286
+ realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir))
287
+ throw Error('Invalid host network binding deployment');
260
288
  const route = configured?.[record.manifest?.id];
261
289
  if (route === undefined) return [];
262
290
  keys(route, ['revisions', 'bindings']);
@@ -312,14 +340,24 @@ export async function compose(config, record, secrets={}, home) {
312
340
  return [key,(value.prefix||'')+secrets[value.secret]+(value.suffix||'')];
313
341
  }))}:{}),...(s.command?{command:s.command}:{})};
314
342
  }
315
- return attachShared({name:record.project,services,volumes,...(Object.keys(networks).length?{networks}:{})}, record);
343
+ // Keep the persisted descriptor portable between the host supervisor's
344
+ // Compose v2 and the isolated broker's bundled Compose v1. Project identity
345
+ // is always supplied with --project-name, so a top-level name is unnecessary.
346
+ return attachShared({version:'3.8',services,volumes,...(Object.keys(networks).length?{networks}:{})}, record);
347
+ }
348
+ async function refreshRecordCompose(home, config, record) {
349
+ await checkFolders(config, record, home);
350
+ const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(error=>{if(error.code==='ENOENT')return {};throw error;});
351
+ await atomic(record.compose, await compose(config, record, secrets, home));
316
352
  }
317
353
  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]]));
354
+ 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
355
  }
356
+ const dockerInvocation = argv => process.env.EZ_DOCKER_COMPOSE === 'standalone' && argv[0] === 'compose'
357
+ ? {command:'docker-compose',args:argv.slice(1)} : {command:'docker',args:argv};
320
358
  export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,timeoutMs=0,maxBytes=Infinity}={}) {
321
359
  return new Promise((resolve,reject)=>{
322
- const child=spawn('docker',argv,{env:dockerEnv(),stdio:capture?['pipe','pipe','pipe']:['inherit','inherit','inherit']});
360
+ const invocation=dockerInvocation(argv),child=spawn(invocation.command,invocation.args,{env:dockerEnv(),stdio:capture?['pipe','pipe','pipe']:['inherit','inherit','inherit']});
323
361
  let stdout='',stderr='',cancelled=false,killTimer,bytes=0,failure;
324
362
  if(capture) {
325
363
  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 +406,53 @@ async function checked(args) {
368
406
  const r=await run(args,{capture:true});if(r.code) throw Error(r.stderr||r.stdout||`Docker failed (${r.code})`);return r.stdout;
369
407
  }
370
408
  const composeArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
409
+ // Application context is opaque domain data. A command container receives only
410
+ // the object addressed to its installed plugin ID, never core state or another
411
+ // plugin's context. The current run and binding are re-authorized before it is
412
+ // forwarded to Docker.
413
+ const ownerKey = owner => owner?.id || (Number.isSafeInteger(owner?.telegramUserId) && Number.isSafeInteger(owner?.telegramChatId)
414
+ ? `telegram:${owner.telegramUserId}:${owner.telegramChatId}` : undefined);
415
+ const ownerEpoch = owner => owner?.generation || owner?.pairedAt;
416
+ const readJson = async file => JSON.parse(await fs.readFile(file,'utf8'));
417
+ // Run records live in relay memory; this manager stays plain Node, so it asks
418
+ // the relay through the shared delivery socket client. A relay that is down
419
+ // reads like a missing run: no context is forwarded.
420
+ const readRun = async (socketPath,runId) => callDeliverySocket(socketPath,{op:'get',payload:{runId}}).catch(error=>{
421
+ if(/Unknown run/.test(error.message)||/Delivery relay unavailable/.test(error.message))return undefined;
422
+ throw error;
423
+ });
424
+ // This manager is intentionally runnable as plain Node. Read only the current
425
+ // run and existing application binding rather than importing the TypeScript
426
+ // relay, and fail closed on missing/revoked ownership.
427
+ const authorizedApplicationRun = async (controlDir,runId,socketPath) => {
428
+ if(!path.isAbsolute(controlDir)||!/^[A-Za-z0-9_-]+$/.test(runId))throw Error('Plugin context requires a bound active run');
429
+ const run=await readRun(socketPath||path.join(controlDir,'delivery.sock'),runId);
430
+ const application=run?.application ?? run?.telegramApplication;
431
+ if(!application?.context||run?.id!==runId||run.status!=='running'||typeof application.bindingId!=='string')return undefined;
432
+ const state=await readJson(path.join(controlDir,'control-state.json'));
433
+ const bindings=await readJson(path.join(controlDir,'application-bindings.json'));
434
+ const binding=Array.isArray(bindings)?bindings.find(candidate=>candidate?.bindingId===application.bindingId):undefined;
435
+ if(!binding?.owner||!state?.owner||ownerKey(binding.owner)!==ownerKey(state.owner)||ownerEpoch(binding.owner)!==ownerEpoch(state.owner)||
436
+ run.ownerId!==ownerKey(state.owner)||run.ownerEpoch!==ownerEpoch(state.owner))throw Error('Application authority revoked');
437
+ return application;
438
+ };
439
+ export async function applicationPluginContext(pluginId, environment=process.env) {
440
+ const rawDir=environment.EZ_CONTROL_DIR?.trim(),runId=environment.EZ_RUN_ID?.trim();
441
+ if(!rawDir||!runId)return undefined;
442
+ if(!path.isAbsolute(rawDir))throw Error('Plugin context requires a bound active run');
443
+ const controlDir=await fs.realpath(rawDir).catch(()=>undefined);
444
+ if(!controlDir)return undefined;
445
+ const application=await authorizedApplicationRun(controlDir,runId,environment.EZ_DELIVERY_SOCKET?.trim()||path.join(rawDir,'delivery.sock'));
446
+ const contexts=application?.context?.plugins;
447
+ if(!contexts||typeof contexts!=='object'||Array.isArray(contexts)||!Object.hasOwn(contexts,pluginId))return undefined;
448
+ const context=contexts[pluginId];
449
+ if(!context||typeof context!=='object'||Array.isArray(context))throw Error('Plugin application context must be an object');
450
+ 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);};
451
+ checkDepth(context);
452
+ const encoded=JSON.stringify(context);
453
+ if(Buffer.byteLength(encoded)>16*1024)throw Error('Plugin application context is too large');
454
+ return encoded;
455
+ }
371
456
  export async function registry(home) {
372
457
  const r=await json(path.join(home,'registry.json'));
373
458
  if(r.schemaVersion!==1 || r.owner!==home || !r.plugins || !r.commands) throw Error('Corrupt registry');
@@ -378,7 +463,7 @@ export async function registry(home) {
378
463
  return r;
379
464
  }
380
465
  // The lock protects admission and compose refresh, never a persistent connection.
381
- export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false}={}) {
466
+ export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false,environment=process.env}={}) {
382
467
  strings(args);
383
468
  return locked(home,async()=>{
384
469
  const config=await json(path.join(home,'config.json')),r=await registry(home);
@@ -389,8 +474,25 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
389
474
  const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
390
475
  await atomic(record.compose,await compose(config,record,secrets,home));
391
476
  const container=`${record.project}-call-${randomUUID()}`;
392
- const release=invocation?await invocationLease(home,container):undefined;
393
- return {container,plugin:record.manifest.id,revision:record.revision,argv:[...composeArgs(record),'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};
477
+ // The application context must reach the command container as an
478
+ // environment variable. A top-level `compose --env-file` only feeds
479
+ // variable interpolation in both Compose v1 and v2, so the container never
480
+ // receives it; a one-shot override file sets the service environment
481
+ // directly and keeps the value out of the process argument list.
482
+ const context=await applicationPluginContext(record.manifest.id,environment);
483
+ let contextFile;
484
+ if(context!==undefined){
485
+ await privateDir(path.join(home,'plugin-context'));
486
+ contextFile=path.join(home,'plugin-context',`${randomUUID()}.json`);
487
+ // Compose interpolates every file it loads, including this override, so
488
+ // an opaque `$` in the context must be escaped as `$$` to reach the
489
+ // container byte-for-byte.
490
+ const override={services:{[binding.service]:{environment:{EZ_PLUGIN_CONTEXT:context.replaceAll('$',()=> '$$')}}}};
491
+ await fs.writeFile(contextFile,JSON.stringify(override,null,2)+'\n',{mode:0o600,flag:'wx'});
492
+ }
493
+ const invocationRelease=invocation?await invocationLease(home,container):undefined;
494
+ const release=(invocationRelease||contextFile)?(async()=>{try{if(contextFile)await fs.rm(contextFile,{force:true});}finally{await invocationRelease?.();}}):undefined;
495
+ 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
496
  },{allowInvocations:true});
395
497
  }
396
498
  export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
@@ -556,7 +658,7 @@ export async function main(args) {
556
658
  await atomic(latest.compose, await compose(currentConfig, latest, secrets, home));
557
659
  // Persist the binding before recreating clients; start can recover an interrupted recreation.
558
660
  await atomic(path.join(home, 'registry.json'), current);
559
- await checked([...composeArgs(latest), 'up', '-d', '--wait']);
661
+ await checked([...composeArgs(latest), 'up', '-d', ...(process.env.EZ_DOCKER_COMPOSE === 'standalone' ? [] : ['--wait'])]);
560
662
  return emit({ ok: true, plugin: name, shared: key, ...result });
561
663
  });
562
664
  }
@@ -570,18 +672,25 @@ export async function main(args) {
570
672
  return emit({ok:true,path:dest});
571
673
  }
572
674
  if(args.length)throw Error('Unknown lifecycle arguments');
573
- if(action==='logs') return emit({plugin:name,logs:await checked([...composeArgs(record),'logs','--tail','100','--no-color'])});
574
- if(action==='status') {const output=await checked([...composeArgs(record),'ps','--all','--format','json']);return emit({plugin:name,installedVersion:record.manifest.version,containers:output});}
675
+ if(action==='logs'||action==='status') return locked(home,async()=>{
676
+ const current=await registry(home),latest=current.plugins[name];
677
+ if(latest?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
678
+ const currentConfig=await json(path.join(home,'config.json'));
679
+ // A host-side Compose v2 operation may have rewritten this file with
680
+ // fields that the broker's bundled Compose v1 does not understand.
681
+ await refreshRecordCompose(home,currentConfig,latest);
682
+ if(action==='logs')return emit({plugin:name,logs:await checked([...composeArgs(latest),'logs','--tail','100','--no-color'])});
683
+ const ps = process.env.EZ_DOCKER_COMPOSE === 'standalone' ? ['ps', '--all'] : ['ps', '--all', '--format', 'json'];
684
+ const output=await checked([...composeArgs(latest),...ps]);
685
+ return emit({plugin:name,installedVersion:latest.manifest.version,containers:output});
686
+ });
575
687
  if(!['start','stop','uninstall'].includes(action))throw Error('Unknown lifecycle command');
576
688
  return locked(home,async()=>{
577
- const current=await registry(home);if(current.plugins[name]?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
578
- if(action==='start') {
579
- const currentConfig=await json(path.join(home,'config.json'));
580
- await checkFolders(currentConfig,current.plugins[name],home);
581
- const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
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'])]);
689
+ const current=await registry(home),latest=current.plugins[name];
690
+ if(latest?.revision!==record.revision)throw Error('Plugin changed during lifecycle request');
691
+ const currentConfig=await json(path.join(home,'config.json'));
692
+ await refreshRecordCompose(home,currentConfig,latest);
693
+ await checked([...composeArgs(latest),...(action==='start'?['up','-d',...(process.env.EZ_DOCKER_COMPOSE === 'standalone'?[]:['--wait'])]:action==='stop'?['stop']:['down'])]);
585
694
  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
695
  emit({ok:true,plugin:name,action,dataPreserved:true});
587
696
  });
@@ -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 { RunStore } from './runs.js'
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 store = new RunStore(loadControlConfig().controlDir)
32
- const item = await store.enqueueReaction(runId, emoji)
33
- console.log(JSON.stringify({ ok: true, run: runId, outbox_id: item.id, emoji: item.emoji }))
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 }))