@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
@@ -4,6 +4,10 @@ import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
+ import { createServer } from 'node:net';
8
+ import { parseEnv } from 'node:util';
9
+ import { callDeliverySocket } from './delivery-socket-client.mjs';
10
+ import { ledgerOverlayName, ledgerOverlayYaml } from './ledger-endpoint.mjs';
7
11
 
8
12
  const root=fileURLToPath(new URL('../',import.meta.url));
9
13
  const read=async file=>JSON.parse(await fs.readFile(file,'utf8'));
@@ -52,49 +56,120 @@ export async function installationStatus(deployment) {
52
56
  let isolated=false;
53
57
  try { isolated=(await read(path.join(deployment,'agent.json'))).isolation==='isolated'; } catch { isolated=false; }
54
58
  const runtimeReady=Boolean(relay?.polling&&fresh(relay,20000)&&(isolated||fresh(host,15000)));
59
+ // Delivery receipts live in relay memory and are re-authorized server-side;
60
+ // ask the running relay. A restart drops receipts by design.
55
61
  let reply=null;
56
- if(paired)for(const name of await fs.readdir(path.join(control,'outbox')).catch(e=>{if(e.code==='ENOENT')return [];throw e;})) {
57
- if(!name.endsWith('.sent.json'))continue;
58
- const item=await read(path.join(control,'outbox',name));
59
- if(!/^tg_\d+$/.test(item.runId||'')||item.chatId!==owner.telegramChatId||(item.type&&item.type!=='message')||!Array.isArray(item.receipt?.messageIds)||!item.receipt.messageIds.length||!item.receipt.messageIds.every(n=>Number.isSafeInteger(n)&&n>0))continue;
60
- const delivered=Date.parse(item.receipt.deliveredAt);if(!Number.isFinite(delivered)||delivered<Date.parse(owner.pairedAt)||delivered>Date.now())continue;
61
- const r=await read(path.join(control,'runs',item.runId+'.json')).catch(absent);
62
- if(r?.status==='completed'&&!r.external&&r.chatId===owner.telegramChatId&&Number.isSafeInteger(r.telegramUserId)&&r.telegramUserId>0&&(owner.kind==='group'||r.telegramUserId===owner.telegramUserId)&&(!reply||delivered>Date.parse(reply.deliveredAt)))reply={runId:item.runId,messageIds:item.receipt.messageIds,deliveredAt:item.receipt.deliveredAt};
63
- }
62
+ if(paired)reply=(await callDeliverySocket(path.join(control,'delivery.sock'),{op:'latestReply'},2000).catch(()=>null))?.reply??null;
64
63
  return {deployment,configured,runtimeReady,ownerPaired:paired,telegramReplyVerified:Boolean(reply),reply,
65
64
  stage:!configured?'not-configured':!runtimeReady?'runtime-offline':!paired?'awaiting-owner':!reply?'awaiting-telegram-reply':'ready-for-telegram-plugin-request',
66
- note:'Read-only: a saved receipt is historical delivery evidence, not a fresh live probe or proof of reboot persistence. Request plugins through the working Telegram conversation.'};
65
+ note:'Read-only: a live receipt from the running relay is delivery evidence; it is not retained across a relay restart. Request plugins through the working Telegram conversation.'};
66
+ }
67
+ // Existing host-capable deployments created before the loopback endpoint need
68
+ // one idempotent migration: a published loopback port in docker.env plus the
69
+ // generated overlay that maps it. New agents receive both at create time.
70
+ const envLine = (text, key, value) => {
71
+ if (/['\r\n\0]/.test(value)) throw Error('Unsafe deployment value');
72
+ const line = `${key}='${value}'`;
73
+ const pattern = new RegExp(`^${key}=.*$`, 'm');
74
+ return pattern.test(text) ? text.replace(pattern, () => line) : text.replace(/\n*$/, '\n') + line + '\n';
75
+ }
76
+
77
+ const freeLoopbackPort = async (start = 20000) => {
78
+ for (let port = start; port < start + 500 && port <= 65535; port++) {
79
+ const free = await new Promise(resolve => {
80
+ const probe = createServer();
81
+ probe.once('error', () => resolve(false));
82
+ probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)));
83
+ });
84
+ if (free) return port;
85
+ }
86
+ throw Error('No free loopback port in the deployment range');
87
+ };
88
+
89
+ const ledgerPortStart = name => 20000 + createHash('sha256').update(name).digest().readUInt16BE(0) % 10000;
90
+
91
+ export async function migrateLedger({ deployment } = {}) {
92
+ if (!path.isAbsolute(deployment || '')) throw Error('Supply --deployment with the absolute deployment directory');
93
+ const directory = path.resolve(deployment), envFile = path.join(directory, 'docker.env');
94
+ const text = await fs.readFile(envFile, 'utf8').catch(error => { if (error.code === 'ENOENT') throw Error('Deployment docker.env not found'); throw error; });
95
+ const values = parseEnv(text);
96
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(values.COMPOSE_PROJECT_NAME || '')) throw Error('Invalid deployment COMPOSE_PROJECT_NAME');
97
+ if (values.EZ_ISOLATION === 'isolated' || values.EZ_EXECUTOR_TRANSPORT === 'local')
98
+ return { ok: true, deployment: directory, changed: false, note: 'Isolated deployment; no host ledger endpoint is needed.' };
99
+ if (!values.COMPOSE_FILE) throw Error('Deployment COMPOSE_FILE not found');
100
+ const overlay = path.join(directory, ledgerOverlayName);
101
+ const files = values.COMPOSE_FILE.split(path.delimiter).filter(Boolean);
102
+ const configured = values.EZ_DELIVERY_TCP_PORT !== undefined && files.includes(overlay) &&
103
+ await fs.stat(overlay).then(info => info.isFile(), () => false);
104
+ if (configured) return { ok: true, deployment: directory, changed: false, port: Number(values.EZ_DELIVERY_TCP_PORT) };
105
+ const port = values.EZ_DELIVERY_TCP_PORT !== undefined ? Number(values.EZ_DELIVERY_TCP_PORT) : await freeLoopbackPort(ledgerPortStart(values.COMPOSE_PROJECT_NAME));
106
+ if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw Error('Invalid EZ_DELIVERY_TCP_PORT');
107
+ const next = envLine(envLine(text, 'EZ_DELIVERY_TCP_PORT', String(port)), 'COMPOSE_FILE', [...new Set([...files, overlay])].join(path.delimiter));
108
+ // Publish the overlay before docker.env references it: a crash in between
109
+ // leaves an unreferenced file instead of a deployment Compose cannot start.
110
+ await fs.writeFile(overlay, ledgerOverlayYaml(), { mode: 0o600 });
111
+ const temporary = `${envFile}.${process.pid}.tmp`;
112
+ await fs.writeFile(temporary, next, { mode: 0o600 });
113
+ await fs.rename(temporary, envFile);
114
+ return { ok: true, deployment: directory, changed: true, port, overlay };
67
115
  }
