@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.
Files changed (142) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +96 -0
  5. package/Dockerfile +20 -11
  6. package/README.md +10 -13
  7. package/THIRD_PARTY_NOTICES.md +7 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +41 -4
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +28 -6
  17. package/docs/application-channel.md +32 -10
  18. package/docs/architecture/ai-selection.md +10 -2
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +98 -11
  22. package/docs/local-qa.md +2 -2
  23. package/docs/plugin-guide.md +202 -0
  24. package/docs/plugins.md +48 -2
  25. package/docs/releasing.md +13 -0
  26. package/docs/responsive-channels.md +1 -1
  27. package/docs/scheduling.md +4 -4
  28. package/docs/setup.md +11 -5
  29. package/docs/upgrades.md +46 -23
  30. package/package.json +5 -5
  31. package/scripts/smoke-scheduler.ts +10 -3
  32. package/scripts/smoke.ts +14 -11
  33. package/src/agent-guidance.ts +6 -9
  34. package/src/agent-install.ts +28 -7
  35. package/src/ai-cli.ts +1 -1
  36. package/src/ai.ts +131 -5
  37. package/src/application-channel.ts +78 -7
  38. package/src/application-cli.ts +2 -2
  39. package/src/approval-cli.ts +14 -7
  40. package/src/approval.ts +27 -37
  41. package/src/channel-backend.ts +2 -3
  42. package/src/config.ts +12 -27
  43. package/src/control-state.ts +175 -8
  44. package/src/conversation-menu.ts +6 -17
  45. package/src/delivery-socket-client.d.mts +4 -0
  46. package/src/delivery-socket-client.mjs +104 -0
  47. package/src/delivery-socket.ts +354 -0
  48. package/src/execution-authority.ts +21 -5
  49. package/src/executor.ts +81 -24
  50. package/src/files.ts +38 -11
  51. package/src/host-executor.ts +33 -17
  52. package/src/inbox.ts +15 -49
  53. package/src/index.ts +98 -54
  54. package/src/install-tools.mjs +97 -22
  55. package/src/ledger-endpoint.d.mts +2 -0
  56. package/src/ledger-endpoint.mjs +17 -0
  57. package/src/menu.ts +8 -3
  58. package/src/message-history.ts +13 -18
  59. package/src/message.ts +35 -18
  60. package/src/plugin-broker.mjs +396 -0
  61. package/src/plugins/connection.mjs +2 -2
  62. package/src/plugins/manager.mjs +131 -23
  63. package/src/plugins/native-tasks.mjs +2 -2
  64. package/src/prompt-suffix.ts +12 -0
  65. package/src/react.ts +6 -4
  66. package/src/runs.ts +156 -243
  67. package/src/schedule-cli.ts +29 -10
  68. package/src/scheduled-tasks.ts +53 -10
  69. package/src/scheduler.ts +20 -7
  70. package/src/service.ts +3 -3
  71. package/src/setup.ts +1 -0
  72. package/src/software-status.ts +36 -4
  73. package/src/task-executor.ts +2 -2
  74. package/src/task-workspace.ts +4 -3
  75. package/src/tasks.ts +1 -1
  76. package/src/telegram-provisioning-cli.ts +22 -0
  77. package/src/telegram-provisioning.ts +214 -0
  78. package/src/updates/control.mjs +77 -7
  79. package/src/updates/runtime.mjs +28 -8
  80. package/src/updates/status.mjs +25 -3
  81. package/src/updates/supervisor.mjs +15 -16
  82. package/src/version.ts +20 -0
  83. package/src/workspace.ts +3 -9
  84. package/templates/failure-review.md +1 -1
  85. package/templates/maintainer-purpose.md +1 -1
  86. package/test/agent-guidance.test.ts +14 -80
  87. package/test/agent-install.test.ts +14 -0
  88. package/test/ai.test.ts +220 -1
  89. package/test/application-channel.test.ts +61 -13
  90. package/test/application-only.test.ts +14 -13
  91. package/test/channel-backend.test.ts +3 -7
  92. package/test/channel-delivery.test.ts +4 -1
  93. package/test/channel-owner.test.ts +6 -3
  94. package/test/codex-context.test.ts +2 -2
  95. package/test/config.test.ts +38 -43
  96. package/test/control-state.test.ts +83 -4
  97. package/test/conversation-menu.test.ts +2 -4
  98. package/test/delivery-transport.test.ts +72 -0
  99. package/test/docker-runtime.test.ts +27 -14
  100. package/test/engine-handoff.test.ts +7 -6
  101. package/test/event-sources.test.ts +2 -2
  102. package/test/execution-authority.test.ts +9 -9
  103. package/test/executor.test.ts +120 -12
  104. package/test/failure.test.ts +12 -5
  105. package/test/files.test.ts +2 -2
  106. package/test/helpers/ledger.ts +18 -0
  107. package/test/host-executor.test.ts +87 -3
  108. package/test/inbox.test.ts +8 -10
  109. package/test/install-config.test.ts +1 -0
  110. package/test/install-tools.test.mjs +74 -7
  111. package/test/intake-relay.test.ts +58 -11
  112. package/test/message-history.test.ts +39 -47
  113. package/test/native-tasks.test.ts +1 -1
  114. package/test/plugin-broker.test.mjs +172 -0
  115. package/test/plugin-manager.test.mjs +136 -2
  116. package/test/reaction.test.ts +4 -0
  117. package/test/relay.test.ts +5 -7
  118. package/test/repair-policy.test.ts +1 -4
  119. package/test/schedule-cli.test.ts +14 -9
  120. package/test/scheduled-tasks.test.ts +29 -6
  121. package/test/scheduler-host.test.ts +5 -5
  122. package/test/scheduler-relay.test.ts +15 -9
  123. package/test/security.test.ts +6 -3
  124. package/test/software-status.test.ts +21 -3
  125. package/test/telegram-provisioning.test.ts +160 -0
  126. package/test/updates.test.mjs +93 -13
  127. package/test/workspace.test.ts +4 -1
  128. package/bin/ezenciel-agents-watch.mjs +0 -8
  129. package/compose.workforce-watch.yaml +0 -33
  130. package/docker/recovery.ts +0 -11
  131. package/docs/pagerduty.md +0 -42
  132. package/docs/workforce-watch.md +0 -101
  133. package/src/pagerduty.ts +0 -109
  134. package/src/reply-context.ts +0 -43
  135. package/src/update-attention.ts +0 -18
  136. package/src/workforce-watch-cli.ts +0 -14
  137. package/src/workforce-watch.ts +0 -155
  138. package/templates/agent/AGENTS.md +0 -5
  139. package/templates/agent-guidance.md +0 -17
  140. package/test/pagerduty.test.ts +0 -104
  141. package/test/update-attention.test.ts +0 -20
  142. 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,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 || 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;
@@ -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
- return attachShared({name:record.project,services,volumes,...(Object.keys(networks).length?{networks}:{})}, record);
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('docker',argv,{env:dockerEnv(),stdio:capture?['pipe','pipe','pipe']:['inherit','inherit','inherit']});
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
- 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};
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 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});}
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);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'])]);
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 { 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 }))