@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.
Files changed (128) hide show
  1. package/.env.example +10 -1
  2. package/AGENTS.md +40 -9
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +31 -1
  5. package/Dockerfile +1 -0
  6. package/README.md +84 -12
  7. package/bin/ezenciel-agents-application +2 -0
  8. package/bin/ezenciel-agents-application.mjs +16 -0
  9. package/compose.yaml +8 -0
  10. package/docker/entrypoint.sh +20 -2
  11. package/docker/healthcheck.mjs +1 -1
  12. package/docker/run.ts +3 -3
  13. package/docker/smoke.mjs +41 -2
  14. package/docs/application-channel.md +366 -0
  15. package/docs/architecture/ai-selection.md +12 -15
  16. package/docs/docker-runtime.md +29 -0
  17. package/docs/host-service.md +5 -8
  18. package/docs/local-qa.md +1 -1
  19. package/docs/managed-applications.md +68 -0
  20. package/docs/plugin-catalog.md +1 -0
  21. package/docs/plugin-connection.md +76 -0
  22. package/docs/plugins.md +54 -5
  23. package/docs/repair.md +26 -25
  24. package/docs/responsive-channels.md +13 -55
  25. package/docs/scheduling.md +40 -36
  26. package/docs/setup.md +11 -21
  27. package/docs/standalone-cli.md +2 -2
  28. package/docs/upgrades.md +43 -18
  29. package/package.json +8 -4
  30. package/src/agent-guidance.ts +32 -3
  31. package/src/ai-cli.ts +5 -1
  32. package/src/ai.ts +6 -28
  33. package/src/application-channel.ts +308 -0
  34. package/src/application-cli.ts +41 -0
  35. package/src/application-client.mjs +87 -0
  36. package/src/application-origin.ts +15 -0
  37. package/src/codex-session.ts +7 -10
  38. package/src/config.ts +23 -5
  39. package/src/control-state.ts +274 -21
  40. package/src/conversation-menu.ts +89 -0
  41. package/src/delivery-context.d.mts +5 -0
  42. package/src/delivery-context.mjs +25 -0
  43. package/src/desktop-bridge.ts +11 -43
  44. package/src/event-sources.ts +2 -2
  45. package/src/execution-authority.ts +2 -0
  46. package/src/executor.ts +29 -58
  47. package/src/host-executor.ts +11 -9
  48. package/src/identity.ts +11 -3
  49. package/src/index.ts +191 -93
  50. package/src/menu.ts +76 -55
  51. package/src/message-history.ts +52 -0
  52. package/src/message-send.ts +1 -1
  53. package/src/message.ts +49 -7
  54. package/src/model-policy.ts +5 -15
  55. package/src/owner.ts +7 -1
  56. package/src/plugins/connection-artifacts.mjs +31 -0
  57. package/src/plugins/connection.mjs +124 -0
  58. package/src/plugins/manager.mjs +93 -23
  59. package/src/plugins/native-tasks.d.mts +4 -0
  60. package/src/plugins/native-tasks.mjs +66 -0
  61. package/src/plugins/workspace-lease.d.mts +3 -0
  62. package/src/plugins/workspace-lease.mjs +44 -0
  63. package/src/repair-policy.ts +0 -8
  64. package/src/reply-context.ts +3 -29
  65. package/src/runs.ts +67 -9
  66. package/src/schedule-cli.ts +33 -15
  67. package/src/scheduled-tasks.ts +20 -21
  68. package/src/scheduler.ts +55 -22
  69. package/src/task-executor.ts +4 -5
  70. package/src/task-workspace.ts +2 -11
  71. package/src/update-attention.ts +1 -1
  72. package/src/updates/binding.mjs +2 -6
  73. package/src/updates/control.mjs +4 -0
  74. package/src/updates/supervisor.mjs +10 -4
  75. package/src/web-launcher.ts +19 -0
  76. package/src/workspace.ts +3 -1
  77. package/templates/agent/AGENTS.md +13 -55
  78. package/templates/agent-guidance.md +90 -37
  79. package/templates/deployments.md +24 -0
  80. package/templates/failure-review.md +6 -0
  81. package/templates/maintainer-purpose.md +12 -6
  82. package/test/agent-guidance.test.ts +29 -39
  83. package/test/ai-cli.test.ts +9 -0
  84. package/test/ai.test.ts +66 -22
  85. package/test/application-channel.test.ts +283 -0
  86. package/test/application-client.test.mjs +84 -0
  87. package/test/application-controls.test.ts +224 -0
  88. package/test/application-only.test.ts +100 -0
  89. package/test/busy-reply-relay.test.ts +11 -7
  90. package/test/channel-delivery.test.ts +63 -0
  91. package/test/channel-owner.test.ts +161 -0
  92. package/test/client-defaults.test.ts +1 -1
  93. package/test/codex-session.test.ts +18 -10
  94. package/test/config.test.ts +16 -1
  95. package/test/connection-artifacts.test.mjs +32 -0
  96. package/test/conversation-menu.test.ts +67 -0
  97. package/test/conversations.test.ts +84 -0
  98. package/test/desktop-bridge.test.ts +17 -11
  99. package/test/engine-handoff.test.ts +73 -0
  100. package/test/event-sources.test.ts +5 -8
  101. package/test/executor.test.ts +68 -16
  102. package/test/failure.test.ts +64 -0
  103. package/test/host-executor.test.ts +58 -17
  104. package/test/install-config.test.ts +1 -1
  105. package/test/intake-relay.test.ts +169 -25
  106. package/test/message-history.test.ts +127 -0
  107. package/test/model-policy.test.ts +23 -48
  108. package/test/native-tasks.test.ts +36 -0
  109. package/test/plugin-connection.test.mjs +124 -0
  110. package/test/plugin-manager.test.mjs +70 -10
  111. package/test/repair-policy.test.ts +8 -12
  112. package/test/runs.test.ts +13 -0
  113. package/test/runtime-identity.test.mjs +18 -0
  114. package/test/schedule-cli.test.ts +34 -5
  115. package/test/scheduled-tasks.test.ts +79 -8
  116. package/test/scheduler.test.ts +30 -1
  117. package/test/task-native.test.ts +5 -2
  118. package/test/update-attention.test.ts +1 -2
  119. package/test/updates.test.mjs +44 -5
  120. package/test/workspace.test.ts +2 -3
  121. package/scripts/smoke-busy-reply.ts +0 -58
  122. package/src/reply-executor.ts +0 -55
  123. package/src/reply-mcp.ts +0 -23
  124. package/templates/agent/TOOLS.md +0 -105
  125. package/templates/chat-guidance.md +0 -23
  126. package/templates/standalone-tools.md +0 -20
  127. package/templates/updates.md +0 -45
  128. 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('Choose AI', 'menu:ai').row()