116
+
68
117
  async function fingerprintOf(source,invoke) {
69
118
  const listing=JSON.parse(await invoke('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:source}));
70
119
  const hash=createHash('sha256');
71
120
  for(const f of listing[0].files){const file=path.resolve(source,f.path);if(!file.startsWith(source+path.sep))throw Error('Invalid package path');const data=await fs.readFile(file);hash.update(JSON.stringify([f.path,f.mode,data.length]));hash.update(data);}
72
121
  return hash.digest('hex');
73
122
  }
74
- export async function build({home=defaultHome(),source=root},invoke=run) {
123
+ // Source-checkout builds are RCs. The reviewed commit is the only SHA that may
124
+ // label an installable image, so a dirty tree can never become one and /status
125
+ // never falls back to the bare package version on a deployment.
126
+ export const rcLabel = /^\d+\.\d+\.\d+-beta\.\d+\.rc\.[1-9]\d*$/;
127
+ async function buildIdentity(source,label,invoke) {
128
+ const git=async args=>{try{return await invoke('git',['-C',source,...args]);}catch{return undefined;}};
129
+ // The package root itself must be the checkout root; a tarball unpacked
130
+ // inside an unrelated repository is not a source checkout.
131
+ const top=await git(['rev-parse','--show-toplevel']);
132
+ const atRoot=Boolean(top)&&await fs.realpath(top).then(value=>value===source,()=>false);
133
+ if(!atRoot) {
134
+ if(label!==undefined)throw Error('--label requires a git checkout so the RC pins the reviewed commit');
135
+ return {};
136
+ }
137
+ if(label===undefined)throw Error('A source checkout requires --label X.Y.Z-beta.N.rc.M; commit, push and open the PR first');
138
+ if(!rcLabel.test(label))throw Error('RC label must look like X.Y.Z-beta.N.rc.M');
139
+ if((await git(['status','--porcelain']))!=='')throw Error('Commit the reviewed source before an RC build; uncommitted work cannot be installed as an RC');
140
+ const sha=await git(['rev-parse','HEAD']);
141
+ if(!/^[a-f0-9]{40}$/.test(sha||''))throw Error('RC builds require the reviewed commit SHA');
142
+ return {label,sha};
143
+ }
144
+ export async function build({home=defaultHome(),source=root,label},invoke=run) {
75
145
  source=await fs.realpath(source);
76
- const fingerprint=await fingerprintOf(source,invoke),image='ezenciel-agents:install-'+fingerprint.slice(0,24);
77
- const dir=path.join(path.resolve(home),'builds',fingerprint);await fs.mkdir(dir,{recursive:true,mode:0o700});
146
+ const identity=await buildIdentity(source,label,invoke);
147
+ const fingerprint=await fingerprintOf(source,invoke);
148
+ const artifact=createHash('sha256').update(JSON.stringify([fingerprint,identity.label??'',identity.sha??''])).digest('hex');
149
+ const image='ezenciel-agents:install-'+artifact.slice(0,24);
150
+ const dir=path.join(path.resolve(home),'builds',artifact);await fs.mkdir(dir,{recursive:true,mode:0o700});
78
151
  const lock=path.join(dir,'lock'),receipt=path.join(dir,'status.json'),log=path.join(dir,'build.log');
79
152
  let handle;
80
153
  try{handle=await fs.open(lock,'wx',0o600);}catch(e){if(e.code!=='EEXIST')throw e;return {state:'busy-or-interrupted',image,log,note:'An existing build owns this artifact. Inspect its status/log and owning process; do not start another build or remove a live lock.'};}
81
154
  try {
82
- await handle.writeFile(JSON.stringify({pid:process.pid,source,image}));
155
+ await handle.writeFile(JSON.stringify({pid:process.pid,source,image,...identity}));
83
156
  const prior=await read(receipt).catch(absent);
84
157
  if(prior?.state==='completed')try{if(await invoke('docker',['image','inspect','--format','{{.Id}}',image])===prior.imageId)return {...prior,reused:true};}catch{/* Image was removed; rebuild under the same exclusive lock. */}
85
- await atomic(receipt,{state:'building',pid:process.pid,source,image,log});
86
- console.error(JSON.stringify({state:'building',image,log}));
158
+ await atomic(receipt,{state:'building',pid:process.pid,source,image,log,...identity});
159
+ console.error(JSON.stringify({state:'building',image,log,...identity}));
160
+ const buildArgs=identity.label?['--build-arg',`BUILD_TAG=${identity.label}`,'--build-arg',`BUILD_SHA=${identity.sha}`]:[];
161
+ const tags=[image,...(identity.label?[`ezenciel-agents:${identity.label}`]:[])];
87
162
  const output=await fs.open(log,'a',0o600);
88
- try{await invoke('docker',['build','--progress','plain','--target','runtime','-t',image,source],{log:output.fd,timeout:3600000});}finally{await output.close();}
163
+ try{await invoke('docker',['build','--progress','plain','--target','runtime',...buildArgs,...tags.flatMap(t=>['-t',t]),source],{log:output.fd,timeout:3600000});}finally{await output.close();}
89
164
  if(await fingerprintOf(source,invoke)!==fingerprint)throw Error('Package changed during build; keep the source stable and retry');
90
- const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log};await atomic(receipt,result);return result;
91
- }catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message});throw e;}
165
+ const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log,...identity};await atomic(receipt,result);return result;
166
+ }catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message,...identity});throw e;}
92
167
  finally{await handle.close();await fs.rm(lock);}
