@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/host-executor.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { installAgentGuidance } from './agent-guidance.js'
|
|
2
1
|
import { redactFailure } from './failure.js'
|
|
3
|
-
import { RunStore } from './runs.js'
|
|
4
2
|
import { Tasks } from './tasks.js'
|
|
5
|
-
import {
|
|
3
|
+
import { ControlStore } from './control-state.js'
|
|
4
|
+
import { authorizeRun, readRun } from './delivery-socket.js'
|
|
5
|
+
import { runPromptSuffix } from './prompt-suffix.js'
|
|
6
6
|
import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { isHostRunId } from './host-executor-protocol.js'
|
|
@@ -37,8 +37,14 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
37
37
|
const catalog = async (agent: HostBinding) => {
|
|
38
38
|
const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
|
|
39
39
|
const providers = (agent.codexProviders ?? []).map(validateCodexProvider)
|
|
40
|
-
const declared = providers.flatMap(provider => provider.models.map(model =>
|
|
41
|
-
|
|
40
|
+
const declared = providers.flatMap(provider => provider.models.map(model => {
|
|
41
|
+
const native = discovered.find(candidate => candidate.cli === 'codex' && !candidate.provider && candidate.model === model)
|
|
42
|
+
return {cli:'codex',provider:provider.id,model,name:`${provider.name} · ${model}`.slice(0,80),efforts:native?.efforts ?? []}
|
|
43
|
+
}))
|
|
44
|
+
const declaredModels = new Set(declared.map(model => model.model))
|
|
45
|
+
const isReplacedByProvider = (model: {cli:string; model?:string}) =>
|
|
46
|
+
model.model !== undefined && declaredModels.has(model.model) && model.cli === 'codex'
|
|
47
|
+
return [...discovered.filter(model => !isReplacedByProvider(model)), ...declared]
|
|
42
48
|
}
|
|
43
49
|
try {
|
|
44
50
|
for (const agent of installation.agents) {
|
|
@@ -71,8 +77,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
71
77
|
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
72
78
|
await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
|
|
73
79
|
locks.push(lock)
|
|
74
|
-
await
|
|
75
|
-
await
|
|
80
|
+
const models = await catalog(agent)
|
|
81
|
+
await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
|
|
82
|
+
const modelsFile=path.join(directory,'models.json')
|
|
83
|
+
await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
|
|
76
84
|
// A host crash is terminal for a claimed job. Never replay an action.
|
|
77
85
|
for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
|
|
78
86
|
const base=path.join(directory,file.slice(0,-13))
|
|
@@ -88,27 +96,32 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
88
96
|
if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
|
|
89
97
|
}
|
|
90
98
|
let catalogAt=Date.now()
|
|
99
|
+
const catalogSeen=new Map<HostBinding,string>()
|
|
91
100
|
while (!signal.aborted) {
|
|
92
101
|
const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
|
|
93
102
|
if(parent) { try { process.kill(parent,0) } catch { break } }
|
|
94
103
|
if(Date.now()-catalogAt>30000){
|
|
95
104
|
for(const agent of installation.agents){
|
|
96
|
-
const
|
|
105
|
+
const catalogModels = await catalog(agent)
|
|
106
|
+
const models=JSON.stringify(catalogModels)
|
|
107
|
+
if(catalogSeen.get(agent)===models)continue
|
|
108
|
+
await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(catalogModels)
|
|
97
109
|
const file=path.join(agent.controlDir,'host-executor/models.json')
|
|
98
110
|
await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
|
|
111
|
+
catalogSeen.set(agent,models)
|
|
99
112
|
}
|
|
100
113
|
catalogAt=Date.now()
|
|
101
114
|
}
|
|
102
115
|
for (const agent of installation.agents) {
|
|
103
116
|
const directory=path.join(agent.controlDir,'host-executor')
|
|
104
|
-
await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
|
|
117
|
+
await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,platform:process.platform,arch:process.arch,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
|
|
105
118
|
await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
|
|
106
119
|
for (const file of await readdir(directory)) {
|
|
107
120
|
if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
|
|
108
121
|
const id=file.slice(0,-13)
|
|
109
122
|
let run
|
|
110
123
|
try {
|
|
111
|
-
run = await
|
|
124
|
+
run = await readRun(agent.controlDir, id)
|
|
112
125
|
if(id.startsWith('r_schedule_') && !run?.scheduled) throw new Error('Missing scheduled run')
|
|
113
126
|
} catch {
|
|
114
127
|
await appendFile(path.join(directory,id+'.events'),JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
|
|
@@ -131,11 +144,11 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
131
144
|
try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
132
145
|
const request=JSON.parse(await readFile(base+'.running.json','utf8'))
|
|
133
146
|
if (!Array.isArray(request.texts) || request.texts.some((text:unknown)=>typeof text!=='string')) throw new Error('Invalid job')
|
|
134
|
-
const run = await
|
|
147
|
+
const run = await readRun(agent.controlDir, path.basename(base))
|
|
135
148
|
if (run?.taskId) {
|
|
136
149
|
if (run.status !== 'running') throw new Error('No active task run')
|
|
137
150
|
await new Tasks(agent.controlDir).authorize(run, false)
|
|
138
|
-
} else await
|
|
151
|
+
} else await authorizeRun(agent.controlDir, path.basename(base))
|
|
139
152
|
const opts=request.options as ExecutorOptions
|
|
140
153
|
const cli = opts.cli || installation.cli
|
|
141
154
|
resolveExecutor(cli)
|
|
@@ -149,9 +162,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
149
162
|
if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
|
|
150
163
|
throw new Error('Selected Codex provider is required for this model')
|
|
151
164
|
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},await catalog(agent))
|
|
152
|
-
const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.
|
|
165
|
+
const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.controlDir,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
153
166
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
154
|
-
sessionId:opts.sessionId,isResume:opts.isResume,
|
|
167
|
+
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
168
|
+
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
155
169
|
job=await launch(request.texts,options)
|
|
156
170
|
active.set(base,job.child)
|
|
157
171
|
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
|
package/src/inbox.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
1
|
import type { Update } from 'grammy/types'
|
|
4
2
|
import { isExecutionChoice, type ExecutionChoice } from './ai.js'
|
|
5
3
|
import { isOwner } from './identity.js'
|
|
@@ -21,58 +19,26 @@ type Entry = { update: Update; receivedAt: number; execution?: ExecutionChoice }
|
|
|
21
19
|
export type InboxBatch = { id: string; entries: Entry[]; status: 'pending' | 'failed' | 'cancelled' }
|
|
22
20
|
type State = { version: 1; seen: number[]; waiting: Entry[]; batches: InboxBatch[] }
|
|
23
21
|
|
|
24
|
-
//
|
|
22
|
+
// Stateless pipe: process memory only. Intake dedup lives in the relay that
|
|
23
|
+
// polls Telegram, so no other process reads this journal. A restart drops
|
|
24
|
+
// unprocessed updates by design; Telegram redelivery is the replay mechanism.
|
|
25
|
+
const statesByDirectory = new Map<string, State>()
|
|
26
|
+
|
|
27
|
+
const stateFor = (directory: string): State => {
|
|
28
|
+
let state = statesByDirectory.get(directory)
|
|
29
|
+
if (!state) { state = { version: 1, seen: [], waiting: [], batches: [] }; statesByDirectory.set(directory, state) }
|
|
30
|
+
return state
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
export class InboxStore {
|
|
26
34
|
private lock: Promise<unknown> = Promise.resolve()
|
|
27
|
-
private readonly file: string
|
|
28
35
|
constructor(
|
|
29
36
|
private readonly directory: string,
|
|
30
37
|
private readonly now = Date.now,
|
|
31
|
-
) {
|
|
32
|
-
this.file = join(directory, 'inbox.json')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
private async read(): Promise<State> {
|
|
36
|
-
try {
|
|
37
|
-
const state = JSON.parse(await readFile(this.file, 'utf8')) as State
|
|
38
|
-
const entry = (e: Entry) =>
|
|
39
|
-
e && Number.isSafeInteger(e.update?.update_id) && Number.isFinite(e.receivedAt) &&
|
|
40
|
-
(e.execution === undefined || isExecutionChoice(e.execution))
|
|
41
|
-
if (
|
|
42
|
-
state.version !== 1 ||
|
|
43
|
-
!Array.isArray(state.seen) ||
|
|
44
|
-
!state.seen.every(Number.isSafeInteger) ||
|
|
45
|
-
!Array.isArray(state.waiting) ||
|
|
46
|
-
!state.waiting.every(entry) ||
|
|
47
|
-
!Array.isArray(state.batches) ||
|
|
48
|
-
!state.batches.every(
|
|
49
|
-
(b) =>
|
|
50
|
-
/^tg_\d+$/.test(b.id) &&
|
|
51
|
-
['pending', 'failed', 'cancelled'].includes(b.status) &&
|
|
52
|
-
Array.isArray(b.entries) &&
|
|
53
|
-
b.entries.length > 0 &&
|
|
54
|
-
b.entries.every(entry),
|
|
55
|
-
)
|
|
56
|
-
)
|
|
57
|
-
throw new Error('Invalid inbox shape')
|
|
58
|
-
return state
|
|
59
|
-
} catch (error) {
|
|
60
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
|
61
|
-
return { version: 1, seen: [], waiting: [], batches: [] }
|
|
62
|
-
throw new Error('Inbox unreadable; restore the local journal before accepting messages')
|
|
63
|
-
}
|
|
64
|
-
}
|
|
38
|
+
) {}
|
|
65
39
|
|
|
66
40
|
private change<T>(work: (state: State) => T): Promise<T> {
|
|
67
|
-
const next = this.lock.then(async () =>
|
|
68
|
-
const state = await this.read()
|
|
69
|
-
const result = work(state)
|
|
70
|
-
await mkdir(this.directory, { recursive: true, mode: 0o700 })
|
|
71
|
-
const temporary = `${this.file}.${process.pid}.tmp`
|
|
72
|
-
await writeFile(temporary, JSON.stringify(state), { mode: 0o600 })
|
|
73
|
-
await rename(temporary, this.file)
|
|
74
|
-
return result
|
|
75
|
-
})
|
|
41
|
+
const next = this.lock.then(async () => work(stateFor(this.directory)))
|
|
76
42
|
this.lock = next.catch(() => {})
|
|
77
43
|
return next
|
|
78
44
|
}
|
|
@@ -163,12 +129,12 @@ export class InboxStore {
|
|
|
163
129
|
|
|
164
130
|
async pending(id: string): Promise<boolean> {
|
|
165
131
|
await this.lock
|
|
166
|
-
return (
|
|
132
|
+
return stateFor(this.directory).batches.some((b) => b.id === id && b.status === 'pending')
|
|
167
133
|
}
|
|
168
134
|
|
|
169
135
|
async status(): Promise<{ pending: number; failed: number }> {
|
|
170
136
|
await this.lock
|
|
171
|
-
const state =
|
|
137
|
+
const state = stateFor(this.directory)
|
|
172
138
|
return {
|
|
173
139
|
pending:
|
|
174
140
|
state.waiting.length +
|
package/src/index.ts
CHANGED
|
@@ -4,14 +4,15 @@ import { TelegramSource } from './telegram-source.js'
|
|
|
4
4
|
import { Tasks } from './tasks.js'
|
|
5
5
|
import { taskRequests } from './task-rpc.js'
|
|
6
6
|
import { executionBlockReason } from './execution-authority.js'
|
|
7
|
+
import { serveDeliverySocket, createLedgerHandler, deliverySocketAlive, deliverySocketPath } from './delivery-socket.js'
|
|
8
|
+
import { packageVersion } from './version.js'
|
|
7
9
|
import {authorizeDeliveryContext} from './delivery-context.mjs'
|
|
8
10
|
import { dispatchChannel } from './channel-backend.js'
|
|
9
11
|
import { randomUUID } from 'node:crypto'
|
|
10
12
|
import { stat } from 'node:fs/promises'
|
|
11
13
|
import { Scheduler } from './scheduler.js'
|
|
12
|
-
import { scheduledTasksText } from './scheduled-tasks.js'
|
|
14
|
+
import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
|
|
13
15
|
import { taskWorkspace } from './task-workspace.js'
|
|
14
|
-
import { queueUpdateAttention } from './update-attention.js'
|
|
15
16
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
16
17
|
import {removeTaskAttachments} from './task-attachments.js'
|
|
17
18
|
import { dirname, join, basename } from 'node:path'
|
|
@@ -37,14 +38,13 @@ import { createConversationMenu } from './conversation-menu.js'
|
|
|
37
38
|
import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
|
|
38
39
|
import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
|
|
39
40
|
import { discoverDefaults } from './client-defaults.js'
|
|
40
|
-
import {
|
|
41
|
+
import { runPromptSuffix } from './prompt-suffix.js'
|
|
41
42
|
import { softwareStatus } from './software-status.js'
|
|
42
|
-
import { PagerDutyStocksMonitor } from './pagerduty.js'
|
|
43
43
|
|
|
44
44
|
export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
45
45
|
const safeError = (error: unknown): string => {
|
|
46
46
|
let message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
|
47
|
-
for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey
|
|
47
|
+
for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
|
|
48
48
|
if (secret) message = message.replaceAll(secret, '[redacted]')
|
|
49
49
|
}
|
|
50
50
|
return message
|
|
@@ -70,19 +70,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
70
70
|
let taskTimer: ReturnType<typeof setInterval> | undefined
|
|
71
71
|
let drainTimer: ReturnType<typeof setInterval> | undefined
|
|
72
72
|
const codexHome = join(config.controlDir, 'cli', 'codex')
|
|
73
|
-
const conversationMenu = createConversationMenu(control
|
|
73
|
+
const conversationMenu = createConversationMenu(control)
|
|
74
74
|
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
|
|
75
75
|
const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
|
|
76
76
|
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
77
|
-
const pagerDuty = config.pagerDutyRoutingKey && config.pagerDutyStocksHealthUrl
|
|
78
|
-
? new PagerDutyStocksMonitor({
|
|
79
|
-
routingKey: config.pagerDutyRoutingKey,
|
|
80
|
-
healthUrl: config.pagerDutyStocksHealthUrl,
|
|
81
|
-
pollMs: config.pagerDutyPollMs!,
|
|
82
|
-
failureThreshold: config.pagerDutyFailureThreshold!,
|
|
83
|
-
onError: (error) => console.error('PagerDuty Stocks health check failed', safeError(error)),
|
|
84
|
-
})
|
|
85
|
-
: undefined
|
|
86
77
|
|
|
87
78
|
let activeTypingTimer: ReturnType<typeof setInterval> | null = null
|
|
88
79
|
let activeBackend = false
|
|
@@ -112,6 +103,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
112
103
|
controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
|
|
113
104
|
aiControls: aiMenu,
|
|
114
105
|
wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
|
|
106
|
+
createTelegramPairing: async (bindingId, owner) => {
|
|
107
|
+
const username = bot?.botInfo?.username
|
|
108
|
+
if (!username || !/^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(username))
|
|
109
|
+
throw new Error('Telegram connection is unavailable')
|
|
110
|
+
const pairing = await control.createApplicationTelegramPairing(bindingId, owner)
|
|
111
|
+
if (!pairing) return { connected: true } as const
|
|
112
|
+
return {
|
|
113
|
+
connected: false as const,
|
|
114
|
+
url: `https://t.me/${username}?start=${pairing.token}`,
|
|
115
|
+
expiresAt: pairing.expiresAt,
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
telegramAvailable: () => Boolean(bot?.botInfo?.username),
|
|
115
119
|
cancel: id => withStartLock(async () => {
|
|
116
120
|
const run = await runs.get(id)
|
|
117
121
|
if (run?.status === 'queued') await runs.patch(id, {status:'cancelled',endedAt:new Date().toISOString()})
|
|
@@ -176,7 +180,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
176
180
|
catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); await releaseExternal(run); return }
|
|
177
181
|
}
|
|
178
182
|
if (config.channelBackendUrl) {
|
|
179
|
-
if (run.external || run.taskId || run.scheduled
|
|
183
|
+
if (run.external || run.taskId || run.scheduled) { await runs.patch(run.id, { status: 'failed' }); await releaseExternal(run); return }
|
|
180
184
|
activeBackend = true
|
|
181
185
|
await runs.patch(run.id, { status: 'running', backendSubmitted: true })
|
|
182
186
|
void dispatchChannel(config, run).then(async reply => {
|
|
@@ -224,14 +228,13 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
224
228
|
const launchStarted = performance.now()
|
|
225
229
|
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
226
230
|
if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
227
|
-
const
|
|
228
|
-
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled || maintenanceWakeup)
|
|
231
|
+
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
|
|
229
232
|
const session = startsOwnSession
|
|
230
233
|
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
231
234
|
: await control.executionSession(started.execution!)
|
|
232
235
|
const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
233
236
|
const { child, cleanup } = await launch(texts, {
|
|
234
|
-
workspace: run.scheduled ? await taskWorkspace(config.
|
|
237
|
+
workspace: run.scheduled ? await taskWorkspace(config.controlDir,run.id) : config.workspace,
|
|
235
238
|
timeoutMs: config.executorTimeoutMs,
|
|
236
239
|
repairEnabled: config.repairEnabled,
|
|
237
240
|
runId: started.id,
|
|
@@ -245,8 +248,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
245
248
|
codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
|
|
246
249
|
sessionId: session.nativeSessionId || session.sessionId,
|
|
247
250
|
isResume: session.hasStarted,
|
|
248
|
-
|
|
249
|
-
|
|
251
|
+
promptSuffix: runPromptSuffix(started),
|
|
252
|
+
taskRun: Boolean(run.taskId),
|
|
253
|
+
nativeSession: Boolean(run.scheduled),
|
|
254
|
+
onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
|
|
250
255
|
})
|
|
251
256
|
const executionStarted = performance.now()
|
|
252
257
|
// Attach before disk writes: a fast child can close while PID persistence
|
|
@@ -385,7 +390,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
385
390
|
for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
|
|
386
391
|
try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
|
|
387
392
|
}
|
|
388
|
-
if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
|
|
389
393
|
}
|
|
390
394
|
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
391
395
|
try {
|
|
@@ -449,10 +453,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
449
453
|
if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
|
|
450
454
|
if (message && (message.chat.type === 'group' || message.chat.type === 'supergroup'))
|
|
451
455
|
item.text = `[Telegram sender ${message.from?.id}, name ${JSON.stringify(message.from?.first_name)}]\n${item.text}`
|
|
452
|
-
if (message && !message.text && message.reply_to_message) {
|
|
453
|
-
const quoted = message.reply_to_message
|
|
454
|
-
item.text = `[Quoted message ${quoted.message_id}]: ${quoted.text || quoted.caption || '[media]'}\n\n${item.text}`
|
|
455
|
-
}
|
|
456
456
|
collected.push(item)
|
|
457
457
|
}
|
|
458
458
|
const scheduleIntake = () => {
|
|
@@ -565,7 +565,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
565
565
|
])
|
|
566
566
|
console.info('run reaction sent', { run_id: item.runId, emoji })
|
|
567
567
|
} else if (item.type === 'document' && item.documentPath) {
|
|
568
|
-
const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.
|
|
568
|
+
const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.controlDir,origin.id) : config.workspace, item.documentPath)
|
|
569
569
|
await paceSend()
|
|
570
570
|
await authorizeChannelDelivery()
|
|
571
571
|
attemptedDelivery = true
|
|
@@ -623,11 +623,34 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
623
623
|
})().finally(() => { outboxWork = undefined }))
|
|
624
624
|
}
|
|
625
625
|
|
|
626
|
+
// Delivery socket: cross-process producers (engine children, plugin
|
|
627
|
+
// children, host executor, CLIs) enqueue and query the memory ledger
|
|
628
|
+
// through the shared handler in delivery-socket.ts. The pump below stays
|
|
629
|
+
// the single delivery executor; the socket only admits work into it.
|
|
630
|
+
let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
|
|
631
|
+
const handleDeliveryOp = createLedgerHandler(config.controlDir, {
|
|
632
|
+
wake: () => { void drainOutbox() },
|
|
633
|
+
status: () => ({
|
|
634
|
+
polling: Boolean(bot?.isRunning()),
|
|
635
|
+
applicationOnly: !telegramEnabled,
|
|
636
|
+
telegramConfigured: config.telegramBotToken !== '',
|
|
637
|
+
version: packageVersion,
|
|
638
|
+
}),
|
|
639
|
+
})
|
|
640
|
+
|
|
626
641
|
const checkOwner = async (ctx: Context): Promise<boolean> => {
|
|
627
642
|
if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
|
|
628
643
|
const state = await control.status()
|
|
629
644
|
if (!telegramOwner(state.owner)) {
|
|
630
645
|
if (ctx.chat?.type !== 'private') return false
|
|
646
|
+
const applicationToken = ctx.message?.text?.trim().match(/^\/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]{43})$/)?.[1]
|
|
647
|
+
if (applicationToken) {
|
|
648
|
+
const linked = await applicationChannel.claimTelegramConnection(applicationToken, ctx.from.id, ctx.chat.id)
|
|
649
|
+
await ctx.reply(linked
|
|
650
|
+
? 'Telegram is connected to your Ez agent. You can keep chatting here.'
|
|
651
|
+
: 'This Telegram connection link expired or is no longer valid. Create a new one in the connected application.', { link_preview_options: { is_disabled: true } })
|
|
652
|
+
return false
|
|
653
|
+
}
|
|
631
654
|
const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
|
|
632
655
|
if (result === 'requested')
|
|
633
656
|
await ctx.reply('Owner approval is pending. Confirm this request through the local setup assistant.')
|
|
@@ -654,18 +677,29 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
654
677
|
const commands = [...mainCommands, ...(launcher ? [{ command: launcher.command, description: launcher.label }] : [])]
|
|
655
678
|
const controlCommand = (text?: string) => /^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
|
|
656
679
|
? '/rename' : text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
|
|
680
|
+
const tasksCommand = (text?: string): number | undefined | null => {
|
|
681
|
+
const match = text?.trim().match(/^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s+([1-9]\d*))?$/)
|
|
682
|
+
return match ? (match[1] ? Number(match[1]) : undefined) : null
|
|
683
|
+
}
|
|
684
|
+
const isTasksCommand = (text?: string): boolean => /^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
|
|
657
685
|
const statusKeyboard = () => new InlineKeyboard()
|
|
658
686
|
.text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
|
|
659
|
-
.text('Retry failed incoming message', 'menu:retry')
|
|
660
|
-
.text('Scheduled tasks', 'menu:scheduled-tasks')
|
|
687
|
+
.text('Retry failed incoming message', 'menu:retry')
|
|
661
688
|
const scheduledTasks = async () => {
|
|
662
689
|
const owner = (await control.status()).owner
|
|
663
690
|
if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
|
|
664
691
|
// This intentionally uses the reader that does not create a schedules directory.
|
|
665
|
-
|
|
692
|
+
const active = await scheduler.listActiveReadOnly(await runs.list())
|
|
693
|
+
return { owner, active, text: scheduledTasksText(active, owner) }
|
|
666
694
|
}
|
|
667
|
-
const replyScheduledTasks = async (ctx: Context) => {
|
|
668
|
-
|
|
695
|
+
const replyScheduledTasks = async (ctx: Context, number?: number) => {
|
|
696
|
+
const result = await scheduledTasks()
|
|
697
|
+
const text = typeof result === 'string' ? result : number === undefined ? result.text : (() => {
|
|
698
|
+
const owned = ownedScheduledTasks(result.active, result.owner)
|
|
699
|
+
const schedule = owned[number - 1]
|
|
700
|
+
return schedule ? scheduledTaskDetailText(schedule, number, owned.length) : `Scheduled task ${number} was not found. Use /tasks to list active tasks.`
|
|
701
|
+
})()
|
|
702
|
+
for (const part of splitTelegramText(text)) await ctx.reply(part)
|
|
669
703
|
}
|
|
670
704
|
const statusText = async () => {
|
|
671
705
|
const running = await runs.running(false)
|
|
@@ -694,7 +728,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
694
728
|
'🟢 Ez is online',
|
|
695
729
|
'',
|
|
696
730
|
'System',
|
|
697
|
-
...await softwareStatus(config.controlDir),
|
|
731
|
+
...await softwareStatus(config.controlDir, config.isolation),
|
|
698
732
|
`AI: ${selected.name} (${presetLabel(displayedSelected)})`,
|
|
699
733
|
'',
|
|
700
734
|
'Work',
|
|
@@ -771,7 +805,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
771
805
|
}
|
|
772
806
|
const message = ctx.message
|
|
773
807
|
const command = controlCommand(message?.text)
|
|
774
|
-
if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) return next()
|
|
808
|
+
if ((command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) || tasksCommand(message?.text) !== null || isTasksCommand(message?.text)) return next()
|
|
775
809
|
const ordinary = message && (message.text || message.photo || message.document || message.voice)
|
|
776
810
|
const approval = ctx.callbackQuery?.data?.startsWith('approval:')
|
|
777
811
|
if (!ordinary && !approval) return next()
|
|
@@ -815,6 +849,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
815
849
|
return
|
|
816
850
|
}
|
|
817
851
|
|
|
852
|
+
const taskNumber = tasksCommand(ctx.message.text)
|
|
853
|
+
if (taskNumber !== null) {
|
|
854
|
+
await replyScheduledTasks(ctx, taskNumber)
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
if (isTasksCommand(ctx.message.text)) {
|
|
858
|
+
await ctx.reply('Usage: /tasks [number]. Use /tasks to list active tasks.')
|
|
859
|
+
return
|
|
860
|
+
}
|
|
861
|
+
|
|
818
862
|
if (text === '/help') {
|
|
819
863
|
await ctx.reply([...commands, ...aliases.filter((a) => !commands.some((c) => c.command === a.command))]
|
|
820
864
|
.map((c) => `/${c.command} — ${c.description}`).join('\n'))
|
|
@@ -881,19 +925,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
881
925
|
return
|
|
882
926
|
}
|
|
883
927
|
|
|
884
|
-
// Quoted reply context forwarding
|
|
885
|
-
let promptText = ctx.message.text
|
|
886
|
-
if (ctx.message.reply_to_message) {
|
|
887
|
-
const quoted = ctx.message.reply_to_message
|
|
888
|
-
const quotedText = ('text' in quoted && quoted.text) || ('caption' in quoted && quoted.caption) || ''
|
|
889
|
-
const quotedSender = quoted.from?.is_bot ? 'Agent' : 'Owner'
|
|
890
|
-
if (quotedText) {
|
|
891
|
-
promptText = `[Quoted message from ${quotedSender} (ID: ${quoted.message_id})]: "${quotedText}"\n\n${promptText}`
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
928
|
collectItem({
|
|
896
|
-
text:
|
|
929
|
+
text: ctx.message.text,
|
|
897
930
|
messageId: ctx.message.message_id,
|
|
898
931
|
updateId: ctx.update.update_id,
|
|
899
932
|
chatId: ctx.chat.id,
|
|
@@ -911,7 +944,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
911
944
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
912
945
|
const buffer = await downloadTelegramFile(fileUrl)
|
|
913
946
|
const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
|
|
914
|
-
const staged = await stageChatAttachment(config.
|
|
947
|
+
const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
|
|
915
948
|
collectItem({
|
|
916
949
|
text: staged.text,
|
|
917
950
|
attachment: staged.attachment,
|
|
@@ -938,7 +971,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
938
971
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
939
972
|
const buffer = await downloadTelegramFile(fileUrl)
|
|
940
973
|
const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
|
|
941
|
-
const staged = await stageChatAttachment(config.
|
|
974
|
+
const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
|
|
942
975
|
collectItem({
|
|
943
976
|
text: staged.text,
|
|
944
977
|
attachment: staged.attachment,
|
|
@@ -1104,6 +1137,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1104
1137
|
shuttingDown = true
|
|
1105
1138
|
runtimeStarted = false
|
|
1106
1139
|
pollingAbort.abort()
|
|
1140
|
+
await deliveryServer?.stop().catch(() => {})
|
|
1141
|
+
deliveryServer = null
|
|
1107
1142
|
await applicationChannel.stop()
|
|
1108
1143
|
wakePollRetry?.()
|
|
1109
1144
|
if (intakeTimer) clearTimeout(intakeTimer)
|
|
@@ -1119,7 +1154,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1119
1154
|
})
|
|
1120
1155
|
await Promise.all(completions)
|
|
1121
1156
|
await Promise.all([taskWork, outboxWork].map(work => work?.catch(() => {})))
|
|
1122
|
-
pagerDuty?.stop()
|
|
1123
1157
|
try {
|
|
1124
1158
|
if (bot?.isRunning()) await bot.stop()
|
|
1125
1159
|
} finally {
|
|
@@ -1130,10 +1164,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1130
1164
|
const start = async () => {
|
|
1131
1165
|
let failed = false
|
|
1132
1166
|
try {
|
|
1133
|
-
|
|
1167
|
+
// Workspace seeding and managed-block replacement happen at install or
|
|
1168
|
+
// upgrade only; relay startup never writes the agent workspace.
|
|
1169
|
+
// The delivery socket is the cross-process ledger transport and the
|
|
1170
|
+
// single-relay guard (replacing relay.lock): a live sibling answers the
|
|
1171
|
+
// ping and this relay refuses to split-brain the agent.
|
|
1172
|
+
if (await deliverySocketAlive(deliverySocketPath(config.controlDir)).catch(() => false))
|
|
1173
|
+
throw new Error(`Another relay owns ${config.controlDir}; stop it before starting a second one`)
|
|
1174
|
+
const deliveryTcpPort = Number(process.env.EZ_DELIVERY_TCP_PORT ?? '')
|
|
1175
|
+
deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
|
|
1176
|
+
...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
|
|
1177
|
+
})
|
|
1134
1178
|
if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
|
|
1135
1179
|
runtimeStarted = true
|
|
1136
|
-
pagerDuty?.start()
|
|
1137
1180
|
const owner = (await control.status()).owner
|
|
1138
1181
|
if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
|
|
1139
1182
|
await scheduler.recover(runs)
|
|
@@ -1210,7 +1253,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1210
1253
|
}
|
|
1211
1254
|
}
|
|
1212
1255
|
}
|
|
1213
|
-
return { bot: bot!, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
|
|
1256
|
+
return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
|
|
1214
1257
|
}
|
|
1215
1258
|
|
|
1216
1259
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|