@jc_stack/ez-agents 0.1.0-beta.33 → 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 +9 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +92 -0
- package/Dockerfile +11 -9
- package/README.md +32 -27
- package/THIRD_PARTY_NOTICES.md +4 -4
- 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 +42 -5
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +32 -7
- package/docs/application-channel.md +38 -10
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +106 -21
- package/docs/host-service.md +17 -6
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +63 -2
- package/docs/releasing.md +13 -0
- package/docs/repair.md +1 -1
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +96 -16
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +12 -4
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +44 -9
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +88 -17
- 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/codex-session.ts +5 -3
- package/src/config.ts +18 -28
- package/src/control-state.ts +187 -9
- 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 +110 -31
- package/src/files.ts +38 -11
- package/src/host-executor.ts +50 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +96 -52
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +100 -23
- package/src/isolation.ts +25 -0
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +16 -8
- 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 +178 -26
- 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/setup.ts +11 -7
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +6 -3
- 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 +29 -9
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +31 -18
- package/src/version.ts +20 -0
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +24 -20
- package/templates/agent-purpose.md +1 -0
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +18 -88
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +84 -0
- package/test/application-channel.test.ts +66 -15
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +16 -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 +63 -3
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +47 -37
- 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 +12 -8
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +47 -11
- package/test/failure.test.ts +18 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +80 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +18 -6
- package/test/install-tools.test.mjs +80 -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 +143 -5
- 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/scheduler.test.ts +2 -2
- 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 +102 -13
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +27 -11
- 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 -44
- 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 -15
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
- package/templates/agent-guidance.md +0 -114
- 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/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,
|
|
@@ -240,12 +243,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
240
243
|
cli: selected.cli,
|
|
241
244
|
model: selected.model,
|
|
242
245
|
effort: selected.effort,
|
|
246
|
+
provider: selected.provider,
|
|
243
247
|
codexAutoCompactTokens: config.codexAutoCompactTokens,
|
|
244
248
|
codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
|
|
245
249
|
sessionId: session.nativeSessionId || session.sessionId,
|
|
246
250
|
isResume: session.hasStarted,
|
|
247
|
-
|
|
248
|
-
|
|
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) },
|
|
249
255
|
})
|
|
250
256
|
const executionStarted = performance.now()
|
|
251
257
|
// Attach before disk writes: a fast child can close while PID persistence
|
|
@@ -384,7 +390,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
384
390
|
for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
|
|
385
391
|
try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
|
|
386
392
|
}
|
|
387
|
-
if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
|
|
388
393
|
}
|
|
389
394
|
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
390
395
|
try {
|
|
@@ -448,10 +453,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
448
453
|
if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
|
|
449
454
|
if (message && (message.chat.type === 'group' || message.chat.type === 'supergroup'))
|
|
450
455
|
item.text = `[Telegram sender ${message.from?.id}, name ${JSON.stringify(message.from?.first_name)}]\n${item.text}`
|
|
451
|
-
if (message && !message.text && message.reply_to_message) {
|
|
452
|
-
const quoted = message.reply_to_message
|
|
453
|
-
item.text = `[Quoted message ${quoted.message_id}]: ${quoted.text || quoted.caption || '[media]'}\n\n${item.text}`
|
|
454
|
-
}
|
|
455
456
|
collected.push(item)
|
|
456
457
|
}
|
|
457
458
|
const scheduleIntake = () => {
|
|
@@ -564,7 +565,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
564
565
|
])
|
|
565
566
|
console.info('run reaction sent', { run_id: item.runId, emoji })
|
|
566
567
|
} else if (item.type === 'document' && item.documentPath) {
|
|
567
|
-
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)
|
|
568
569
|
await paceSend()
|
|
569
570
|
await authorizeChannelDelivery()
|
|
570
571
|
attemptedDelivery = true
|
|
@@ -622,11 +623,34 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
622
623
|
})().finally(() => { outboxWork = undefined }))
|
|
623
624
|
}
|
|
624
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
|
+
|
|
625
641
|
const checkOwner = async (ctx: Context): Promise<boolean> => {
|
|
626
642
|
if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
|
|
627
643
|
const state = await control.status()
|
|
628
644
|
if (!telegramOwner(state.owner)) {
|
|
629
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
|
+
}
|
|
630
654
|
const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
|
|
631
655
|
if (result === 'requested')
|
|
632
656
|
await ctx.reply('Owner approval is pending. Confirm this request through the local setup assistant.')
|
|
@@ -653,18 +677,29 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
653
677
|
const commands = [...mainCommands, ...(launcher ? [{ command: launcher.command, description: launcher.label }] : [])]
|
|
654
678
|
const controlCommand = (text?: string) => /^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
|
|
655
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() ?? '')
|
|
656
685
|
const statusKeyboard = () => new InlineKeyboard()
|
|
657
686
|
.text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
|
|
658
|
-
.text('Retry failed incoming message', 'menu:retry')
|
|
659
|
-
.text('Scheduled tasks', 'menu:scheduled-tasks')
|
|
687
|
+
.text('Retry failed incoming message', 'menu:retry')
|
|
660
688
|
const scheduledTasks = async () => {
|
|
661
689
|
const owner = (await control.status()).owner
|
|
662
690
|
if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
|
|
663
691
|
// This intentionally uses the reader that does not create a schedules directory.
|
|
664
|
-
|
|
692
|
+
const active = await scheduler.listActiveReadOnly(await runs.list())
|
|
693
|
+
return { owner, active, text: scheduledTasksText(active, owner) }
|
|
665
694
|
}
|
|
666
|
-
const replyScheduledTasks = async (ctx: Context) => {
|
|
667
|
-
|
|
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)
|
|
668
703
|
}
|
|
669
704
|
const statusText = async () => {
|
|
670
705
|
const running = await runs.running(false)
|
|
@@ -693,7 +728,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
693
728
|
'🟢 Ez is online',
|
|
694
729
|
'',
|
|
695
730
|
'System',
|
|
696
|
-
...await softwareStatus(config.controlDir),
|
|
731
|
+
...await softwareStatus(config.controlDir, config.isolation),
|
|
697
732
|
`AI: ${selected.name} (${presetLabel(displayedSelected)})`,
|
|
698
733
|
'',
|
|
699
734
|
'Work',
|
|
@@ -770,7 +805,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
770
805
|
}
|
|
771
806
|
const message = ctx.message
|
|
772
807
|
const command = controlCommand(message?.text)
|
|
773
|
-
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()
|
|
774
809
|
const ordinary = message && (message.text || message.photo || message.document || message.voice)
|
|
775
810
|
const approval = ctx.callbackQuery?.data?.startsWith('approval:')
|
|
776
811
|
if (!ordinary && !approval) return next()
|
|
@@ -814,6 +849,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
814
849
|
return
|
|
815
850
|
}
|
|
816
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
|
+
|
|
817
862
|
if (text === '/help') {
|
|
818
863
|
await ctx.reply([...commands, ...aliases.filter((a) => !commands.some((c) => c.command === a.command))]
|
|
819
864
|
.map((c) => `/${c.command} — ${c.description}`).join('\n'))
|
|
@@ -880,19 +925,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
880
925
|
return
|
|
881
926
|
}
|
|
882
927
|
|
|
883
|
-
// Quoted reply context forwarding
|
|
884
|
-
let promptText = ctx.message.text
|
|
885
|
-
if (ctx.message.reply_to_message) {
|
|
886
|
-
const quoted = ctx.message.reply_to_message
|
|
887
|
-
const quotedText = ('text' in quoted && quoted.text) || ('caption' in quoted && quoted.caption) || ''
|
|
888
|
-
const quotedSender = quoted.from?.is_bot ? 'Agent' : 'Owner'
|
|
889
|
-
if (quotedText) {
|
|
890
|
-
promptText = `[Quoted message from ${quotedSender} (ID: ${quoted.message_id})]: "${quotedText}"\n\n${promptText}`
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
928
|
collectItem({
|
|
895
|
-
text:
|
|
929
|
+
text: ctx.message.text,
|
|
896
930
|
messageId: ctx.message.message_id,
|
|
897
931
|
updateId: ctx.update.update_id,
|
|
898
932
|
chatId: ctx.chat.id,
|
|
@@ -910,7 +944,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
910
944
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
911
945
|
const buffer = await downloadTelegramFile(fileUrl)
|
|
912
946
|
const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
|
|
913
|
-
const staged = await stageChatAttachment(config.
|
|
947
|
+
const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
|
|
914
948
|
collectItem({
|
|
915
949
|
text: staged.text,
|
|
916
950
|
attachment: staged.attachment,
|
|
@@ -937,7 +971,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
937
971
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
938
972
|
const buffer = await downloadTelegramFile(fileUrl)
|
|
939
973
|
const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
|
|
940
|
-
const staged = await stageChatAttachment(config.
|
|
974
|
+
const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
|
|
941
975
|
collectItem({
|
|
942
976
|
text: staged.text,
|
|
943
977
|
attachment: staged.attachment,
|
|
@@ -1103,6 +1137,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1103
1137
|
shuttingDown = true
|
|
1104
1138
|
runtimeStarted = false
|
|
1105
1139
|
pollingAbort.abort()
|
|
1140
|
+
await deliveryServer?.stop().catch(() => {})
|
|
1141
|
+
deliveryServer = null
|
|
1106
1142
|
await applicationChannel.stop()
|
|
1107
1143
|
wakePollRetry?.()
|
|
1108
1144
|
if (intakeTimer) clearTimeout(intakeTimer)
|
|
@@ -1118,7 +1154,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1118
1154
|
})
|
|
1119
1155
|
await Promise.all(completions)
|
|
1120
1156
|
await Promise.all([taskWork, outboxWork].map(work => work?.catch(() => {})))
|
|
1121
|
-
pagerDuty?.stop()
|
|
1122
1157
|
try {
|
|
1123
1158
|
if (bot?.isRunning()) await bot.stop()
|
|
1124
1159
|
} finally {
|
|
@@ -1129,10 +1164,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1129
1164
|
const start = async () => {
|
|
1130
1165
|
let failed = false
|
|
1131
1166
|
try {
|
|
1132
|
-
|
|
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
|
+
})
|
|
1133
1178
|
if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
|
|
1134
1179
|
runtimeStarted = true
|
|
1135
|
-
pagerDuty?.start()
|
|
1136
1180
|
const owner = (await control.status()).owner
|
|
1137
1181
|
if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
|
|
1138
1182
|
await scheduler.recover(runs)
|
|
@@ -1209,7 +1253,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1209
1253
|
}
|
|
1210
1254
|
}
|
|
1211
1255
|
}
|
|
1212
|
-
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 }
|
|
1213
1257
|
}
|
|
1214
1258
|
|
|
1215
1259
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
package/src/install-config.ts
CHANGED
|
@@ -11,7 +11,7 @@ const inside = (parent: string, child: string) => {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
// Configuration only: no provider calls, owner approval, service start, or AI turn.
|
|
14
|
-
export const configureInstallation = async (directory: string, cli: string, token?: string) => {
|
|
14
|
+
export const configureInstallation = async (directory: string, cli: string, token?: string, purposeFile?: string) => {
|
|
15
15
|
let executor: string
|
|
16
16
|
try { executor = resolveExecutor(cli).name }
|
|
17
17
|
catch { throw new Error('Unsupported executor. See ezenciel-agents-setup status.') }
|
|
@@ -44,7 +44,7 @@ export const configureInstallation = async (directory: string, cli: string, toke
|
|
|
44
44
|
if (!quote) throw new Error('Existing .env has a value requiring manual quoting; it was not changed.')
|
|
45
45
|
return `${key}=${quote}${value}${quote}\n`
|
|
46
46
|
}).join('')
|
|
47
|
-
const created = await initializeWorkspace(workspace)
|
|
47
|
+
const created = await initializeWorkspace(workspace, purposeFile)
|
|
48
48
|
const temporary = `${envFile}.${randomUUID()}.tmp`
|
|
49
49
|
try {
|
|
50
50
|
await writeFile(temporary, content, { mode: 0o600, flag: 'wx' })
|
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'));
|
|
@@ -49,50 +53,123 @@ export async function installationStatus(deployment) {
|
|
|
49
53
|
const paired=Boolean(owner&&Number.isSafeInteger(owner.telegramUserId)&&owner.telegramUserId>0&&Number.isSafeInteger(owner.telegramChatId)&&(owner.kind==='group'?owner.telegramChatId<0:owner.kind===undefined&&owner.telegramChatId>0)&&Number.isFinite(Date.parse(owner.pairedAt)));
|
|
50
54
|
const relay=await read(path.join(control,'heartbeat.json')).catch(absent),host=await read(path.join(control,'host-executor/heartbeat.json')).catch(absent);
|
|
51
55
|
const fresh=(h,ms)=>Boolean(h&&Number.isFinite(h.at)&&h.at<=Date.now()+1000&&Date.now()-h.at<ms);
|
|
52
|
-
|
|
56
|
+
let isolated=false;
|
|
57
|
+
try { isolated=(await read(path.join(deployment,'agent.json'))).isolation==='isolated'; } catch { isolated=false; }
|
|
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.
|
|
53
61
|
let reply=null;
|
|
54
|
-
if(paired)
|
|
55
|
-
if(!name.endsWith('.sent.json'))continue;
|
|
56
|
-
const item=await read(path.join(control,'outbox',name));
|
|
57
|
-
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;
|
|
58
|
-
const delivered=Date.parse(item.receipt.deliveredAt);if(!Number.isFinite(delivered)||delivered<Date.parse(owner.pairedAt)||delivered>Date.now())continue;
|
|
59
|
-
const r=await read(path.join(control,'runs',item.runId+'.json')).catch(absent);
|
|
60
|
-
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};
|
|
61
|
-
}
|
|
62
|
+
if(paired)reply=(await callDeliverySocket(path.join(control,'delivery.sock'),{op:'latestReply'},2000).catch(()=>null))?.reply??null;
|
|
62
63
|
return {deployment,configured,runtimeReady,ownerPaired:paired,telegramReplyVerified:Boolean(reply),reply,
|
|
63
64
|
stage:!configured?'not-configured':!runtimeReady?'runtime-offline':!paired?'awaiting-owner':!reply?'awaiting-telegram-reply':'ready-for-telegram-plugin-request',
|
|
64
|
-
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 };
|
|
65
115
|
}
|
|
116
|
+
|
|
66
117
|
async function fingerprintOf(source,invoke) {
|
|
67
118
|
const listing=JSON.parse(await invoke('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:source}));
|
|
68
119
|
const hash=createHash('sha256');
|
|
69
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);}
|
|
70
121
|
return hash.digest('hex');
|
|
71
122
|
}
|
|
72
|
-
|
|
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) {
|
|
73
145
|
source=await fs.realpath(source);
|
|
74
|
-
const
|
|
75
|
-
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});
|
|
76
151
|
const lock=path.join(dir,'lock'),receipt=path.join(dir,'status.json'),log=path.join(dir,'build.log');
|
|
77
152
|
let handle;
|
|
78
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.'};}
|
|
79
154
|
try {
|
|
80
|
-
await handle.writeFile(JSON.stringify({pid:process.pid,source,image}));
|
|
155
|
+
await handle.writeFile(JSON.stringify({pid:process.pid,source,image,...identity}));
|
|
81
156
|
const prior=await read(receipt).catch(absent);
|
|
82
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. */}
|
|
83
|
-
await atomic(receipt,{state:'building',pid:process.pid,source,image,log});
|
|
84
|
-
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}`]:[])];
|
|
85
162
|
const output=await fs.open(log,'a',0o600);
|
|
86
|
-
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();}
|
|
87
164
|
if(await fingerprintOf(source,invoke)!==fingerprint)throw Error('Package changed during build; keep the source stable and retry');
|
|
88
|
-
const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log};await atomic(receipt,result);return result;
|
|
89
|
-
}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;}
|
|
90
167
|
finally{await handle.close();await fs.rm(lock);}
|
|
91
168
|
}
|
|
92
169
|
export async function main(args) {
|
|
93
170
|
const [action,...rest]=args;const options={};
|
|
94
|
-
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
|
|
95
|
-
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];}
|
|
96
|
-
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');
|
|
97
|
-
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;
|
|
98
175
|
}
|
package/src/isolation.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type IsolationClass = 'isolated' | 'host-capable'
|
|
2
|
+
|
|
3
|
+
export const isolationTransport = (isolation: IsolationClass): 'local' | 'host' =>
|
|
4
|
+
isolation === 'host-capable' ? 'host' : 'local'
|
|
5
|
+
|
|
6
|
+
export const parseIsolationClass = (value: string): IsolationClass => {
|
|
7
|
+
if (value !== 'isolated' && value !== 'host-capable') throw new Error('Isolation must be isolated or host-capable')
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Isolated runs the native CLI in the relay. Host-capable reuses the installer UID.
|
|
12
|
+
// Unlabeled host transport stays host-capable so existing deployments are not flipped.
|
|
13
|
+
export const resolveIsolation = (env: NodeJS.ProcessEnv = process.env): IsolationClass => {
|
|
14
|
+
const labeled = env.EZ_ISOLATION?.trim()
|
|
15
|
+
if (labeled) parseIsolationClass(labeled)
|
|
16
|
+
const transport = env.EZ_EXECUTOR_TRANSPORT?.trim() || ''
|
|
17
|
+
if (env.EZ_CHANNEL_BACKEND_URL?.trim()) {
|
|
18
|
+
if (labeled === 'host-capable' || transport === 'host') throw new Error('Host-capable isolation requires the host native CLI, not a channel backend')
|
|
19
|
+
return labeled ? parseIsolationClass(labeled) : 'isolated'
|
|
20
|
+
}
|
|
21
|
+
const isolation = labeled ? parseIsolationClass(labeled) : transport === 'host' ? 'host-capable' : 'isolated'
|
|
22
|
+
const expected = isolationTransport(isolation)
|
|
23
|
+
if ((transport || expected) !== expected) throw new Error(`Isolation ${isolation} requires EZ_EXECUTOR_TRANSPORT=${expected}`)
|
|
24
|
+
return isolation
|
|
25
|
+
}
|
|
@@ -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,17 +13,18 @@ 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
|
|
|
25
26
|
const matchesModel = (preset: AiPreset, model: ModelChoice) =>
|
|
26
|
-
model.cli === preset.cli && (preset.model === undefined || model.model === preset.model) &&
|
|
27
|
+
model.cli === preset.cli && model.provider === preset.provider && (preset.model === undefined || model.model === preset.model) &&
|
|
27
28
|
(preset.effort === undefined || model.efforts.includes(preset.effort))
|
|
28
29
|
|
|
29
30
|
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
@@ -33,13 +34,20 @@ 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)
|
|
39
44
|
if (preset.id === initial.id) return
|
|
40
45
|
if (preset.id.startsWith('detected_')) {
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
if (host) await validateSelection(preset, await catalog(), async name => (await catalog()).some(model => model.cli === name))
|
|
47
|
+
else {
|
|
48
|
+
const detected = await discoverDefaults(workspace, { codexHome })
|
|
49
|
+
if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
|
|
50
|
+
}
|
|
43
51
|
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
44
52
|
}
|
|
45
53
|
const buttons = new Map<string, { expires: number; action: (ctx: Context) => Promise<void> }>()
|
|
@@ -56,7 +64,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
56
64
|
const state = (await control.status()).ai
|
|
57
65
|
if (!state) throw new Error('AI settings not initialized')
|
|
58
66
|
const current = state.presets.find((p) => p.id === state.selectedId)!
|
|
59
|
-
const fresh = current.cli !== preset.cli || Boolean(session && !session.cli)
|
|
67
|
+
const fresh = current.cli !== preset.cli || current.provider !== preset.provider || Boolean(session && !session.cli)
|
|
60
68
|
if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
|
|
61
69
|
return { preset, fresh }
|
|
62
70
|
}
|
|
@@ -116,9 +124,9 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
116
124
|
const state = (await control.status()).ai
|
|
117
125
|
if (!state) throw new Error('AI settings not initialized')
|
|
118
126
|
const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
|
|
119
|
-
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, model: model.model, effort }
|
|
127
|
+
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, model: model.model, effort }
|
|
120
128
|
const stored = persistedPreset(candidate)
|
|
121
|
-
const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.model === stored.model && preset.effort === stored.effort)
|
|
129
|
+
const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.provider === stored.provider && preset.model === stored.model && preset.effort === stored.effort)
|
|
122
130
|
const preset = existing ?? candidate
|
|
123
131
|
await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
124
132
|
await control.savePreset(preset, guard)
|