93
168
  }
94
169
  export async function main(args) {
95
170
  const [action,...rest]=args;const options={};
96
- if(!action||action==='--help'){console.log(JSON.stringify({commands:['preflight --executor <name-or-absolute-path> [--home PATH]','build [--home PATH]','status --deployment PATH'],note:'Host prerequisites and main-agent diagnostics only. Initialize an empty registry; verify Telegram before asking the installed agent to add plugins.'}));return;}
97
- for(let i=0;i<rest.length;i+=2){if(!['--home','--executor','--deployment'].includes(rest[i])||!rest[i+1]||Object.hasOwn(options,rest[i].slice(2)))throw Error('Invalid arguments');options[rest[i].slice(2)]=rest[i+1];}
98
- const allowed={preflight:['home','executor'],build:['home'],status:['deployment']};if(!allowed[action]||Object.keys(options).some(k=>!allowed[action].includes(k)))throw Error('Invalid action/options');
99
- const result=action==='preflight'?await preflight(options):action==='build'?await build(options):await installationStatus(options.deployment);console.log(JSON.stringify(result));if(result.ok===false)process.exitCode=1;
171
+ if(!action||action==='--help'){console.log(JSON.stringify({commands:['preflight --executor <name-or-absolute-path> [--home PATH]','build [--home PATH] [--label X.Y.Z-beta.N.rc.M]','migrate-ledger --deployment PATH','status --deployment PATH'],note:'Source checkouts build RCs only: commit and open the PR first, then pass the next --label. migrate-ledger adds the loopback host endpoint to an existing host-capable deployment. Host prerequisites and main-agent diagnostics only; initialize an empty registry and verify Telegram before asking the installed agent to add plugins.'}));return;}
172
+ for(let i=0;i<rest.length;i+=2){if(!['--home','--executor','--deployment','--label'].includes(rest[i])||!rest[i+1]||Object.hasOwn(options,rest[i].slice(2)))throw Error('Invalid arguments');options[rest[i].slice(2)]=rest[i+1];}
173
+ const allowed={preflight:['home','executor'],build:['home','label'],'migrate-ledger':['deployment'],status:['deployment']};if(!allowed[action]||Object.keys(options).some(k=>!allowed[action].includes(k)))throw Error('Invalid action/options');
174
+ const result=action==='preflight'?await preflight(options):action==='build'?await build(options):action==='migrate-ledger'?await migrateLedger(options):await installationStatus(options.deployment);console.log(JSON.stringify(result));if(result.ok===false)process.exitCode=1;
100
175
  }
