@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.28
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/.env.example +10 -1
- package/AGENTS.md +40 -9
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +31 -1
- package/Dockerfile +1 -0
- package/README.md +84 -12
- package/bin/ezenciel-agents-application +2 -0
- package/bin/ezenciel-agents-application.mjs +16 -0
- package/compose.yaml +8 -0
- package/docker/entrypoint.sh +20 -2
- package/docker/healthcheck.mjs +1 -1
- package/docker/run.ts +3 -3
- package/docker/smoke.mjs +41 -2
- package/docs/application-channel.md +366 -0
- package/docs/architecture/ai-selection.md +12 -15
- package/docs/docker-runtime.md +29 -0
- package/docs/host-service.md +5 -8
- package/docs/local-qa.md +1 -1
- package/docs/managed-applications.md +68 -0
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugin-connection.md +76 -0
- package/docs/plugins.md +54 -5
- package/docs/repair.md +26 -25
- package/docs/responsive-channels.md +13 -55
- package/docs/scheduling.md +40 -36
- package/docs/setup.md +11 -21
- package/docs/standalone-cli.md +2 -2
- package/docs/upgrades.md +43 -18
- package/package.json +8 -4
- package/src/agent-guidance.ts +32 -3
- package/src/ai-cli.ts +5 -1
- package/src/ai.ts +6 -28
- package/src/application-channel.ts +308 -0
- package/src/application-cli.ts +41 -0
- package/src/application-client.mjs +87 -0
- package/src/application-origin.ts +15 -0
- package/src/codex-session.ts +7 -10
- package/src/config.ts +23 -5
- package/src/control-state.ts +274 -21
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/desktop-bridge.ts +11 -43
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +29 -58
- package/src/host-executor.ts +11 -9
- package/src/identity.ts +11 -3
- package/src/index.ts +191 -93
- package/src/menu.ts +76 -55
- package/src/message-history.ts +52 -0
- package/src/message-send.ts +1 -1
- package/src/message.ts +49 -7
- package/src/model-policy.ts +5 -15
- package/src/owner.ts +7 -1
- package/src/plugins/connection-artifacts.mjs +31 -0
- package/src/plugins/connection.mjs +124 -0
- package/src/plugins/manager.mjs +93 -23
- package/src/plugins/native-tasks.d.mts +4 -0
- package/src/plugins/native-tasks.mjs +66 -0
- package/src/plugins/workspace-lease.d.mts +3 -0
- package/src/plugins/workspace-lease.mjs +44 -0
- package/src/repair-policy.ts +0 -8
- package/src/reply-context.ts +3 -29
- package/src/runs.ts +67 -9
- package/src/schedule-cli.ts +33 -15
- package/src/scheduled-tasks.ts +20 -21
- package/src/scheduler.ts +55 -22
- package/src/task-executor.ts +4 -5
- package/src/task-workspace.ts +2 -11
- package/src/update-attention.ts +1 -1
- package/src/updates/binding.mjs +2 -6
- package/src/updates/control.mjs +4 -0
- package/src/updates/supervisor.mjs +10 -4
- package/src/web-launcher.ts +19 -0
- package/src/workspace.ts +3 -1
- package/templates/agent/AGENTS.md +13 -55
- package/templates/agent-guidance.md +90 -37
- package/templates/deployments.md +24 -0
- package/templates/failure-review.md +6 -0
- package/templates/maintainer-purpose.md +12 -6
- package/test/agent-guidance.test.ts +29 -39
- package/test/ai-cli.test.ts +9 -0
- package/test/ai.test.ts +66 -22
- package/test/application-channel.test.ts +283 -0
- package/test/application-client.test.mjs +84 -0
- package/test/application-controls.test.ts +224 -0
- package/test/application-only.test.ts +100 -0
- package/test/busy-reply-relay.test.ts +11 -7
- package/test/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/client-defaults.test.ts +1 -1
- package/test/codex-session.test.ts +18 -10
- package/test/config.test.ts +16 -1
- package/test/connection-artifacts.test.mjs +32 -0
- package/test/conversation-menu.test.ts +67 -0
- package/test/conversations.test.ts +84 -0
- package/test/desktop-bridge.test.ts +17 -11
- package/test/engine-handoff.test.ts +73 -0
- package/test/event-sources.test.ts +5 -8
- package/test/executor.test.ts +68 -16
- package/test/failure.test.ts +64 -0
- package/test/host-executor.test.ts +58 -17
- package/test/install-config.test.ts +1 -1
- package/test/intake-relay.test.ts +169 -25
- package/test/message-history.test.ts +127 -0
- package/test/model-policy.test.ts +23 -48
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +70 -10
- package/test/repair-policy.test.ts +8 -12
- package/test/runs.test.ts +13 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/schedule-cli.test.ts +34 -5
- package/test/scheduled-tasks.test.ts +79 -8
- package/test/scheduler.test.ts +30 -1
- package/test/task-native.test.ts +5 -2
- package/test/update-attention.test.ts +1 -2
- package/test/updates.test.mjs +44 -5
- package/test/workspace.test.ts +2 -3
- package/scripts/smoke-busy-reply.ts +0 -58
- package/src/reply-executor.ts +0 -55
- package/src/reply-mcp.ts +0 -23
- package/templates/agent/TOOLS.md +0 -105
- package/templates/chat-guidance.md +0 -23
- package/templates/standalone-tools.md +0 -20
- package/templates/updates.md +0 -45
- package/test/reply.test.ts +0 -159
package/src/menu.ts
CHANGED
|
@@ -3,24 +3,33 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { randomBytes } from 'node:crypto'
|
|
5
5
|
import { InlineKeyboard, type Context } from 'grammy'
|
|
6
|
-
import { ControlStore } from './control-state.js'
|
|
7
|
-
import { chatPreset, presetLabel, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
6
|
+
import { ControlStore, type ControlGuard } from './control-state.js'
|
|
7
|
+
import { chatPreset, installed, persistedPreset, presetLabel, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
8
8
|
import { discoverDefaults } from './client-defaults.js'
|
|
9
9
|
|
|
10
10
|
export const mainCommands = [
|
|
11
11
|
{ command: 'new', description: 'New conversation' },
|
|
12
|
+
{ command: 'chats', description: 'Conversations' },
|
|
13
|
+
{ command: 'rename', description: 'Rename current conversation' },
|
|
12
14
|
{ command: 'ai', description: 'Choose AI' },
|
|
13
15
|
{ command: 'status', description: 'Work status' },
|
|
14
|
-
{ command: 'settings', description: 'Settings' },
|
|
15
16
|
]
|
|
16
17
|
|
|
17
18
|
export const mainKeyboard = () => new InlineKeyboard()
|
|
18
|
-
.text('New conversation', 'menu:new').text('
|
|
19
|
-
.text('
|
|
19
|
+
.text('New conversation', 'menu:new').text('Conversations', 'menu:chats').row()
|
|
20
|
+
.text('Choose AI', 'menu:ai')
|
|
21
|
+
.text('Work status', 'menu:status')
|
|
22
|
+
|
|
23
|
+
const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
|
|
24
|
+
|
|
25
|
+
const matchesModel = (preset: AiPreset, model: ModelChoice) =>
|
|
26
|
+
model.cli === preset.cli && (preset.model === undefined || model.model === preset.model) &&
|
|
27
|
+
(preset.effort === undefined || model.efforts.includes(preset.effort))
|
|
20
28
|
|
|
21
29
|
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
22
30
|
// These are operational settings, not a second conversational/agent loop.
|
|
23
|
-
export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string
|
|
31
|
+
export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
|
|
32
|
+
isInstalled = installed) => {
|
|
24
33
|
const initial = chatPreset(cli)
|
|
25
34
|
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
26
35
|
if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
|
|
@@ -31,7 +40,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
31
40
|
if (preset.id.startsWith('detected_')) {
|
|
32
41
|
const detected = await discoverDefaults(workspace, { codexHome })
|
|
33
42
|
if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
|
|
34
|
-
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) :
|
|
43
|
+
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
35
44
|
}
|
|
36
45
|
const buttons = new Map<string, { expires: number; action: (ctx: Context) => Promise<void> }>()
|
|
37
46
|
const button = (keyboard: InlineKeyboard, label: string, action: (ctx: Context) => Promise<void>) => {
|
|
@@ -41,85 +50,97 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
41
50
|
buttons.set(id, { expires: Date.now() + 15 * 60_000, action })
|
|
42
51
|
keyboard.text(label.slice(0, 64), `ai:${id}`).row()
|
|
43
52
|
}
|
|
44
|
-
const
|
|
53
|
+
const select = async (preset: AiPreset, expectedSession: string | null, guard?: ControlGuard) => {
|
|
45
54
|
await validate(preset)
|
|
46
55
|
const session = await control.getActiveSession()
|
|
47
|
-
const state = await control.
|
|
56
|
+
const state = (await control.status()).ai
|
|
57
|
+
if (!state) throw new Error('AI settings not initialized')
|
|
48
58
|
const current = state.presets.find((p) => p.id === state.selectedId)!
|
|
49
59
|
const fresh = current.cli !== preset.cli || Boolean(session && !session.cli)
|
|
50
|
-
await control.selectPreset(preset.id,
|
|
60
|
+
if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
|
|
61
|
+
return { preset, fresh }
|
|
62
|
+
}
|
|
63
|
+
const choose = async (ctx: Context, preset: AiPreset) => {
|
|
64
|
+
const session = await control.getActiveSession()
|
|
65
|
+
const { fresh } = await select(preset, session?.sessionId ?? null)
|
|
51
66
|
await ctx.reply(`${preset.name}\n${presetLabel(preset)}\n${fresh
|
|
52
67
|
? 'CLI changed: fresh conversation. Files kept; queued work unchanged.'
|
|
53
68
|
: 'Selected for this conversation. Queued work unchanged.'}`)
|
|
54
69
|
}
|
|
55
|
-
const list = async (ctx: Context
|
|
56
|
-
|
|
57
|
-
const models = await catalog()
|
|
58
|
-
if (models.length) return available(ctx, 0, models)
|
|
59
|
-
}
|
|
70
|
+
const list = async (ctx: Context) => {
|
|
71
|
+
const models = await catalog()
|
|
60
72
|
const state = await control.aiState(initial)
|
|
61
73
|
const keyboard = new InlineKeyboard()
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
if (models.length) {
|
|
75
|
+
const recent = (state.recentIds ?? [])
|
|
76
|
+
.map((id) => state.presets.find((preset) => preset.id === id))
|
|
77
|
+
.filter((preset): preset is AiPreset => Boolean(preset))
|
|
78
|
+
.filter((preset) => models.some((model) => matchesModel(preset, model)))
|
|
79
|
+
.slice(0, 3)
|
|
80
|
+
for (const preset of recent) button(keyboard,
|
|
81
|
+
`${preset.id === state.selectedId ? '✓ ' : ''}Recent · ${clientLabel(preset.cli)} · ${preset.name}`,
|
|
82
|
+
(next) => choose(next, preset))
|
|
83
|
+
for (const cli of [...new Set(models.map((model) => model.cli))].sort((a, b) => clientLabel(a).localeCompare(clientLabel(b))))
|
|
84
|
+
button(keyboard, clientLabel(cli), (next) => available(next, cli, 0, models))
|
|
85
|
+
} else {
|
|
86
|
+
const current = state.presets.find((preset) => preset.id === initial.id)
|
|
87
|
+
if (current) button(keyboard, `✓ ${current.name}`, (next) => choose(next, current))
|
|
88
|
+
}
|
|
73
89
|
button(keyboard, 'Refresh available AIs', async (next) => {
|
|
74
90
|
await refresh()
|
|
75
|
-
await list(next
|
|
91
|
+
await list(next)
|
|
76
92
|
})
|
|
77
|
-
await ctx.reply(
|
|
78
|
-
? '
|
|
93
|
+
await ctx.reply(models.length
|
|
94
|
+
? 'Choose AI\nUse a recent choice or select an installed client, then choose its model and reasoning level.'
|
|
79
95
|
: 'Choose AI\nNo client catalog available. Showing the current client setup only.', { reply_markup: keyboard })
|
|
80
96
|
}
|
|
81
|
-
const available = async (ctx: Context, page = 0, listed?: ModelChoice[]) => {
|
|
82
|
-
const models = listed ?? await catalog()
|
|
97
|
+
const available = async (ctx: Context, cli: string, page = 0, listed?: ModelChoice[]) => {
|
|
98
|
+
const models = (listed ?? await catalog()).filter((model) => model.cli === cli)
|
|
83
99
|
const keyboard = new InlineKeyboard()
|
|
84
100
|
for (const model of models.slice(page * 8, page * 8 + 8)) {
|
|
85
|
-
button(keyboard,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
101
|
+
button(keyboard, model.name, async (next) => {
|
|
102
|
+
const supportedEfforts = model.efforts.filter(effort => allowedEffort(effort, model.model, model.cli))
|
|
103
|
+
if (!supportedEfforts.length) return save(next, model)
|
|
104
|
+
const effortKeyboard = new InlineKeyboard()
|
|
105
|
+
for (const effort of supportedEfforts) button(effortKeyboard, effort, (last) => save(last, model, effort))
|
|
106
|
+
button(effortKeyboard, 'Back to models', (last) => available(last, cli, page, listed))
|
|
107
|
+
await next.reply(`${clientLabel(cli)} · ${model.name}\nChoose reasoning level`, { reply_markup: effortKeyboard })
|
|
90
108
|
})
|
|
91
109
|
}
|
|
92
|
-
if (page > 0) button(keyboard, 'Previous', (next) => available(next, page - 1))
|
|
93
|
-
if (models.length > (page + 1) * 8) button(keyboard, 'Next', (next) => available(next, page + 1))
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
: 'No client catalog available. Open the installed CLI once, then try again.', { reply_markup: keyboard })
|
|
110
|
+
if (page > 0) button(keyboard, 'Previous', (next) => available(next, cli, page - 1, listed))
|
|
111
|
+
if (models.length > (page + 1) * 8) button(keyboard, 'Next', (next) => available(next, cli, page + 1, listed))
|
|
112
|
+
button(keyboard, 'Back to clients', (next) => list(next))
|
|
113
|
+
await ctx.reply(`${clientLabel(cli)}\nChoose a model`, { reply_markup: keyboard })
|
|
97
114
|
}
|
|
98
|
-
const
|
|
99
|
-
const state = await control.
|
|
100
|
-
|
|
101
|
-
const
|
|
115
|
+
const saveSelection = async (model: ModelChoice, effort?: string, guard?: ControlGuard) => {
|
|
116
|
+
const state = (await control.status()).ai
|
|
117
|
+
if (!state) throw new Error('AI settings not initialized')
|
|
118
|
+
const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
|
|
102
119
|
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, model: model.model, effort }
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
await ctx
|
|
120
|
+
const stored = persistedPreset(candidate)
|
|
121
|
+
const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.model === stored.model && preset.effort === stored.effort)
|
|
122
|
+
const preset = existing ?? candidate
|
|
123
|
+
await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
124
|
+
await control.savePreset(preset, guard)
|
|
125
|
+
return preset
|
|
126
|
+
}
|
|
127
|
+
const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
|
|
128
|
+
await choose(ctx, await saveSelection(model, effort))
|
|
112
129
|
}
|
|
113
130
|
return {
|
|
114
131
|
initial,
|
|
115
132
|
refresh,
|
|
133
|
+
validate,
|
|
134
|
+
catalog: () => catalog(),
|
|
135
|
+
select,
|
|
136
|
+
saveSelection,
|
|
116
137
|
list,
|
|
117
138
|
async handle(ctx: Context): Promise<boolean> {
|
|
118
139
|
const data = ctx.callbackQuery?.data
|
|
119
140
|
if (!data?.startsWith('ai:')) return false
|
|
120
141
|
const entry = buttons.get(data.slice(3))
|
|
121
142
|
await ctx.answerCallbackQuery().catch(() => {})
|
|
122
|
-
if (!entry || entry.expires < Date.now()) await ctx.reply('Menu expired. Open /ai
|
|
143
|
+
if (!entry || entry.expires < Date.now()) await ctx.reply('Menu expired. Open /ai again.')
|
|
123
144
|
else {
|
|
124
145
|
buttons.delete(data.slice(3))
|
|
125
146
|
try { await entry.action(ctx) }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
2
|
+
import { join, basename } from 'node:path'
|
|
3
|
+
import { requireOwnerExecution } from './execution-authority.js'
|
|
4
|
+
import { ControlStore } from './control-state.js'
|
|
5
|
+
import { ownsRun } from './identity.js'
|
|
6
|
+
import { RunStore } from './runs.js'
|
|
7
|
+
|
|
8
|
+
// Read existing delivery receipts; native sessions still own conversation history.
|
|
9
|
+
export async function deliveredMessages(controlDir: string, runId: string, options: { limit?: number; messageId?: number } = {}) {
|
|
10
|
+
const limit = options.limit ?? 8
|
|
11
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error('Limit must be 1..50')
|
|
12
|
+
if (options.messageId !== undefined && (!Number.isSafeInteger(options.messageId) || options.messageId < 1))
|
|
13
|
+
throw new Error('Message ID must be a positive integer')
|
|
14
|
+
const caller = await requireOwnerExecution(controlDir, runId)
|
|
15
|
+
if (caller.application || caller.delivery) throw new Error('History requires a Telegram owner run')
|
|
16
|
+
const owner = (await new ControlStore(controlDir, 900_000).status()).owner
|
|
17
|
+
if (!ownsRun(owner, caller)) throw new Error('Owner binding changed')
|
|
18
|
+
const pairedAt = Date.parse(owner!.pairedAt)
|
|
19
|
+
const runs = new Map((await new RunStore(controlDir).list()).filter(run =>
|
|
20
|
+
ownsRun(owner, run) && !run.external && !run.taskId && !run.application &&
|
|
21
|
+
Date.parse(run.createdAt) >= pairedAt && (!run.scheduled || run.scheduled.pairedAt === owner!.pairedAt)
|
|
22
|
+
).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
|
+
const messages = []
|
|
29
|
+
for (const file of files.filter(file => file.endsWith('.sent.json'))) {
|
|
30
|
+
// Corrupt or incomplete receipts are not delivery evidence.
|
|
31
|
+
let item
|
|
32
|
+
try { item = JSON.parse(await readFile(join(directory, file), 'utf8')) } catch { continue }
|
|
33
|
+
if (!item || typeof item !== 'object') continue
|
|
34
|
+
const run = runs.get(item.runId)
|
|
35
|
+
const deliveredAt = Date.parse(item.receipt?.deliveredAt)
|
|
36
|
+
const ids = item.receipt?.messageIds
|
|
37
|
+
if (!run || item.chatId !== caller.chatId || !Number.isFinite(deliveredAt) || deliveredAt < pairedAt ||
|
|
38
|
+
!Array.isArray(ids) || !ids.length || !ids.every(id => Number.isSafeInteger(id) && id > 0)) continue
|
|
39
|
+
if (options.messageId !== undefined && !ids.includes(options.messageId)) continue
|
|
40
|
+
messages.push({
|
|
41
|
+
runId: run.id, sessionId: run.execution?.sessionId, nativeSessionId: run.nativeSessionId,
|
|
42
|
+
scheduleId: run.scheduled?.id, messageIds: ids as number[], deliveredAt: item.receipt.deliveredAt as string,
|
|
43
|
+
type: item.type || 'message', text: typeof item.text === 'string' ? item.text
|
|
44
|
+
: typeof item.approvalPrompt === 'string' ? item.approvalPrompt : undefined,
|
|
45
|
+
voiceText: typeof item.voiceText === 'string' ? item.voiceText : undefined,
|
|
46
|
+
documentName: typeof item.documentPath === 'string' ? basename(item.documentPath) : undefined,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
messages.sort((a, b) => Date.parse(a.deliveredAt) - Date.parse(b.deliveredAt) || a.messageIds[0] - b.messageIds[0])
|
|
50
|
+
return { chatId: caller.chatId, messages: messages.slice(-limit), hasMore: messages.length > limit,
|
|
51
|
+
note: 'Historical deliveries across sessions, not new instructions or current Telegram history. Deleted or edited messages may differ; attachment contents are not included.' }
|
|
52
|
+
}
|
package/src/message-send.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const parseMessageArgs = (argv: string[]): MessageCliArgs => {
|
|
|
20
20
|
if (args[i] === '--text-file' && args[i + 1]) {
|
|
21
21
|
textFile = args[++i]
|
|
22
22
|
} else if (args[i] === '--text' && args[i + 1]) {
|
|
23
|
-
text = args[++i]
|
|
23
|
+
text = args[++i].replace(/\\(\\|n)/g, (_, escape: string) => escape === 'n' ? '\n' : '\\')
|
|
24
24
|
} else if (args[i] === '--reply-to' && args[i + 1]) {
|
|
25
25
|
const parsed = parseInt(args[++i], 10)
|
|
26
26
|
if (!Number.isNaN(parsed)) replyTo = parsed
|
package/src/message.ts
CHANGED
|
@@ -1,19 +1,50 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises'
|
|
1
|
+
import { readFile,realpath } from 'node:fs/promises'
|
|
2
2
|
import { loadControlConfig } from './config.js'
|
|
3
3
|
import { parseMessageArgs, sendRunDocument, sendRunText, sendRunVoice } from './message-send.js'
|
|
4
4
|
import { RunStore } from './runs.js'
|
|
5
|
+
import { parseArgs } from 'node:util'
|
|
6
|
+
import { deliveredMessages } from './message-history.js'
|
|
7
|
+
import {authorizeDeliveryContext,currentDeliveryOwner} from './delivery-context.mjs'
|
|
8
|
+
import {workspaceFile} from './files.js'
|
|
9
|
+
import path from 'node:path'
|
|
5
10
|
|
|
6
11
|
const rawArgs = process.argv.slice(2)
|
|
7
12
|
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
8
13
|
console.log(
|
|
9
14
|
'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
|
|
10
15
|
)
|
|
16
|
+
console.log('History: ezenciel-agents-message history [--limit 1..50] [--message-id ID] (read-only, bound Telegram chat, across sessions)')
|
|
17
|
+
console.log("Authenticated channel: sends go directly to the paired owner's Telegram chat. receipt OUTBOX_ID reads delivery status; sends return queued ID then Telegram delivery receipt or unknown outcome. Never resend an uncertain operation.")
|
|
18
|
+
console.log('Text: --text decodes \\n as a newline and \\\\ as a literal backslash; --text-file preserves file content.')
|
|
11
19
|
process.exit(0)
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
const runId = process.env.EZ_RUN_ID?.trim()
|
|
23
|
+
const deliveryContext = !runId && process.env.EZ_DELIVERY_CONTEXT ? authorizeDeliveryContext(JSON.parse(process.env.EZ_DELIVERY_CONTEXT),await currentDeliveryOwner(loadControlConfig().controlDir)) : undefined
|
|
24
|
+
if(rawArgs[0]==='receipt') {
|
|
25
|
+
if(!deliveryContext||rawArgs.length!==2)throw new Error('Receipt requires an authenticated delivery context and outbox ID')
|
|
26
|
+
console.log(JSON.stringify(await new RunStore(loadControlConfig().controlDir).ownerDeliveryReceipt(deliveryContext,rawArgs[1]!)))
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
if (rawArgs[0] === 'history') {
|
|
30
|
+
try {
|
|
31
|
+
const { values } = parseArgs({ args: rawArgs.slice(1), options: {
|
|
32
|
+
limit: { type: 'string' }, 'message-id': { type: 'string' },
|
|
33
|
+
} })
|
|
34
|
+
if (!runId) throw new Error('EZ_RUN_ID is required')
|
|
35
|
+
const result = await deliveredMessages(loadControlConfig().controlDir, runId, {
|
|
36
|
+
limit: values.limit === undefined ? undefined : Number(values.limit),
|
|
37
|
+
messageId: values['message-id'] === undefined ? undefined : Number(values['message-id']),
|
|
38
|
+
})
|
|
39
|
+
await new Promise<void>((resolve, reject) => process.stdout.write(JSON.stringify(result) + '\n', error => error ? reject(error) : resolve()))
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
process.exit(0)
|
|
45
|
+
}
|
|
15
46
|
const args = parseMessageArgs(rawArgs)
|
|
16
|
-
if (!runId) {
|
|
47
|
+
if (!runId && !deliveryContext) {
|
|
17
48
|
console.error('EZ_RUN_ID is required')
|
|
18
49
|
process.exit(1)
|
|
19
50
|
}
|
|
@@ -22,6 +53,7 @@ const store = new RunStore(loadControlConfig().controlDir)
|
|
|
22
53
|
|
|
23
54
|
let textContent = args.text?.trim()
|
|
24
55
|
if (args.textFile) {
|
|
56
|
+
if(deliveryContext)throw new Error('Channel delivery requires inline text, not host file input')
|
|
25
57
|
try {
|
|
26
58
|
textContent = (await readFile(args.textFile, 'utf8')).trim()
|
|
27
59
|
} catch (err: any) {
|
|
@@ -31,12 +63,20 @@ if (args.textFile) {
|
|
|
31
63
|
}
|
|
32
64
|
|
|
33
65
|
let item
|
|
34
|
-
if
|
|
35
|
-
|
|
66
|
+
if(deliveryContext) {
|
|
67
|
+
if(args.document) {
|
|
68
|
+
if(!process.env.EZ_AGENT_WORKSPACE)throw new Error('Channel delivery requires owning workspace')
|
|
69
|
+
const documentPath=await workspaceFile(process.env.EZ_AGENT_WORKSPACE,args.document)
|
|
70
|
+
item=await store.enqueueOwnerDelivery(deliveryContext,{type:'document',documentPath:path.relative(await realpath(process.env.EZ_AGENT_WORKSPACE),documentPath),text:textContent,replyToMessageId:args.replyTo})
|
|
71
|
+
} else if(args.voice) item=await store.enqueueOwnerDelivery(deliveryContext,{type:'voice',voiceText:args.voice,replyToMessageId:args.replyTo})
|
|
72
|
+
else if(textContent) item=await store.enqueueOwnerDelivery(deliveryContext,{type:'message',text:textContent,replyToMessageId:args.replyTo})
|
|
73
|
+
else throw new Error('Message content is required')
|
|
74
|
+
} else if (args.document) {
|
|
75
|
+
item = await sendRunDocument(store, runId!, args.document, textContent, { replyTo: args.replyTo })
|
|
36
76
|
} else if (args.voice) {
|
|
37
|
-
item = await sendRunVoice(store, runId
|
|
77
|
+
item = await sendRunVoice(store, runId!, args.voice, { replyTo: args.replyTo })
|
|
38
78
|
} else if (textContent) {
|
|
39
|
-
item = await sendRunText(store, runId
|
|
79
|
+
item = await sendRunText(store, runId!, textContent, { replyTo: args.replyTo })
|
|
40
80
|
} else {
|
|
41
81
|
console.error(
|
|
42
82
|
'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
|
|
@@ -45,12 +85,14 @@ if (args.document) {
|
|
|
45
85
|
}
|
|
46
86
|
|
|
47
87
|
try {
|
|
48
|
-
|
|
88
|
+
if(deliveryContext)console.log(JSON.stringify({ok:true,status:'queued',outbox_id:item.id}))
|
|
89
|
+
const receipt = await store.waitForDelivery(item.id,deliveryContext?20000:undefined)
|
|
49
90
|
console.log(
|
|
50
91
|
JSON.stringify({
|
|
51
92
|
ok: true,
|
|
52
93
|
status: 'delivered',
|
|
53
94
|
run: runId,
|
|
95
|
+
...(deliveryContext?{connection:deliveryContext.connectionId}:{}),
|
|
54
96
|
outbox_id: item.id,
|
|
55
97
|
type: item.type,
|
|
56
98
|
receipt,
|
package/src/model-policy.ts
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
export const
|
|
3
|
-
export const CODEX_DEFAULT_MODEL = 'gpt-5.6-luna'
|
|
4
|
-
export const DEFAULT_EFFORT = 'max'
|
|
5
|
-
export const allowedEffort = (effort?: string, model?: string, cli?: string) => effort === undefined ||
|
|
6
|
-
['none', 'minimal', 'low', 'medium', 'high'].includes(effort) ||
|
|
7
|
-
(['xhigh', 'max'].includes(effort) && model === 'gpt-5.6-luna' && ['codex', 'codex-gui'].includes(cli || ''))
|
|
1
|
+
// Validate option syntax; the installed engine owns supported models and effort levels.
|
|
2
|
+
export const allowedEffort = (effort?: string, _model?: string, _cli?: string) => effort === undefined || /^[a-z][a-z0-9_-]{0,31}$/.test(effort)
|
|
8
3
|
export function assertEffort(effort?: string, model?: string, cli?: string) {
|
|
9
|
-
if (!allowedEffort(effort,
|
|
4
|
+
if (!allowedEffort(effort,model,cli)) throw new Error('Invalid reasoning effort')
|
|
10
5
|
}
|
|
11
6
|
export function executionDefaults<T extends { model?: string; effort?: string }>(cli: string, options: T): T {
|
|
12
|
-
assertEffort(options.effort,
|
|
13
|
-
|
|
14
|
-
return { ...options,
|
|
15
|
-
...(['codex', 'codex-gui'].includes(cli) ? { model } : {}),
|
|
16
|
-
...(['codex', 'codex-gui'].includes(cli)
|
|
17
|
-
? { effort: options.effort || (model === 'gpt-5.6-luna' ? DEFAULT_EFFORT : 'high') } : {}),
|
|
18
|
-
}
|
|
7
|
+
assertEffort(options.effort,options.model,cli)
|
|
8
|
+
return options
|
|
19
9
|
}
|
|
20
10
|
|
|
21
11
|
export function executionOverrides<T extends { model?: string; effort?: string }>(
|
package/src/owner.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { loadControlConfig } from './config.js'
|
|
|
2
2
|
import { ControlStore } from './control-state.js'
|
|
3
3
|
import { parseOwnerArgs } from './owner-args.js'
|
|
4
4
|
|
|
5
|
-
const help = 'Usage: ezenciel-agents-owner status | approve <telegram-user-id> | approve-group <negative-chat-id> | revoke'
|
|
5
|
+
const help = 'Usage: ezenciel-agents-owner status | register <verified-owner-id> | approve <telegram-user-id> | approve-group <negative-chat-id> | unlink-telegram | revoke'
|
|
6
6
|
if (process.argv.slice(2).some(arg => arg === '--help' || arg === '-h')) {
|
|
7
7
|
console.log(help)
|
|
8
8
|
process.exit(0)
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const { command, value } = parseOwnerArgs(process.argv.slice(2))
|
|
12
|
+
if (process.env.EZ_RUN_ID && command !== 'status') throw new Error('Owner registration and channel linking require the installing administrator')
|
|
12
13
|
const config = loadControlConfig()
|
|
13
14
|
const store = new ControlStore(config.controlDir, config.pairingTtlMs)
|
|
14
15
|
|
|
@@ -20,6 +21,11 @@ const usage = (): never => {
|
|
|
20
21
|
if (command === 'status' && !value) {
|
|
21
22
|
const state = await store.status()
|
|
22
23
|
console.log(JSON.stringify({ owner: state.owner, pending: state.pending, control_dir: config.controlDir }, null, 2))
|
|
24
|
+
} else if (command === 'register' && value) {
|
|
25
|
+
console.log(JSON.stringify(await store.registerOwner(value)))
|
|
26
|
+
} else if (command === 'unlink-telegram' && !value) {
|
|
27
|
+
await store.unlinkTelegram()
|
|
28
|
+
console.log('Telegram unlinked; installation owner and sessions retained.')
|
|
23
29
|
} else if ((command === 'approve' || command === 'approve-group') && value) {
|
|
24
30
|
const owner = await store.approveOwner(Number(value), command === 'approve-group')
|
|
25
31
|
console.log(`Paired Telegram owner ${owner.telegramUserId}.`)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {createHash,randomUUID} from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// Preserve CLI stdout as bytes without putting attachments into model context.
|
|
6
|
+
export async function commandArtifact(workspace,name,execute,{signal}={}) {
|
|
7
|
+
if(typeof name!=='string'||!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$/.test(name))throw Error('Expected a simple output filename');
|
|
8
|
+
const root=await fs.realpath(workspace),directory=path.join(root,'artifacts');
|
|
9
|
+
await fs.mkdir(directory,{recursive:true,mode:0o700});
|
|
10
|
+
if(await fs.realpath(directory)!==directory)throw Error('Artifact directory must not be a symlink');
|
|
11
|
+
const controller=new AbortController(),cancel=()=>controller.abort();
|
|
12
|
+
signal?.addEventListener('abort',cancel,{once:true});
|
|
13
|
+
if(signal?.aborted)controller.abort();
|
|
14
|
+
let bytes=0,overflow=false;const chunks=[];
|
|
15
|
+
try {
|
|
16
|
+
controller.signal.throwIfAborted();
|
|
17
|
+
const result=await execute({signal:controller.signal,onStdout:chunk=>{
|
|
18
|
+
bytes+=chunk.length;
|
|
19
|
+
if(bytes>20*1024*1024){overflow=true;controller.abort();return;}
|
|
20
|
+
if(!overflow)chunks.push(Buffer.from(chunk));
|
|
21
|
+
}});
|
|
22
|
+
if(overflow)throw Error('Artifact exceeds 20 MiB limit');
|
|
23
|
+
controller.signal.throwIfAborted();
|
|
24
|
+
if(result.code!==0)return result;
|
|
25
|
+
const content=Buffer.concat(chunks),relative=path.join('artifacts',`${randomUUID()}_${name}`);
|
|
26
|
+
// Check the parent again after the command, before writing its result.
|
|
27
|
+
if(await fs.realpath(directory)!==directory)throw Error('Artifact directory changed');
|
|
28
|
+
await fs.writeFile(path.join(root,relative),content,{mode:0o600,flag:'wx'});
|
|
29
|
+
return {...result,stdout:'',artifact:{path:relative,bytes:content.length,sha256:createHash('sha256').update(content).digest('hex')}};
|
|
30
|
+
} finally {signal?.removeEventListener('abort',cancel);}
|
|
31
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
4
|
+
import { registry, prepareCommand, run } from './manager.mjs';
|
|
5
|
+
import { invokeLease } from './workspace-lease.mjs';
|
|
6
|
+
import { nativeTasks,nativeTaskBinding,nativeCommands } from './native-tasks.mjs';
|
|
7
|
+
import {currentDeliveryOwner,captureDeliveryContext} from '../delivery-context.mjs';
|
|
8
|
+
import {commandArtifact} from './connection-artifacts.mjs';
|
|
9
|
+
|
|
10
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
const reserved = ['coreRequest','coreResponse','coreApprove','coreApproval','coreApprovalResolved','coreCancel'];
|
|
12
|
+
const maxFrame = 1048576;
|
|
13
|
+
function requestId(value) {if(typeof value!=='string'||!/^[a-zA-Z0-9_-]{1,100}$/.test(value))throw Error('Invalid request id');return value;}
|
|
14
|
+
|
|
15
|
+
// The local owning connection uses the same installed authority as its bound CLI.
|
|
16
|
+
export function connectionProtocol({readRegistry,execute,executeNative,listNative=nativeCommands,readOwner,sendClient,sendPlugin,excludedPlugin}) {
|
|
17
|
+
const active=new Map(),consumed=new Set();let closed=false;
|
|
18
|
+
async function record(alias) {
|
|
19
|
+
const r=await readRegistry(),plugin=r.commands[alias],value=r.plugins[plugin];
|
|
20
|
+
if(!value||plugin===excludedPlugin)throw Error('Unknown or unavailable registered CLI');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
async function perform(req,state) {
|
|
24
|
+
const p=req.params??{};
|
|
25
|
+
if(!object(p))throw Error('Invalid request params');
|
|
26
|
+
if(req.method==='tools.owner') {
|
|
27
|
+
if(Object.keys(p).length||!readOwner)throw Error('Owner identity unavailable');
|
|
28
|
+
await readRegistry();return readOwner();
|
|
29
|
+
}
|
|
30
|
+
if(req.method==='tools.native.list') {await readRegistry();return listNative();}
|
|
31
|
+
if(req.method==='tools.native') {
|
|
32
|
+
if(Object.keys(p).some(k=>!['command','args'].includes(k)))throw Error('Unknown native request parameter');
|
|
33
|
+
await readRegistry();
|
|
34
|
+
if(closed||state.abort.signal.aborted)throw Error('Request cancelled');
|
|
35
|
+
if(!executeNative)throw Error('Native task access is unavailable');
|
|
36
|
+
return executeNative(p.args,{signal:state.abort.signal,command:p.command??'schedule'});
|
|
37
|
+
}
|
|
38
|
+
if(req.method==='tools.list') {
|
|
39
|
+
const r=await readRegistry();return Object.entries(r.commands).filter(([,owner])=>owner!==excludedPlugin).map(([alias,owner])=>{
|
|
40
|
+
const v=r.plugins[owner];return {alias,plugin:owner,description:String(v.manifest.description??'').slice(0,200),skillCount:v.manifest.skills.length,revision:v.revision};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const v=await record(p.alias);
|
|
44
|
+
if(req.method==='tools.skill') {
|
|
45
|
+
const index=p.index,line=p.line??1;
|
|
46
|
+
if(!Number.isInteger(index)||index<0||index>=v.manifest.skills.length||!Number.isInteger(line)||line<1)throw Error('Invalid skill index or line');
|
|
47
|
+
const root=await fs.realpath(v.source),file=await fs.realpath(path.resolve(root,v.manifest.skills[index]));
|
|
48
|
+
if(!file.startsWith(root+path.sep))throw Error('Skill escapes plugin source');
|
|
49
|
+
const handle=await fs.open(file,'r');let text;
|
|
50
|
+
try {const stat=await handle.stat();if(!stat.isFile()||stat.size>maxFrame)throw Error('Skill exceeds read limit');text=await handle.readFile('utf8');}finally{await handle.close();}
|
|
51
|
+
const lines=text.split('\n'),selected=lines.slice(line-1,line+99).join('\n');
|
|
52
|
+
if(Buffer.byteLength(selected)>32768)throw Error('Skill page exceeds read limit');
|
|
53
|
+
return {text:selected,nextLine:line+100<=lines.length?line+100:null};
|
|
54
|
+
}
|
|
55
|
+
let args;
|
|
56
|
+
if(req.method==='tools.help')args=['--help'];
|
|
57
|
+
else if(req.method==='tools.invoke') {
|
|
58
|
+
args=p.args;
|
|
59
|
+
if(!Array.isArray(args)||args.length>100||args.some(a=>typeof a!=='string'||a.includes('\0')||a.length>8192)||p.stdin!==undefined&&(typeof p.stdin!=='string'||Buffer.byteLength(p.stdin)>65536))throw Error('Invalid literal command arguments');
|
|
60
|
+
} else throw Error('Unknown core method');
|
|
61
|
+
if(closed||state.abort.signal.aborted)throw Error('Request cancelled');
|
|
62
|
+
const current=await record(p.alias);
|
|
63
|
+
if(current.revision!==v.revision)throw Error('Plugin changed; discover again');
|
|
64
|
+
return execute(p.alias,args,{revision:v.revision,stdin:req.method==='tools.invoke'?p.stdin:undefined,output:req.method==='tools.invoke'?p.output:undefined,signal:state.abort.signal,invocation:req.method==='tools.invoke'});
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
plugin(frame) {
|
|
68
|
+
if(closed)return;
|
|
69
|
+
if(!object(frame))throw Error('Expected JSON object');
|
|
70
|
+
if(frame.coreCancel){if(reserved.some(k=>k!=='coreCancel'&&k in frame))throw Error('Plugin forged core control frame');const id=requestId(frame.coreCancel.id),state=active.get(id);state?.abort.abort();return;}
|
|
71
|
+
if(reserved.some(k=>k!=='coreRequest'&&k in frame))throw Error('Plugin forged core control frame');
|
|
72
|
+
if(!('coreRequest' in frame)){sendClient(frame);return;}
|
|
73
|
+
const req=frame.coreRequest;if(!object(req))throw Error('Invalid core request');requestId(req.id);
|
|
74
|
+
if(active.has(req.id)||consumed.has(req.id)||active.size>=8||(req.method!=='tools.owner'&&consumed.size>=10000))throw Error('Duplicate or excessive core request');
|
|
75
|
+
if(req.method!=='tools.owner')consumed.add(req.id);
|
|
76
|
+
const state={abort:new AbortController()};active.set(req.id,state);
|
|
77
|
+
return perform(req,state).then(result=>{if(!closed)sendPlugin({coreResponse:{id:req.id,result}});},error=>{if(!closed)sendPlugin({coreResponse:{id:req.id,error:error.message}});}).finally(()=>{active.delete(req.id);});
|
|
78
|
+
},
|
|
79
|
+
client(frame) {
|
|
80
|
+
if(closed)return;
|
|
81
|
+
if(!object(frame))throw Error('Expected JSON object');
|
|
82
|
+
if(reserved.some(k=>k in frame))throw Error('Client forged core control frame');
|
|
83
|
+
sendPlugin(frame);
|
|
84
|
+
},
|
|
85
|
+
close() {closed=true;for(const state of active.values())state.abort.abort();active.clear();},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function jsonLines(onFrame,onError) {
|
|
90
|
+
let buffer='';const decoder=new StringDecoder('utf8');
|
|
91
|
+
return chunk=>{try {buffer+=decoder.write(chunk);if(Buffer.byteLength(buffer)>maxFrame)throw Error('Connection frame limit exceeded');let index;while((index=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,index);buffer=buffer.slice(index+1);if(line)onFrame(JSON.parse(line));}}catch(error){onError(error);}};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function loopbackPublish(value) {
|
|
95
|
+
if(typeof value!=='string'||!/^\d{1,5}:\d{1,5}$/.test(value)||value.split(':').some(p=>Number(p)<1024||Number(p)>65535))throw Error('Expected host-port:container-port (1024-65535)');
|
|
96
|
+
return `127.0.0.1:${value}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function connect(home,alias,args,{input=process.stdin,output=process.stdout,publish,serve=false}={}) {
|
|
100
|
+
const command=await prepareCommand(home,alias,args,{...(serve?{publish:loopbackPublish(publish)}:{})}),abort=new AbortController();let child,protocol,failure;
|
|
101
|
+
const config=JSON.parse(await fs.readFile(path.join(home,'config.json'),'utf8'));
|
|
102
|
+
const nativeBinding=config.hostConfig?await nativeTaskBinding(home):undefined;
|
|
103
|
+
const deliveryContext=nativeBinding?await captureDeliveryContext(nativeBinding.env.EZ_CONTROL_DIR,command.plugin,command.revision):undefined;
|
|
104
|
+
const send=(stream,frame)=>{try {const text=JSON.stringify(frame)+'\n';if(stream?.writableLength>maxFrame||Buffer.byteLength(text)>maxFrame)throw Error('Connection backpressure limit exceeded');stream?.write(text);}catch(error){fail(error);}};
|
|
105
|
+
const fail=error=>{failure??=error;abort.abort();protocol?.close();};
|
|
106
|
+
protocol=connectionProtocol({excludedPlugin:command.plugin,readRegistry:async()=>{const r=await registry(home);if(r.commands[alias]!==command.plugin||r.plugins[command.plugin]?.revision!==command.revision)throw Error('Connected plugin changed; reconnect');return r;},
|
|
107
|
+
readOwner:async()=>{
|
|
108
|
+
if(!nativeBinding)return null;
|
|
109
|
+
const owner=await currentDeliveryOwner(nativeBinding.env.EZ_CONTROL_DIR);
|
|
110
|
+
if(!owner||owner.kind!==undefined||!Number.isSafeInteger(owner.telegramUserId)||owner.telegramUserId<=0||!Number.isSafeInteger(owner.telegramChatId)||owner.telegramChatId<=0||typeof owner.pairedAt!=='string'||!Number.isFinite(Date.parse(owner.pairedAt)))return null;
|
|
111
|
+
return {telegramUserId:owner.telegramUserId,pairedAt:JSON.stringify([owner.id,owner.generation,owner.pairedAt,owner.telegramLinkedAt,owner.telegramChatId])};
|
|
112
|
+
},
|
|
113
|
+
listNative:()=>nativeCommands().map(item=>({...item,available:!!nativeBinding&&(item.command!=='message'||!!deliveryContext)})),
|
|
114
|
+
executeNative:(args,options)=>nativeTasks(home,args,{...options,deliveryContext}),
|
|
115
|
+
execute:async(a,argv,options)=>{const release=options.invocation?await invokeLease(home):undefined;try {const c=await prepareCommand(home,a,argv,{revision:options.revision,exclude:command.plugin});if(options.signal.aborted)throw Error('Request cancelled');const execute=overrides=>run(c.argv,{...options,...overrides,container:c.container,capture:true,timeoutMs:30000,maxBytes:262144});return options.output===undefined?await execute({}):await commandArtifact(config.workspace,options.output,execute,{signal:options.signal});}finally{await release?.();}},
|
|
116
|
+
sendClient:frame=>send(output,frame),sendPlugin:frame=>send(child?.stdin,frame)});
|
|
117
|
+
const onInput=jsonLines(frame=>protocol.client(frame),fail),onEnd=()=>{protocol.close();abort.abort();};
|
|
118
|
+
const onSignal=()=>{protocol.close();abort.abort();};
|
|
119
|
+
if(serve)for(const signal of ['SIGINT','SIGTERM'])process.on(signal,onSignal);
|
|
120
|
+
try {
|
|
121
|
+
const result=await run(command.argv,{container:command.container,capture:true,maxBytes:262144,signal:abort.signal,onStdout:jsonLines(frame=>protocol.plugin(frame),fail),onStart:c=>{child=c;if(!serve){input.on('data',onInput);input.once('end',onEnd);input.resume();}}});
|
|
122
|
+
if(failure)throw failure;process.exitCode=result.code;
|
|
123
|
+
} finally {if(serve)for(const signal of ['SIGINT','SIGTERM'])process.off(signal,onSignal);input.off('data',onInput);input.off('end',onEnd);input.pause();protocol.close();}
|
|
124
|
+
}
|