@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +96 -0
- package/Dockerfile +20 -11
- package/README.md +10 -13
- package/THIRD_PARTY_NOTICES.md +7 -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 +41 -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/ai-selection.md +10 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +98 -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/ai-cli.ts +1 -1
- package/src/ai.ts +131 -5
- 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 +81 -24
- package/src/files.ts +38 -11
- package/src/host-executor.ts +33 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +98 -54
- 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 +8 -3
- 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 +131 -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/setup.ts +1 -0
- 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 +220 -1
- 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 +120 -12
- 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 +87 -3
- 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 +136 -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/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'
|
|
@@ -25,7 +26,7 @@ import { InboxStore, type IncomingItem } from './inbox.js'
|
|
|
25
26
|
import { loadConfig, type Config } from './config.js'
|
|
26
27
|
import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
27
28
|
import { ApprovalStore } from './approval.js'
|
|
28
|
-
import { startExecutorJob, terminateJob } from './executor.js'
|
|
29
|
+
import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.js'
|
|
29
30
|
import { RunStore, type RunRecord } from './runs.js'
|
|
30
31
|
import { splitTelegramText } from './reply.js'
|
|
31
32
|
import { markdownToTelegramHtml, escapeHtml } from './format.js'
|
|
@@ -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,11 @@ 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
|
|
74
|
-
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome
|
|
73
|
+
const conversationMenu = createConversationMenu(control)
|
|
74
|
+
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome,
|
|
75
|
+
undefined, opencodeDataHome(config.controlDir))
|
|
75
76
|
const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
|
|
76
77
|
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
78
|
|
|
87
79
|
let activeTypingTimer: ReturnType<typeof setInterval> | null = null
|
|
88
80
|
let activeBackend = false
|
|
@@ -112,6 +104,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
112
104
|
controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
|
|
113
105
|
aiControls: aiMenu,
|
|
114
106
|
wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
|
|
107
|
+
createTelegramPairing: async (bindingId, owner) => {
|
|
108
|
+
const username = bot?.botInfo?.username
|
|
109
|
+
if (!username || !/^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(username))
|
|
110
|
+
throw new Error('Telegram connection is unavailable')
|
|
111
|
+
const pairing = await control.createApplicationTelegramPairing(bindingId, owner)
|
|
112
|
+
if (!pairing) return { connected: true } as const
|
|
113
|
+
return {
|
|
114
|
+
connected: false as const,
|
|
115
|
+
url: `https://t.me/${username}?start=${pairing.token}`,
|
|
116
|
+
expiresAt: pairing.expiresAt,
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
telegramAvailable: () => Boolean(bot?.botInfo?.username),
|
|
115
120
|
cancel: id => withStartLock(async () => {
|
|
116
121
|
const run = await runs.get(id)
|
|
117
122
|
if (run?.status === 'queued') await runs.patch(id, {status:'cancelled',endedAt:new Date().toISOString()})
|
|
@@ -176,7 +181,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
176
181
|
catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); await releaseExternal(run); return }
|
|
177
182
|
}
|
|
178
183
|
if (config.channelBackendUrl) {
|
|
179
|
-
if (run.external || run.taskId || run.scheduled
|
|
184
|
+
if (run.external || run.taskId || run.scheduled) { await runs.patch(run.id, { status: 'failed' }); await releaseExternal(run); return }
|
|
180
185
|
activeBackend = true
|
|
181
186
|
await runs.patch(run.id, { status: 'running', backendSubmitted: true })
|
|
182
187
|
void dispatchChannel(config, run).then(async reply => {
|
|
@@ -224,14 +229,13 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
224
229
|
const launchStarted = performance.now()
|
|
225
230
|
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
226
231
|
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)
|
|
232
|
+
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
|
|
229
233
|
const session = startsOwnSession
|
|
230
234
|
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
231
235
|
: await control.executionSession(started.execution!)
|
|
232
236
|
const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
233
237
|
const { child, cleanup } = await launch(texts, {
|
|
234
|
-
workspace: run.scheduled ? await taskWorkspace(config.
|
|
238
|
+
workspace: run.scheduled ? await taskWorkspace(config.controlDir,run.id) : config.workspace,
|
|
235
239
|
timeoutMs: config.executorTimeoutMs,
|
|
236
240
|
repairEnabled: config.repairEnabled,
|
|
237
241
|
runId: started.id,
|
|
@@ -245,8 +249,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
245
249
|
codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
|
|
246
250
|
sessionId: session.nativeSessionId || session.sessionId,
|
|
247
251
|
isResume: session.hasStarted,
|
|
248
|
-
|
|
249
|
-
|
|
252
|
+
promptSuffix: runPromptSuffix(started),
|
|
253
|
+
taskRun: Boolean(run.taskId),
|
|
254
|
+
nativeSession: Boolean(run.scheduled),
|
|
255
|
+
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
256
|
})
|
|
251
257
|
const executionStarted = performance.now()
|
|
252
258
|
// Attach before disk writes: a fast child can close while PID persistence
|
|
@@ -385,7 +391,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
385
391
|
for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
|
|
386
392
|
try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
|
|
387
393
|
}
|
|
388
|
-
if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
|
|
389
394
|
}
|
|
390
395
|
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
391
396
|
try {
|
|
@@ -449,10 +454,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
449
454
|
if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
|
|
450
455
|
if (message && (message.chat.type === 'group' || message.chat.type === 'supergroup'))
|
|
451
456
|
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
457
|
collected.push(item)
|
|
457
458
|
}
|
|
458
459
|
const scheduleIntake = () => {
|
|
@@ -565,7 +566,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
565
566
|
])
|
|
566
567
|
console.info('run reaction sent', { run_id: item.runId, emoji })
|
|
567
568
|
} else if (item.type === 'document' && item.documentPath) {
|
|
568
|
-
const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.
|
|
569
|
+
const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.controlDir,origin.id) : config.workspace, item.documentPath)
|
|
569
570
|
await paceSend()
|
|
570
571
|
await authorizeChannelDelivery()
|
|
571
572
|
attemptedDelivery = true
|
|
@@ -623,11 +624,34 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
623
624
|
})().finally(() => { outboxWork = undefined }))
|
|
624
625
|
}
|
|
625
626
|
|
|
627
|
+
// Delivery socket: cross-process producers (engine children, plugin
|
|
628
|
+
// children, host executor, CLIs) enqueue and query the memory ledger
|
|
629
|
+
// through the shared handler in delivery-socket.ts. The pump below stays
|
|
630
|
+
// the single delivery executor; the socket only admits work into it.
|
|
631
|
+
let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
|
|
632
|
+
const handleDeliveryOp = createLedgerHandler(config.controlDir, {
|
|
633
|
+
wake: () => { void drainOutbox() },
|
|
634
|
+
status: () => ({
|
|
635
|
+
polling: Boolean(bot?.isRunning()),
|
|
636
|
+
applicationOnly: !telegramEnabled,
|
|
637
|
+
telegramConfigured: config.telegramBotToken !== '',
|
|
638
|
+
version: packageVersion,
|
|
639
|
+
}),
|
|
640
|
+
})
|
|
641
|
+
|
|
626
642
|
const checkOwner = async (ctx: Context): Promise<boolean> => {
|
|
627
643
|
if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
|
|
628
644
|
const state = await control.status()
|
|
629
645
|
if (!telegramOwner(state.owner)) {
|
|
630
646
|
if (ctx.chat?.type !== 'private') return false
|
|
647
|
+
const applicationToken = ctx.message?.text?.trim().match(/^\/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]{43})$/)?.[1]
|
|
648
|
+
if (applicationToken) {
|
|
649
|
+
const linked = await applicationChannel.claimTelegramConnection(applicationToken, ctx.from.id, ctx.chat.id)
|
|
650
|
+
await ctx.reply(linked
|
|
651
|
+
? 'Telegram is connected to your Ez agent. You can keep chatting here.'
|
|
652
|
+
: 'This Telegram connection link expired or is no longer valid. Create a new one in the connected application.', { link_preview_options: { is_disabled: true } })
|
|
653
|
+
return false
|
|
654
|
+
}
|
|
631
655
|
const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
|
|
632
656
|
if (result === 'requested')
|
|
633
657
|
await ctx.reply('Owner approval is pending. Confirm this request through the local setup assistant.')
|
|
@@ -654,18 +678,29 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
654
678
|
const commands = [...mainCommands, ...(launcher ? [{ command: launcher.command, description: launcher.label }] : [])]
|
|
655
679
|
const controlCommand = (text?: string) => /^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
|
|
656
680
|
? '/rename' : text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
|
|
681
|
+
const tasksCommand = (text?: string): number | undefined | null => {
|
|
682
|
+
const match = text?.trim().match(/^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s+([1-9]\d*))?$/)
|
|
683
|
+
return match ? (match[1] ? Number(match[1]) : undefined) : null
|
|
684
|
+
}
|
|
685
|
+
const isTasksCommand = (text?: string): boolean => /^\/tasks(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
|
|
657
686
|
const statusKeyboard = () => new InlineKeyboard()
|
|
658
687
|
.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')
|
|
688
|
+
.text('Retry failed incoming message', 'menu:retry')
|
|
661
689
|
const scheduledTasks = async () => {
|
|
662
690
|
const owner = (await control.status()).owner
|
|
663
691
|
if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
|
|
664
692
|
// This intentionally uses the reader that does not create a schedules directory.
|
|
665
|
-
|
|
693
|
+
const active = await scheduler.listActiveReadOnly(await runs.list())
|
|
694
|
+
return { owner, active, text: scheduledTasksText(active, owner) }
|
|
666
695
|
}
|
|
667
|
-
const replyScheduledTasks = async (ctx: Context) => {
|
|
668
|
-
|
|
696
|
+
const replyScheduledTasks = async (ctx: Context, number?: number) => {
|
|
697
|
+
const result = await scheduledTasks()
|
|
698
|
+
const text = typeof result === 'string' ? result : number === undefined ? result.text : (() => {
|
|
699
|
+
const owned = ownedScheduledTasks(result.active, result.owner)
|
|
700
|
+
const schedule = owned[number - 1]
|
|
701
|
+
return schedule ? scheduledTaskDetailText(schedule, number, owned.length) : `Scheduled task ${number} was not found. Use /tasks to list active tasks.`
|
|
702
|
+
})()
|
|
703
|
+
for (const part of splitTelegramText(text)) await ctx.reply(part)
|
|
669
704
|
}
|
|
670
705
|
const statusText = async () => {
|
|
671
706
|
const running = await runs.running(false)
|
|
@@ -694,7 +729,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
694
729
|
'🟢 Ez is online',
|
|
695
730
|
'',
|
|
696
731
|
'System',
|
|
697
|
-
...await softwareStatus(config.controlDir),
|
|
732
|
+
...await softwareStatus(config.controlDir, config.isolation),
|
|
698
733
|
`AI: ${selected.name} (${presetLabel(displayedSelected)})`,
|
|
699
734
|
'',
|
|
700
735
|
'Work',
|
|
@@ -771,7 +806,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
771
806
|
}
|
|
772
807
|
const message = ctx.message
|
|
773
808
|
const command = controlCommand(message?.text)
|
|
774
|
-
if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) return next()
|
|
809
|
+
if ((command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) || tasksCommand(message?.text) !== null || isTasksCommand(message?.text)) return next()
|
|
775
810
|
const ordinary = message && (message.text || message.photo || message.document || message.voice)
|
|
776
811
|
const approval = ctx.callbackQuery?.data?.startsWith('approval:')
|
|
777
812
|
if (!ordinary && !approval) return next()
|
|
@@ -815,6 +850,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
815
850
|
return
|
|
816
851
|
}
|
|
817
852
|
|
|
853
|
+
const taskNumber = tasksCommand(ctx.message.text)
|
|
854
|
+
if (taskNumber !== null) {
|
|
855
|
+
await replyScheduledTasks(ctx, taskNumber)
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
if (isTasksCommand(ctx.message.text)) {
|
|
859
|
+
await ctx.reply('Usage: /tasks [number]. Use /tasks to list active tasks.')
|
|
860
|
+
return
|
|
861
|
+
}
|
|
862
|
+
|
|
818
863
|
if (text === '/help') {
|
|
819
864
|
await ctx.reply([...commands, ...aliases.filter((a) => !commands.some((c) => c.command === a.command))]
|
|
820
865
|
.map((c) => `/${c.command} — ${c.description}`).join('\n'))
|
|
@@ -881,19 +926,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
881
926
|
return
|
|
882
927
|
}
|
|
883
928
|
|
|
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
929
|
collectItem({
|
|
896
|
-
text:
|
|
930
|
+
text: ctx.message.text,
|
|
897
931
|
messageId: ctx.message.message_id,
|
|
898
932
|
updateId: ctx.update.update_id,
|
|
899
933
|
chatId: ctx.chat.id,
|
|
@@ -911,7 +945,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
911
945
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
912
946
|
const buffer = await downloadTelegramFile(fileUrl)
|
|
913
947
|
const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
|
|
914
|
-
const staged = await stageChatAttachment(config.
|
|
948
|
+
const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
|
|
915
949
|
collectItem({
|
|
916
950
|
text: staged.text,
|
|
917
951
|
attachment: staged.attachment,
|
|
@@ -938,7 +972,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
938
972
|
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
939
973
|
const buffer = await downloadTelegramFile(fileUrl)
|
|
940
974
|
const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
|
|
941
|
-
const staged = await stageChatAttachment(config.
|
|
975
|
+
const staged = await stageChatAttachment(config.controlDir, fileName, buffer, ctx.message.caption ?? '')
|
|
942
976
|
collectItem({
|
|
943
977
|
text: staged.text,
|
|
944
978
|
attachment: staged.attachment,
|
|
@@ -1104,6 +1138,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1104
1138
|
shuttingDown = true
|
|
1105
1139
|
runtimeStarted = false
|
|
1106
1140
|
pollingAbort.abort()
|
|
1141
|
+
await deliveryServer?.stop().catch(() => {})
|
|
1142
|
+
deliveryServer = null
|
|
1107
1143
|
await applicationChannel.stop()
|
|
1108
1144
|
wakePollRetry?.()
|
|
1109
1145
|
if (intakeTimer) clearTimeout(intakeTimer)
|
|
@@ -1119,7 +1155,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1119
1155
|
})
|
|
1120
1156
|
await Promise.all(completions)
|
|
1121
1157
|
await Promise.all([taskWork, outboxWork].map(work => work?.catch(() => {})))
|
|
1122
|
-
pagerDuty?.stop()
|
|
1123
1158
|
try {
|
|
1124
1159
|
if (bot?.isRunning()) await bot.stop()
|
|
1125
1160
|
} finally {
|
|
@@ -1130,10 +1165,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1130
1165
|
const start = async () => {
|
|
1131
1166
|
let failed = false
|
|
1132
1167
|
try {
|
|
1133
|
-
|
|
1168
|
+
// Workspace seeding and managed-block replacement happen at install or
|
|
1169
|
+
// upgrade only; relay startup never writes the agent workspace.
|
|
1170
|
+
// The delivery socket is the cross-process ledger transport and the
|
|
1171
|
+
// single-relay guard (replacing relay.lock): a live sibling answers the
|
|
1172
|
+
// ping and this relay refuses to split-brain the agent.
|
|
1173
|
+
if (await deliverySocketAlive(deliverySocketPath(config.controlDir)).catch(() => false))
|
|
1174
|
+
throw new Error(`Another relay owns ${config.controlDir}; stop it before starting a second one`)
|
|
1175
|
+
const deliveryTcpPort = Number(process.env.EZ_DELIVERY_TCP_PORT ?? '')
|
|
1176
|
+
deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
|
|
1177
|
+
...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
|
|
1178
|
+
})
|
|
1134
1179
|
if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
|
|
1135
1180
|
runtimeStarted = true
|
|
1136
|
-
pagerDuty?.start()
|
|
1137
1181
|
const owner = (await control.status()).owner
|
|
1138
1182
|
if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
|
|
1139
1183
|
await scheduler.recover(runs)
|
|
@@ -1210,7 +1254,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1210
1254
|
}
|
|
1211
1255
|
}
|
|
1212
1256
|
}
|
|
1213
|
-
return { bot: bot!, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
|
|
1257
|
+
return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
|
|
1214
1258
|
}
|
|
1215
1259
|
|
|
1216
1260
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
package/src/install-tools.mjs
CHANGED
|
@@ -4,6 +4,10 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
|
+
import { createServer } from 'node:net';
|
|
8
|
+
import { parseEnv } from 'node:util';
|
|
9
|
+
import { callDeliverySocket } from './delivery-socket-client.mjs';
|
|
10
|
+
import { ledgerOverlayName, ledgerOverlayYaml } from './ledger-endpoint.mjs';
|
|
7
11
|
|
|
8
12
|
const root=fileURLToPath(new URL('../',import.meta.url));
|
|
9
13
|
const read=async file=>JSON.parse(await fs.readFile(file,'utf8'));
|
|
@@ -52,49 +56,120 @@ export async function installationStatus(deployment) {
|
|
|
52
56
|
let isolated=false;
|
|
53
57
|
try { isolated=(await read(path.join(deployment,'agent.json'))).isolation==='isolated'; } catch { isolated=false; }
|
|
54
58
|
const runtimeReady=Boolean(relay?.polling&&fresh(relay,20000)&&(isolated||fresh(host,15000)));
|
|
59
|
+
// Delivery receipts live in relay memory and are re-authorized server-side;
|
|
60
|
+
// ask the running relay. A restart drops receipts by design.
|
|
55
61
|
let reply=null;
|
|
56
|
-
if(paired)
|
|
57
|
-
if(!name.endsWith('.sent.json'))continue;
|
|
58
|
-
const item=await read(path.join(control,'outbox',name));
|
|
59
|
-
if(!/^tg_\d+$/.test(item.runId||'')||item.chatId!==owner.telegramChatId||(item.type&&item.type!=='message')||!Array.isArray(item.receipt?.messageIds)||!item.receipt.messageIds.length||!item.receipt.messageIds.every(n=>Number.isSafeInteger(n)&&n>0))continue;
|
|
60
|
-
const delivered=Date.parse(item.receipt.deliveredAt);if(!Number.isFinite(delivered)||delivered<Date.parse(owner.pairedAt)||delivered>Date.now())continue;
|
|
61
|
-
const r=await read(path.join(control,'runs',item.runId+'.json')).catch(absent);
|
|
62
|
-
if(r?.status==='completed'&&!r.external&&r.chatId===owner.telegramChatId&&Number.isSafeInteger(r.telegramUserId)&&r.telegramUserId>0&&(owner.kind==='group'||r.telegramUserId===owner.telegramUserId)&&(!reply||delivered>Date.parse(reply.deliveredAt)))reply={runId:item.runId,messageIds:item.receipt.messageIds,deliveredAt:item.receipt.deliveredAt};
|
|
63
|
-
}
|
|
62
|
+
if(paired)reply=(await callDeliverySocket(path.join(control,'delivery.sock'),{op:'latestReply'},2000).catch(()=>null))?.reply??null;
|
|
64
63
|
return {deployment,configured,runtimeReady,ownerPaired:paired,telegramReplyVerified:Boolean(reply),reply,
|
|
65
64
|
stage:!configured?'not-configured':!runtimeReady?'runtime-offline':!paired?'awaiting-owner':!reply?'awaiting-telegram-reply':'ready-for-telegram-plugin-request',
|
|
66
|
-
note:'Read-only: a
|
|
65
|
+
note:'Read-only: a live receipt from the running relay is delivery evidence; it is not retained across a relay restart. Request plugins through the working Telegram conversation.'};
|
|
66
|
+
}
|
|
67
|
+
// Existing host-capable deployments created before the loopback endpoint need
|
|
68
|
+
// one idempotent migration: a published loopback port in docker.env plus the
|
|
69
|
+
// generated overlay that maps it. New agents receive both at create time.
|
|
70
|
+
const envLine = (text, key, value) => {
|
|
71
|
+
if (/['\r\n\0]/.test(value)) throw Error('Unsafe deployment value');
|
|
72
|
+
const line = `${key}='${value}'`;
|
|
73
|
+
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
|
74
|
+
return pattern.test(text) ? text.replace(pattern, () => line) : text.replace(/\n*$/, '\n') + line + '\n';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const freeLoopbackPort = async (start = 20000) => {
|
|
78
|
+
for (let port = start; port < start + 500 && port <= 65535; port++) {
|
|
79
|
+
const free = await new Promise(resolve => {
|
|
80
|
+
const probe = createServer();
|
|
81
|
+
probe.once('error', () => resolve(false));
|
|
82
|
+
probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)));
|
|
83
|
+
});
|
|
84
|
+
if (free) return port;
|
|
85
|
+
}
|
|
86
|
+
throw Error('No free loopback port in the deployment range');
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const ledgerPortStart = name => 20000 + createHash('sha256').update(name).digest().readUInt16BE(0) % 10000;
|
|
90
|
+
|
|
91
|
+
export async function migrateLedger({ deployment } = {}) {
|
|
92
|
+
if (!path.isAbsolute(deployment || '')) throw Error('Supply --deployment with the absolute deployment directory');
|
|
93
|
+
const directory = path.resolve(deployment), envFile = path.join(directory, 'docker.env');
|
|
94
|
+
const text = await fs.readFile(envFile, 'utf8').catch(error => { if (error.code === 'ENOENT') throw Error('Deployment docker.env not found'); throw error; });
|
|
95
|
+
const values = parseEnv(text);
|
|
96
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(values.COMPOSE_PROJECT_NAME || '')) throw Error('Invalid deployment COMPOSE_PROJECT_NAME');
|
|
97
|
+
if (values.EZ_ISOLATION === 'isolated' || values.EZ_EXECUTOR_TRANSPORT === 'local')
|
|
98
|
+
return { ok: true, deployment: directory, changed: false, note: 'Isolated deployment; no host ledger endpoint is needed.' };
|
|
99
|
+
if (!values.COMPOSE_FILE) throw Error('Deployment COMPOSE_FILE not found');
|
|
100
|
+
const overlay = path.join(directory, ledgerOverlayName);
|
|
101
|
+
const files = values.COMPOSE_FILE.split(path.delimiter).filter(Boolean);
|
|
102
|
+
const configured = values.EZ_DELIVERY_TCP_PORT !== undefined && files.includes(overlay) &&
|
|
103
|
+
await fs.stat(overlay).then(info => info.isFile(), () => false);
|
|
104
|
+
if (configured) return { ok: true, deployment: directory, changed: false, port: Number(values.EZ_DELIVERY_TCP_PORT) };
|
|
105
|
+
const port = values.EZ_DELIVERY_TCP_PORT !== undefined ? Number(values.EZ_DELIVERY_TCP_PORT) : await freeLoopbackPort(ledgerPortStart(values.COMPOSE_PROJECT_NAME));
|
|
106
|
+
if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw Error('Invalid EZ_DELIVERY_TCP_PORT');
|
|
107
|
+
const next = envLine(envLine(text, 'EZ_DELIVERY_TCP_PORT', String(port)), 'COMPOSE_FILE', [...new Set([...files, overlay])].join(path.delimiter));
|
|
108
|
+
// Publish the overlay before docker.env references it: a crash in between
|
|
109
|
+
// leaves an unreferenced file instead of a deployment Compose cannot start.
|
|
110
|
+
await fs.writeFile(overlay, ledgerOverlayYaml(), { mode: 0o600 });
|
|
111
|
+
const temporary = `${envFile}.${process.pid}.tmp`;
|
|
112
|
+
await fs.writeFile(temporary, next, { mode: 0o600 });
|
|
113
|
+
await fs.rename(temporary, envFile);
|
|
114
|
+
return { ok: true, deployment: directory, changed: true, port, overlay };
|
|
67
115
|
}
|
|
116
|
+
|
|
68
117
|
async function fingerprintOf(source,invoke) {
|
|
69
118
|
const listing=JSON.parse(await invoke('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:source}));
|
|
70
119
|
const hash=createHash('sha256');
|
|
71
120
|
for(const f of listing[0].files){const file=path.resolve(source,f.path);if(!file.startsWith(source+path.sep))throw Error('Invalid package path');const data=await fs.readFile(file);hash.update(JSON.stringify([f.path,f.mode,data.length]));hash.update(data);}
|
|
72
121
|
return hash.digest('hex');
|
|
73
122
|
}
|
|
74
|
-
|
|
123
|
+
// Source-checkout builds are RCs. The reviewed commit is the only SHA that may
|
|
124
|
+
// label an installable image, so a dirty tree can never become one and /status
|
|
125
|
+
// never falls back to the bare package version on a deployment.
|
|
126
|
+
export const rcLabel = /^\d+\.\d+\.\d+-beta\.\d+\.rc\.[1-9]\d*$/;
|
|
127
|
+
async function buildIdentity(source,label,invoke) {
|
|
128
|
+
const git=async args=>{try{return await invoke('git',['-C',source,...args]);}catch{return undefined;}};
|
|
129
|
+
// The package root itself must be the checkout root; a tarball unpacked
|
|
130
|
+
// inside an unrelated repository is not a source checkout.
|
|
131
|
+
const top=await git(['rev-parse','--show-toplevel']);
|
|
132
|
+
const atRoot=Boolean(top)&&await fs.realpath(top).then(value=>value===source,()=>false);
|
|
133
|
+
if(!atRoot) {
|
|
134
|
+
if(label!==undefined)throw Error('--label requires a git checkout so the RC pins the reviewed commit');
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
if(label===undefined)throw Error('A source checkout requires --label X.Y.Z-beta.N.rc.M; commit, push and open the PR first');
|
|
138
|
+
if(!rcLabel.test(label))throw Error('RC label must look like X.Y.Z-beta.N.rc.M');
|
|
139
|
+
if((await git(['status','--porcelain']))!=='')throw Error('Commit the reviewed source before an RC build; uncommitted work cannot be installed as an RC');
|
|
140
|
+
const sha=await git(['rev-parse','HEAD']);
|
|
141
|
+
if(!/^[a-f0-9]{40}$/.test(sha||''))throw Error('RC builds require the reviewed commit SHA');
|
|
142
|
+
return {label,sha};
|
|
143
|
+
}
|
|
144
|
+
export async function build({home=defaultHome(),source=root,label},invoke=run) {
|
|
75
145
|
source=await fs.realpath(source);
|
|
76
|
-
const
|
|
77
|
-
const
|
|
146
|
+
const identity=await buildIdentity(source,label,invoke);
|
|
147
|
+
const fingerprint=await fingerprintOf(source,invoke);
|
|
148
|
+
const artifact=createHash('sha256').update(JSON.stringify([fingerprint,identity.label??'',identity.sha??''])).digest('hex');
|
|
149
|
+
const image='ezenciel-agents:install-'+artifact.slice(0,24);
|
|
150
|
+
const dir=path.join(path.resolve(home),'builds',artifact);await fs.mkdir(dir,{recursive:true,mode:0o700});
|
|
78
151
|
const lock=path.join(dir,'lock'),receipt=path.join(dir,'status.json'),log=path.join(dir,'build.log');
|
|
79
152
|
let handle;
|
|
80
153
|
try{handle=await fs.open(lock,'wx',0o600);}catch(e){if(e.code!=='EEXIST')throw e;return {state:'busy-or-interrupted',image,log,note:'An existing build owns this artifact. Inspect its status/log and owning process; do not start another build or remove a live lock.'};}
|
|
81
154
|
try {
|
|
82
|
-
await handle.writeFile(JSON.stringify({pid:process.pid,source,image}));
|
|
155
|
+
await handle.writeFile(JSON.stringify({pid:process.pid,source,image,...identity}));
|
|
83
156
|
const prior=await read(receipt).catch(absent);
|
|
84
157
|
if(prior?.state==='completed')try{if(await invoke('docker',['image','inspect','--format','{{.Id}}',image])===prior.imageId)return {...prior,reused:true};}catch{/* Image was removed; rebuild under the same exclusive lock. */}
|
|
85
|
-
await atomic(receipt,{state:'building',pid:process.pid,source,image,log});
|
|
86
|
-
console.error(JSON.stringify({state:'building',image,log}));
|
|
158
|
+
await atomic(receipt,{state:'building',pid:process.pid,source,image,log,...identity});
|
|
159
|
+
console.error(JSON.stringify({state:'building',image,log,...identity}));
|
|
160
|
+
const buildArgs=identity.label?['--build-arg',`BUILD_TAG=${identity.label}`,'--build-arg',`BUILD_SHA=${identity.sha}`]:[];
|
|
161
|
+
const tags=[image,...(identity.label?[`ezenciel-agents:${identity.label}`]:[])];
|
|
87
162
|
const output=await fs.open(log,'a',0o600);
|
|
88
|
-
try{await invoke('docker',['build','--progress','plain','--target','runtime'
|
|
163
|
+
try{await invoke('docker',['build','--progress','plain','--target','runtime',...buildArgs,...tags.flatMap(t=>['-t',t]),source],{log:output.fd,timeout:3600000});}finally{await output.close();}
|
|
89
164
|
if(await fingerprintOf(source,invoke)!==fingerprint)throw Error('Package changed during build; keep the source stable and retry');
|
|
90
|
-
const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log};await atomic(receipt,result);return result;
|
|
91
|
-
}catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message});throw e;}
|
|
165
|
+
const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log,...identity};await atomic(receipt,result);return result;
|
|
166
|
+
}catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message,...identity});throw e;}
|
|
92
167
|
finally{await handle.close();await fs.rm(lock);}
|
|
93
168
|
}
|
|
94
169
|
export async function main(args) {
|
|
95
170
|
const [action,...rest]=args;const options={};
|
|
96
|
-
if(!action||action==='--help'){console.log(JSON.stringify({commands:['preflight --executor <name-or-absolute-path> [--home PATH]','build [--home PATH]','status --deployment PATH'],note:'Host prerequisites and main-agent diagnostics only
|
|
97
|
-
for(let i=0;i<rest.length;i+=2){if(!['--home','--executor','--deployment'].includes(rest[i])||!rest[i+1]||Object.hasOwn(options,rest[i].slice(2)))throw Error('Invalid arguments');options[rest[i].slice(2)]=rest[i+1];}
|
|
98
|
-
const allowed={preflight:['home','executor'],build:['home'],status:['deployment']};if(!allowed[action]||Object.keys(options).some(k=>!allowed[action].includes(k)))throw Error('Invalid action/options');
|
|
99
|
-
const result=action==='preflight'?await preflight(options):action==='build'?await build(options):await installationStatus(options.deployment);console.log(JSON.stringify(result));if(result.ok===false)process.exitCode=1;
|
|
171
|
+
if(!action||action==='--help'){console.log(JSON.stringify({commands:['preflight --executor <name-or-absolute-path> [--home PATH]','build [--home PATH] [--label X.Y.Z-beta.N.rc.M]','migrate-ledger --deployment PATH','status --deployment PATH'],note:'Source checkouts build RCs only: commit and open the PR first, then pass the next --label. migrate-ledger adds the loopback host endpoint to an existing host-capable deployment. Host prerequisites and main-agent diagnostics only; initialize an empty registry and verify Telegram before asking the installed agent to add plugins.'}));return;}
|
|
172
|
+
for(let i=0;i<rest.length;i+=2){if(!['--home','--executor','--deployment','--label'].includes(rest[i])||!rest[i+1]||Object.hasOwn(options,rest[i].slice(2)))throw Error('Invalid arguments');options[rest[i].slice(2)]=rest[i+1];}
|
|
173
|
+
const allowed={preflight:['home','executor'],build:['home','label'],'migrate-ledger':['deployment'],status:['deployment']};if(!allowed[action]||Object.keys(options).some(k=>!allowed[action].includes(k)))throw Error('Invalid action/options');
|
|
174
|
+
const result=action==='preflight'?await preflight(options):action==='build'?await build(options):action==='migrate-ledger'?await migrateLedger(options):await installationStatus(options.deployment);console.log(JSON.stringify(result));if(result.ok===false)process.exitCode=1;
|
|
100
175
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// One source for the host-capable ledger overlay: agent creation, the
|
|
2
|
+
// deployment migration and its tests must emit the same compose fragment.
|
|
3
|
+
export const ledgerOverlayName = 'ledger.compose.yaml'
|
|
4
|
+
|
|
5
|
+
export const ledgerOverlayYaml = () => [
|
|
6
|
+
'# Host-capable execution: on Docker Desktop/OrbStack the control-volume',
|
|
7
|
+
'# Unix socket is not connectable from the host, so the relay serves its',
|
|
8
|
+
'# memory ledger on this published loopback port and requests authenticate',
|
|
9
|
+
'# with the per-run token in the control directory.',
|
|
10
|
+
'services:',
|
|
11
|
+
' relay:',
|
|
12
|
+
' environment:',
|
|
13
|
+
' EZ_DELIVERY_TCP_PORT: ${EZ_DELIVERY_TCP_PORT:?}',
|
|
14
|
+
' ports:',
|
|
15
|
+
' - "127.0.0.1:${EZ_DELIVERY_TCP_PORT:?}:${EZ_DELIVERY_TCP_PORT:?}"',
|
|
16
|
+
'',
|
|
17
|
+
].join('\n')
|
package/src/menu.ts
CHANGED
|
@@ -13,12 +13,13 @@ export const mainCommands = [
|
|
|
13
13
|
{ command: 'rename', description: 'Rename current conversation' },
|
|
14
14
|
{ command: 'ai', description: 'Choose AI' },
|
|
15
15
|
{ command: 'status', description: 'Work status' },
|
|
16
|
+
{ command: 'tasks', description: 'Scheduled tasks' },
|
|
16
17
|
]
|
|
17
18
|
|
|
18
19
|
export const mainKeyboard = () => new InlineKeyboard()
|
|
19
20
|
.text('New conversation', 'menu:new').text('Conversations', 'menu:chats').row()
|
|
20
|
-
.text('Choose AI', 'menu:ai')
|
|
21
|
-
.text('Work status', 'menu:status')
|
|
21
|
+
.text('Choose AI', 'menu:ai').row()
|
|
22
|
+
.text('Work status', 'menu:status').text('Scheduled tasks', 'menu:scheduled-tasks')
|
|
22
23
|
|
|
23
24
|
const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
|
|
24
25
|
|
|
@@ -29,10 +30,14 @@ const matchesModel = (preset: AiPreset, model: ModelChoice) =>
|
|
|
29
30
|
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
30
31
|
// These are operational settings, not a second conversational/agent loop.
|
|
31
32
|
export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
|
|
32
|
-
isInstalled = installed) => {
|
|
33
|
+
isInstalled = installed, opencodeDataHome?: string) => {
|
|
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, undefined, opencodeDataHome, undefined, process.env.EZ_CONTROL_DIR)
|
|
36
41
|
const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome }))
|
|
37
42
|
const validate = async (preset: AiPreset) => {
|
|
38
43
|
assertEffort(preset.effort, preset.model, preset.cli)
|
package/src/message-history.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join, basename } from 'node:path'
|
|
1
|
+
import { basename } from 'node:path'
|
|
3
2
|
import { requireOwnerExecution } from './execution-authority.js'
|
|
4
3
|
import { ControlStore } from './control-state.js'
|
|
5
4
|
import { ownsRun } from './identity.js'
|
|
6
|
-
import { RunStore } from './runs.js'
|
|
5
|
+
import { RunStore, sentOutbox } from './runs.js'
|
|
7
6
|
|
|
8
|
-
// Read existing delivery receipts
|
|
7
|
+
// Read existing delivery receipts from the relay memory ledger; native
|
|
8
|
+
// sessions still own conversation history. Explicit tool path only (message
|
|
9
|
+
// history CLI / delivery socket history op), never ambient prompt enrichment.
|
|
9
10
|
export async function deliveredMessages(controlDir: string, runId: string, options: { limit?: number; messageId?: number } = {}) {
|
|
10
11
|
const limit = options.limit ?? 8
|
|
11
12
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error('Limit must be 1..50')
|
|
@@ -20,26 +21,20 @@ export async function deliveredMessages(controlDir: string, runId: string, optio
|
|
|
20
21
|
ownsRun(owner, run) && !run.external && !run.taskId && !run.application &&
|
|
21
22
|
Date.parse(run.createdAt) >= pairedAt && (!run.scheduled || run.scheduled.pairedAt === owner!.pairedAt)
|
|
22
23
|
).map(run => [run.id, run]))
|
|
23
|
-
const directory = join(controlDir, 'outbox')
|
|
24
|
-
const files = await readdir(directory).catch((error: NodeJS.ErrnoException) => {
|
|
25
|
-
if (error.code === 'ENOENT') return []
|
|
26
|
-
throw error
|
|
27
|
-
})
|
|
28
24
|
const messages = []
|
|
29
|
-
for (const
|
|
30
|
-
//
|
|
31
|
-
let item
|
|
32
|
-
try { item = JSON.parse(await readFile(join(directory, file), 'utf8')) } catch { continue }
|
|
25
|
+
for (const item of sentOutbox(controlDir)) {
|
|
26
|
+
// Incomplete receipts are not delivery evidence.
|
|
33
27
|
if (!item || typeof item !== 'object') continue
|
|
34
|
-
const run = runs.get(item.runId)
|
|
35
|
-
const
|
|
36
|
-
const
|
|
28
|
+
const run = item.runId === undefined ? undefined : runs.get(item.runId)
|
|
29
|
+
const receipt = (item.receipt ?? {}) as { deliveredAt?: unknown; messageIds?: unknown }
|
|
30
|
+
const deliveredAt = Date.parse(receipt.deliveredAt as string)
|
|
31
|
+
const ids = receipt.messageIds
|
|
37
32
|
if (!run || item.chatId !== caller.chatId || !Number.isFinite(deliveredAt) || deliveredAt < pairedAt ||
|
|
38
33
|
!Array.isArray(ids) || !ids.length || !ids.every(id => Number.isSafeInteger(id) && id > 0)) continue
|
|
39
|
-
if (options.messageId !== undefined && !ids.includes(options.messageId)) continue
|
|
34
|
+
if (options.messageId !== undefined && !(ids as number[]).includes(options.messageId)) continue
|
|
40
35
|
messages.push({
|
|
41
36
|
runId: run.id, sessionId: run.execution?.sessionId, nativeSessionId: run.nativeSessionId,
|
|
42
|
-
scheduleId: run.scheduled?.id, messageIds: ids as number[], deliveredAt:
|
|
37
|
+
scheduleId: run.scheduled?.id, messageIds: ids as number[], deliveredAt: receipt.deliveredAt as string,
|
|
43
38
|
type: item.type || 'message', text: typeof item.text === 'string' ? item.text
|
|
44
39
|
: typeof item.approvalPrompt === 'string' ? item.approvalPrompt : undefined,
|
|
45
40
|
voiceText: typeof item.voiceText === 'string' ? item.voiceText : undefined,
|