@@ -0,0 +1,2 @@
1
+ export declare const ledgerOverlayName: string
2
+ export declare const ledgerOverlayYaml: () => string
@@ -0,0 +1,17 @@
1
+ // One source for the host-capable ledger overlay: agent creation, the
2
+ // deployment migration and its tests must emit the same compose fragment.
3
+ export const ledgerOverlayName = 'ledger.compose.yaml'
4
+
5
+ export const ledgerOverlayYaml = () => [
6
+ '# Host-capable execution: on Docker Desktop/OrbStack the control-volume',
7
+ '# Unix socket is not connectable from the host, so the relay serves its',
8
+ '# memory ledger on this published loopback port and requests authenticate',
9
+ '# with the per-run token in the control directory.',
10
+ 'services:',
11
+ ' relay:',
12
+ ' environment:',
13
+ ' EZ_DELIVERY_TCP_PORT: ${EZ_DELIVERY_TCP_PORT:?}',
14
+ ' ports:',
15
+ ' - "127.0.0.1:${EZ_DELIVERY_TCP_PORT:?}:${EZ_DELIVERY_TCP_PORT:?}"',
16
+ '',
17
+ ].join('\n')
package/src/menu.ts CHANGED
@@ -13,12 +13,13 @@ export const mainCommands = [
13
13
  { command: 'rename', description: 'Rename current conversation' },
14
14
  { command: 'ai', description: 'Choose AI' },
15
15
  { command: 'status', description: 'Work status' },
16
+ { command: 'tasks', description: 'Scheduled tasks' },
16
17
  ]