19
- .text('Work status', 'menu:status').text('Settings', 'menu:settings')
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) : undefined)
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 choose = async (ctx: Context, preset: AiPreset) => {
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.aiState(initial)
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, session?.sessionId ?? null, fresh)
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, settings = false) => {
56
- if (!settings) {
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
- const presets = settings ? state.presets : state.presets.filter((preset) => preset.id === initial.id)
63
- for (const preset of presets) button(keyboard,
64
- `${preset.id === (settings ? state.defaultId : state.selectedId) ? '✓ ' : ''}${preset.name}`,
65
- async (next) => {
66
- if (settings) {
67
- await validate(preset)
68
- await control.defaultPreset(preset.id)
69
- await next.reply(`Default: ${preset.name}. Applies to new conversations only.`)
70
- } else await choose(next, preset)
71
- })
72
- button(keyboard, 'Browse available models', (next) => available(next))
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, true)
91
+ await list(next)
76
92
  })
77
- await ctx.reply(settings
78
- ? 'Default for new conversations\nChoose a saved AI. Current work will not change.'
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, `${model.cli} · ${model.name}`, async (next) => {
86
- if (!model.efforts.length) return save(next, model)
87
- const efforts = new InlineKeyboard()
88
- for (const effort of model.efforts.filter(effort => allowedEffort(effort, model.model, model.cli))) button(efforts, effort, (last) => save(last, model, effort))
89
- await next.reply(`${model.name} effort`, { reply_markup: efforts })
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
- await ctx.reply(models.length
95
- ? 'Choose AI\nAvailable models are populated automatically from the installed clients. Grok/Codex use their local catalog; other clients use their own default. Choosing one saves it; it does not switch AI.'
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 save = async (ctx: Context, model: ModelChoice, effort?: string) => {
99
- const state = await control.aiState(initial)
100
- const existing = state.presets.find((p) => p.cli === model.cli && p.model === model.model && p.effort === effort)
101
- const preset: AiPreset = existing ?? { id: randomBytes(8).toString('hex'),
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
- await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : undefined)
104
- await control.savePreset(preset)
105
- const keyboard = new InlineKeyboard()
106
- button(keyboard, 'Use now', (next) => choose(next, preset))
107
- button(keyboard, 'Make default', async (next) => {
108
- await control.defaultPreset(preset.id)
109
- await next.reply(`Default: ${preset.name}. Applies to new conversations only.`)
110
- })
111
- await ctx.reply(`Saved: ${preset.name}\n${presetLabel(preset)}`, { reply_markup: keyboard })
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 or /settings again.')
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
+ }
@@ -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 (args.document) {
35
- item = await sendRunDocument(store, runId, args.document, textContent, { replyTo: args.replyTo })
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, args.voice, { replyTo: args.replyTo })
77
+ item = await sendRunVoice(store, runId!, args.voice, { replyTo: args.replyTo })
38
78
  } else if (textContent) {
39
- item = await sendRunText(store, runId, textContent, { replyTo: args.replyTo })
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
- const receipt = await store.waitForDelivery(item.id)
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,
@@ -1,21 +1,11 @@
1
- export const CODEX_CHAT_MODEL = 'gpt-5.6-sol'
2
- export const CHAT_EFFORT = 'medium'
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, model, cli)) throw new Error('Reasoning effort is capped at high, except Codex Luna/xhigh/max; choose none, minimal, low, medium, high, xhigh, or max with gpt-5.6-luna.')
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, options.model, cli)
13
- const model = options.model || CODEX_DEFAULT_MODEL
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
+ }