@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.
- package/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +67 -0
- package/Dockerfile +7 -9
- package/README.md +10 -13
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +40 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +79 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/application-channel.ts +78 -7
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/config.ts +12 -27
- package/src/control-state.ts +175 -8
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +20 -23
- package/src/files.ts +38 -11
- package/src/host-executor.ts +28 -14
- package/src/inbox.ts +15 -49
- package/src/index.ts +95 -52
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +7 -2
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +132 -23
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +36 -0
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +2 -2
- package/test/config.test.ts +38 -43
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +10 -11
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +74 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +119 -2
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +93 -13
- package/test/workspace.test.ts +4 -1
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -43
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -5
- package/templates/agent-guidance.md +0 -17
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
package/src/install-tools.mjs
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
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
|
|
77
|
-
const
|
|
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'
|
|
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
|
|
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,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)
|
package/src/message-history.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
30
|
-
//
|
|
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
|
|
36
|
-
const
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
} else if(args.voice)
|
|
72
|
-
else if(textContent)
|
|
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
|
-
|
|
83
|
+
Object.assign(envelope,{kind:'document',documentPath:args.document,text:textContent})
|
|
76
84
|
} else if (args.voice) {
|
|
77
|
-
|
|
85
|
+
Object.assign(envelope,{kind:'voice',voiceText:args.voice})
|
|
78
86
|
} else if (textContent) {
|
|
79
|
-
|
|
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
|
|
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) {
|