17
18
 
18
19
  export const mainKeyboard = () => new InlineKeyboard()
19
20
  .text('New conversation', 'menu:new').text('Conversations', 'menu:chats').row()
20
- .text('Choose AI', 'menu:ai')
21
- .text('Work status', 'menu:status')
21
+ .text('Choose AI', 'menu:ai').row()
22
+ .text('Work status', 'menu:status').text('Scheduled tasks', 'menu:scheduled-tasks')
22
23
 
23
24
  const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
24
25
 
@@ -33,6 +34,10 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
33
34
  const initial = chatPreset(cli)
34
35
  const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
35
36
  if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
37
+ // Local execution runs Codex with CODEX_HOME set to the agent-bound control
38
+ // directory, but the default catalog probed the process home instead. Bind it
39
+ // to the same agent-bound home so /ai lists the models Codex can actually run.
40
+ else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome)
36
41
  const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome }))
37
42
  const validate = async (preset: AiPreset) => {
38
43
  assertEffort(preset.effort, preset.model, preset.cli)
@@ -1,11 +1,12 @@
1
- import { readFile, readdir } from 'node:fs/promises'
2
- import { join, basename } from 'node:path'
1
+ import { basename } from 'node:path'
3
2
  import { requireOwnerExecution } from './execution-authority.js'
4
3
  import { ControlStore } from './control-state.js'
5
4
  import { ownsRun } from './identity.js'
6
- import { RunStore } from './runs.js'
5
+ import { RunStore, sentOutbox } from './runs.js'
7
6
 
8
- // Read existing delivery receipts; native sessions still own conversation history.
7
+ // Read existing delivery receipts from the relay memory ledger; native
8
+ // sessions still own conversation history. Explicit tool path only (message
9
+ // history CLI / delivery socket history op), never ambient prompt enrichment.
9
10
  export async function deliveredMessages(controlDir: string, runId: string, options: { limit?: number; messageId?: number } = {}) {
10
11
  const limit = options.limit ?? 8
11
12
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error('Limit must be 1..50')
@@ -20,26 +21,20 @@ export async function deliveredMessages(controlDir: string, runId: string, optio
20
21
  ownsRun(owner, run) && !run.external && !run.taskId && !run.application &&
21
22
  Date.parse(run.createdAt) >= pairedAt && (!run.scheduled || run.scheduled.pairedAt === owner!.pairedAt)
22
23
  ).map(run => [run.id, run]))
23
- const directory = join(controlDir, 'outbox')
24
- const files = await readdir(directory).catch((error: NodeJS.ErrnoException) => {
25
- if (error.code === 'ENOENT') return []
26
- throw error
27
- })
28
24
  const messages = []
29
- for (const file of files.filter(file => file.endsWith('.sent.json'))) {
30
- // Corrupt or incomplete receipts are not delivery evidence.
31
- let item
32
- try { item = JSON.parse(await readFile(join(directory, file), 'utf8')) } catch { continue }
25
+ for (const item of sentOutbox(controlDir)) {
26
+ // Incomplete receipts are not delivery evidence.
33
27
  if (!item || typeof item !== 'object') continue
34
- const run = runs.get(item.runId)
35
- const deliveredAt = Date.parse(item.receipt?.deliveredAt)
36
- const ids = item.receipt?.messageIds
28
+ const run = item.runId === undefined ? undefined : runs.get(item.runId)
29
+ const receipt = (item.receipt ?? {}) as { deliveredAt?: unknown; messageIds?: unknown }
30
+ const deliveredAt = Date.parse(receipt.deliveredAt as string)
31
+ const ids = receipt.messageIds
37
32
  if (!run || item.chatId !== caller.chatId || !Number.isFinite(deliveredAt) || deliveredAt < pairedAt ||
38
33
  !Array.isArray(ids) || !ids.length || !ids.every(id => Number.isSafeInteger(id) && id > 0)) continue
39
- if (options.messageId !== undefined && !ids.includes(options.messageId)) continue
34
+ if (options.messageId !== undefined && !(ids as number[]).includes(options.messageId)) continue
40
35
  messages.push({
41
36
  runId: run.id, sessionId: run.execution?.sessionId, nativeSessionId: run.nativeSessionId,
42
- scheduleId: run.scheduled?.id, messageIds: ids as number[], deliveredAt: item.receipt.deliveredAt as string,
37
+ scheduleId: run.scheduled?.id, messageIds: ids as number[], deliveredAt: receipt.deliveredAt as string,
43
38
  type: item.type || 'message', text: typeof item.text === 'string' ? item.text
44
39
  : typeof item.approvalPrompt === 'string' ? item.approvalPrompt : undefined,
45
40
  voiceText: typeof item.voiceText === 'string' ? item.voiceText : undefined,
package/src/message.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { readFile,realpath } from 'node:fs/promises'
2
2
  import { loadControlConfig } from './config.js'
3
- import { parseMessageArgs, sendRunDocument, sendRunText, sendRunVoice } from './message-send.js'
4
- import { RunStore } from './runs.js'
3
+ import { parseMessageArgs } from './message-send.js'
5
4
  import { parseArgs } from 'node:util'
6
- import { deliveredMessages } from './message-history.js'
7
5
  import {authorizeDeliveryContext,currentDeliveryOwner} from './delivery-context.mjs'
8
6
  import {workspaceFile} from './files.js'
7
+ import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
9
8
  import path from 'node:path'
9
+ import { randomBytes } from 'node:crypto'
10
10
 
11
11
  const rawArgs = process.argv.slice(2)
12
12
  if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
@@ -19,11 +19,13 @@ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
19
19
  process.exit(0)
20
20
  }
21
21
 
22
+ const controlDir = loadControlConfig().controlDir
23
+ const socketPath = socketPathFor(controlDir)
22
24
  const runId = process.env.EZ_RUN_ID?.trim()
23
- const deliveryContext = !runId && process.env.EZ_DELIVERY_CONTEXT ? authorizeDeliveryContext(JSON.parse(process.env.EZ_DELIVERY_CONTEXT),await currentDeliveryOwner(loadControlConfig().controlDir)) : undefined
25
+ const deliveryContext = !runId && process.env.EZ_DELIVERY_CONTEXT ? authorizeDeliveryContext(JSON.parse(process.env.EZ_DELIVERY_CONTEXT),await currentDeliveryOwner(controlDir)) : undefined
24
26
  if(rawArgs[0]==='receipt') {
25
27
  if(!deliveryContext||rawArgs.length!==2)throw new Error('Receipt requires an authenticated delivery context and outbox ID')
26
- console.log(JSON.stringify(await new RunStore(loadControlConfig().controlDir).ownerDeliveryReceipt(deliveryContext,rawArgs[1]!)))
28
+ console.log(JSON.stringify(await callDeliverySocket(socketPath, { op: 'receipt', payload: { context: deliveryContext, id: rawArgs[1]! } })))
27
29
  process.exit(0)
28
30
  }
29
31
  if (rawArgs[0] === 'history') {
@@ -32,10 +34,11 @@ if (rawArgs[0] === 'history') {
32
34
  limit: { type: 'string' }, 'message-id': { type: 'string' },
33
35
  } })
34
36
  if (!runId) throw new Error('EZ_RUN_ID is required')
35
- const result = await deliveredMessages(loadControlConfig().controlDir, runId, {
37
+ const result = await callDeliverySocket(socketPath, { op: 'history', payload: {
38
+ runId,
36
39
  limit: values.limit === undefined ? undefined : Number(values.limit),
37
40
  messageId: values['message-id'] === undefined ? undefined : Number(values['message-id']),
38
- })
41
+ } })
39
42
  await new Promise<void>((resolve, reject) => process.stdout.write(JSON.stringify(result) + '\n', error => error ? reject(error) : resolve()))
40
43
  } catch (error) {
41
44
  console.error(error instanceof Error ? error.message : String(error))
@@ -49,8 +52,6 @@ if (!runId && !deliveryContext) {
49
52
  process.exit(1)
50
53
  }
51
54
 
52
- const store = new RunStore(loadControlConfig().controlDir)
53
-
54
55
  let textContent = args.text?.trim()
55
56
  if (args.textFile) {
56
57
  if(deliveryContext)throw new Error('Channel delivery requires inline text, not host file input')
@@ -62,21 +63,28 @@ if (args.textFile) {
62
63
  }
63
64
  }
64
65
 
65
- let item
66
+ // Delivery rides the relay socket (same envelope the relay pump delivers);
67
+ // the relay re-verifies ownership before sending. Client-generated IDs keep
68
+ // retried sends idempotent without a second delivery.
69
+ const envelope: Record<string, unknown> = {
70
+ ...(runId ? { runId } : {}),
71
+ ...(deliveryContext ? { kind: 'owner', deliveryContext } : {}),
72
+ ...(args.replyTo !== undefined ? { replyToMessageId: args.replyTo } : {}),
73
+ }
66
74
  if(deliveryContext) {
67
75
  if(args.document) {
68
76
  if(!process.env.EZ_AGENT_WORKSPACE)throw new Error('Channel delivery requires owning workspace')
69
77
  const documentPath=await workspaceFile(process.env.EZ_AGENT_WORKSPACE,args.document)
70
- item=await store.enqueueOwnerDelivery(deliveryContext,{type:'document',documentPath:path.relative(await realpath(process.env.EZ_AGENT_WORKSPACE),documentPath),text:textContent,replyToMessageId:args.replyTo})
71
- } else if(args.voice) item=await store.enqueueOwnerDelivery(deliveryContext,{type:'voice',voiceText:args.voice,replyToMessageId:args.replyTo})
72
- else if(textContent) item=await store.enqueueOwnerDelivery(deliveryContext,{type:'message',text:textContent,replyToMessageId:args.replyTo})
78
+ Object.assign(envelope,{type:'document',documentPath:path.relative(await realpath(process.env.EZ_AGENT_WORKSPACE),documentPath),text:textContent})
79
+ } else if(args.voice) Object.assign(envelope,{type:'voice',voiceText:args.voice})
80
+ else if(textContent) Object.assign(envelope,{type:'message',text:textContent})
73
81
  else throw new Error('Message content is required')
74
82
  } else if (args.document) {
75
- item = await sendRunDocument(store, runId!, args.document, textContent, { replyTo: args.replyTo })
83
+ Object.assign(envelope,{kind:'document',documentPath:args.document,text:textContent})
76
84
  } else if (args.voice) {
77
- item = await sendRunVoice(store, runId!, args.voice, { replyTo: args.replyTo })
85
+ Object.assign(envelope,{kind:'voice',voiceText:args.voice})
78
86
  } else if (textContent) {
79
- item = await sendRunText(store, runId!, textContent, { replyTo: args.replyTo })
87
+ Object.assign(envelope,{kind:'message',text:textContent,id:`${runId}_${Date.now().toString(36)}_${randomBytes(2).toString('hex')}`})
80
88
  } else {
81
89
  console.error(
82
90
  'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
@@ -84,9 +92,18 @@ if(deliveryContext) {
84
92
  process.exit(1)
85
93
  }
86
94
 
95
+ let item: { outbox_id: string; id: string; type?: string }
96
+ try {
97
+ item = await callDeliverySocket(socketPath, { op: 'enqueue', payload: envelope }) as typeof item
98
+ } catch (error) {
99
+ console.error(error instanceof Error ? error.message : String(error))
100
+ process.exit(1)
101
+ }
102
+
87
103
  try {
88
104
  if(deliveryContext)console.log(JSON.stringify({ok:true,status:'queued',outbox_id:item.id}))
89
- const receipt = await store.waitForDelivery(item.id,deliveryContext?20000:undefined)
105
+ const receipt = await callDeliverySocket(socketPath,
106
+ { op: 'wait', payload: { id: item.id } }, deliveryContext?30000:130000)
90
107
  console.log(
91
108
  JSON.stringify({
92
109
  ok: true,
@@ -95,7 +112,7 @@ try {
95
112
  ...(deliveryContext?{connection:deliveryContext.connectionId}:{}),
96
113
  outbox_id: item.id,
97
114
  type: item.type,
98
- receipt,
115
+ receipt: (receipt as { receipt?: unknown }).receipt ?? receipt,
99
116
  }),
100
117
  )
101
118
  } catch (error) {