@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/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseWebLauncher, type WebLauncher } from './web-launcher.js'
1
2
  import { repairEnabled } from './repair-policy.js'
2
3
  import path from 'node:path'
3
4
  import { homedir } from 'node:os'
@@ -9,13 +10,18 @@ export type ControlConfig = {
9
10
 
10
11
  export type Config = ControlConfig & {
11
12
  repairEnabled?: boolean
13
+ telegramEnabled?: boolean
14
+ webLauncher?: WebLauncher
12
15
  telegramBotToken: string
13
16
  workspace: string
14
17
  executorTimeoutMs: number
18
+ codexSandbox?: 'external'
15
19
  codexAutoCompactTokens?: number
16
20
  executorCli: string
17
21
  channelBackendUrl?: string
18
22
  channelBackendToken?: string
23
+ applicationPort?: number
24
+ applicationHost?: string
19
25
  geminiApiKey?: string
20
26
  openaiApiKey?: string
21
27
  pagerDutyRoutingKey?: string
@@ -24,8 +30,8 @@ export type Config = ControlConfig & {
24
30
  pagerDutyFailureThreshold?: number
25
31
  }
26
32
 
27
- const positiveInteger = (value: string | undefined, name: string, fallback: number): number => {
28
- if (!value) return fallback
33
+ const positiveInteger = (value: string | undefined, name: string, fallback?: number): number => {
34
+ if (!value && fallback !== undefined) return fallback
29
35
  const parsed = Number(value)
30
36
  if (!Number.isSafeInteger(parsed) || parsed <= 0) {
31
37
  throw new Error(`${name} must be a positive integer`)
@@ -42,10 +48,17 @@ export const loadControlConfig = (env: NodeJS.ProcessEnv = process.env): Control
42
48
  }
43
49
 
44
50
  export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
45
- const telegramBotToken = env.TELEGRAM_BOT_TOKEN?.trim()
46
- if (!telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
51
+ if (env.EZ_TELEGRAM_ENABLED !== undefined && !['true', 'false'].includes(env.EZ_TELEGRAM_ENABLED)) throw new Error('EZ_TELEGRAM_ENABLED must be true or false')
52
+ const telegramEnabled = env.EZ_TELEGRAM_ENABLED !== 'false'
53
+ const telegramBotToken = telegramEnabled ? env.TELEGRAM_BOT_TOKEN?.trim() || '' : ''
54
+ if (!telegramEnabled && !env.EZ_APPLICATION_PORT) throw new Error('Application-only execution requires EZ_APPLICATION_PORT')
55
+ if (telegramEnabled && !telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
47
56
 
48
57
  if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
58
+ if (env.EZ_APPLICATION_PORT && env.EZ_CHANNEL_BACKEND_URL) throw new Error('Application input requires the native Ez executor, not a channel backend')
59
+ const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
60
+ if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
61
+ if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || env.EZ_EXECUTOR_TRANSPORT !== 'local')) throw new Error('External Codex sandbox requires native local execution')
49
62
  const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
50
63
  const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
51
64
  if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
@@ -59,12 +72,17 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
59
72
  }
60
73
  return {
61
74
  ...loadControlConfig(env),
75
+ telegramEnabled,
76
+ webLauncher: parseWebLauncher(env.EZ_TELEGRAM_WEB_APP),
62
77
  telegramBotToken,
63
78
  repairEnabled: repairEnabled(env.EZ_REPAIR_ENABLED),
64
79
  workspace: path.resolve(env.EZ_AGENT_WORKSPACE?.trim() || './agent'),
65
80
  executorTimeoutMs: 0,
66
- codexAutoCompactTokens: positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS', 64000),
81
+ codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
82
+ codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
67
83
  executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
84
+ applicationPort: env.EZ_APPLICATION_PORT ? positiveInteger(env.EZ_APPLICATION_PORT, 'EZ_APPLICATION_PORT') : undefined,
85
+ applicationHost: env.EZ_APPLICATION_HOST?.trim() || '127.0.0.1',
68
86
  channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
69
87
  channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
70
88
  geminiApiKey: env.GEMINI_API_KEY?.trim(),
@@ -4,12 +4,34 @@ import path from 'node:path'
4
4
  import { isPreset, persistedPreset, type AiPreset, type ExecutionChoice } from './ai.js'
5
5
 
6
6
  export type Owner = {
7
+ id?: string
8
+ generation?: string
7
9
  kind?: 'group'
8
- telegramUserId: number
9
- telegramChatId: number
10
+ telegramUserId?: number
11
+ telegramChatId?: number
12
+ telegramLinkedAt?: string
10
13
  pairedAt: string
11
14
  }
12
15
 
16
+ export type TelegramOwner = Owner & { telegramUserId: number; telegramChatId: number }
17
+ export const telegramOwner = (owner: Owner | null): TelegramOwner | null =>
18
+ owner && Number.isSafeInteger(owner.telegramUserId) && Number.isSafeInteger(owner.telegramChatId) ? owner as TelegramOwner : null
19
+ export const ownerId = (owner: Owner): string => owner.id ?? `telegram:${owner.telegramUserId}:${owner.telegramChatId}`
20
+ export const ownerEpoch = (owner: Owner): string => owner.generation ?? owner.pairedAt
21
+ export const sameOwner = (left: Owner, right: Owner | null): boolean =>
22
+ !!right && ownerId(left) === ownerId(right) && ownerEpoch(left) === ownerEpoch(right)
23
+ export const validOwner = (owner: unknown): owner is Owner => {
24
+ if (!owner || typeof owner !== 'object') return false
25
+ const p = owner as Owner
26
+ const linked = p.telegramUserId !== undefined || p.telegramChatId !== undefined
27
+ return typeof p.pairedAt === 'string' && Number.isFinite(Date.parse(p.pairedAt)) &&
28
+ (p.generation === undefined || typeof p.generation === 'string' && /^[a-f0-9-]{36}$/.test(p.generation)) &&
29
+ (p.telegramLinkedAt === undefined || typeof p.telegramLinkedAt === 'string') &&
30
+ (p.id === undefined ? linked : typeof p.id === 'string' && /^[a-zA-Z0-9_:.-]{1,200}$/.test(p.id)) &&
31
+ (!linked ? p.kind === undefined : Number.isSafeInteger(p.telegramUserId) && p.telegramUserId! > 0 &&
32
+ Number.isSafeInteger(p.telegramChatId) && (p.kind === 'group' ? p.telegramChatId! < 0 : p.kind === undefined && p.telegramChatId! > 0))
33
+ }
34
+
13
35
  export type PairingRequest = {
14
36
  kind?: 'group'
15
37
  title?: string
@@ -24,14 +46,21 @@ export type SessionState = {
24
46
  hasStarted: boolean
25
47
  cli?: string
26
48
  nativeSessionId?: string
49
+ title?: string
50
+ archived?: boolean
51
+ preset?: AiPreset
52
+ applicationScope?: string
53
+ telegramShared?: boolean
27
54
  }
28
55
 
56
+ export type ControlGuard = { owner: Owner; authorize: () => Promise<unknown>; expectedSession?: string | null; applicationScope?: string }
57
+
29
58
  type ControlState = {
30
59
  version: 1
31
60
  owner: Owner | null
32
61
  pending: PairingRequest[]
33
62
  activeSession?: SessionState | null
34
- ai?: { presets: AiPreset[]; defaultId: string; selectedId: string }
63
+ ai?: { presets: AiPreset[]; defaultId: string; selectedId: string; recentIds?: string[] }
35
64
  sessions?: SessionState[]
36
65
  }
37
66
 
@@ -42,6 +71,10 @@ const emptyState = (): ControlState => ({ version: 1, owner: null, pending: [] }
42
71
  const isPositiveId = (value: unknown): value is number =>
43
72
  typeof value === 'number' && Number.isSafeInteger(value) && value > 0
44
73
 
74
+ const isRecentIds = (value: unknown): value is string[] =>
75
+ Array.isArray(value) && value.length <= 3 && new Set(value).size === value.length &&
76
+ value.every((id) => typeof id === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(id))
77
+
45
78
  const isState = (value: unknown): value is ControlState => {
46
79
  if (!value || typeof value !== 'object') return false
47
80
  const candidate = value as Partial<ControlState>
@@ -49,27 +82,56 @@ const isState = (value: unknown): value is ControlState => {
49
82
  if (!person || typeof person !== 'object') return false
50
83
  const p = person as Owner
51
84
  return isPositiveId(p.telegramUserId) && (p.kind === 'group'
52
- ? Number.isSafeInteger(p.telegramChatId) && p.telegramChatId < 0
85
+ ? Number.isSafeInteger(p.telegramChatId) && p.telegramChatId! < 0
53
86
  : p.kind === undefined && isPositiveId(p.telegramChatId))
54
87
  }
88
+ const session = (s: SessionState) => s && /^[0-9a-f-]{36}$/i.test(s.sessionId) && typeof s.hasStarted === 'boolean' &&
89
+ (s.title === undefined || (typeof s.title === 'string' && s.title.length <= 80)) &&
90
+ (s.archived === undefined || typeof s.archived === 'boolean') &&
91
+ (s.applicationScope === undefined || /^[a-f0-9]{64}$/.test(s.applicationScope)) &&
92
+ (s.telegramShared === undefined || typeof s.telegramShared === 'boolean') &&
93
+ (s.preset === undefined || (isPreset(s.preset) && s.preset.cli === s.cli))
55
94
  return (
56
95
  candidate.version === 1 &&
57
96
  Array.isArray(candidate.pending) &&
58
97
  candidate.pending.every((p) => identity(p) && Number.isFinite(Date.parse(p.expiresAt))) &&
59
- (candidate.owner === null || identity(candidate.owner)) &&
98
+ (candidate.owner === null || validOwner(candidate.owner)) &&
60
99
  (candidate.ai === undefined || (Array.isArray(candidate.ai.presets) &&
61
100
  candidate.ai.presets.every(isPreset) &&
62
101
  candidate.ai.presets.some((p) => p.id === candidate.ai!.defaultId) &&
63
- candidate.ai.presets.some((p) => p.id === candidate.ai!.selectedId))) &&
102
+ candidate.ai.presets.some((p) => p.id === candidate.ai!.selectedId) &&
103
+ (candidate.ai.recentIds === undefined || isRecentIds(candidate.ai.recentIds)))) &&
64
104
  (candidate.sessions === undefined || (Array.isArray(candidate.sessions) && candidate.sessions.every(
65
- (s) => /^[0-9a-f-]{36}$/i.test(s.sessionId) && typeof s.hasStarted === 'boolean'))) &&
105
+ session))) &&
66
106
  (candidate.activeSession == null ||
67
- (typeof candidate.activeSession.sessionId === 'string' &&
68
- /^[0-9a-f-]{36}$/i.test(candidate.activeSession.sessionId) &&
69
- typeof candidate.activeSession.hasStarted === 'boolean'))
107
+ session(candidate.activeSession))
70
108
  )
71
109
  }
72
110
 
111
+ export const sessionTitle = (session: SessionState): string =>
112
+ session.title || `Conversation ${session.sessionId.slice(0, 8)}`
113
+
114
+ const rememberPreset = (state: ControlState) => {
115
+ const preset = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
116
+ if (state.activeSession && preset && state.activeSession.cli === preset.cli)
117
+ state.activeSession.preset = preset
118
+ }
119
+
120
+ const currentApplicationSession = (state: ControlState, scope: string) =>
121
+ [state.activeSession, ...(state.sessions ?? [])].find(session => session?.applicationScope === scope && !session.archived)
122
+
123
+ const requireControlGuard = async (state: ControlState, guard?: ControlGuard) => {
124
+ if (!guard) return
125
+ // The callback may inspect binding authority, but must not acquire this store's lock.
126
+ await guard.authorize()
127
+ const expected = guard.owner
128
+ if (!sameOwner(expected, state.owner)) throw new Error('Control owner changed. Refresh the connection.')
129
+ const current = guard.applicationScope ? currentApplicationSession(state, guard.applicationScope) : state.activeSession
130
+ if (guard.applicationScope && current && (current.telegramShared || current === state.activeSession))
131
+ throw new Error('Application scope is shared; use shared controls')
132
+ if (guard.expectedSession !== undefined && (current?.sessionId ?? null) !== guard.expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
133
+ }
134
+
73
135
  const wait = (milliseconds: number): Promise<void> =>
74
136
  new Promise((resolve) => setTimeout(resolve, milliseconds))
75
137
 
@@ -94,7 +156,12 @@ export class ControlStore {
94
156
  try {
95
157
  const parsed: unknown = JSON.parse(await readFile(this.statePath, 'utf8'))
96
158
  if (!isState(parsed)) throw new Error('Control state has an unsupported shape')
97
- if (parsed.ai) parsed.ai.presets = parsed.ai.presets.map(persistedPreset)
159
+ if (parsed.ai) {
160
+ parsed.ai.presets = parsed.ai.presets.map(persistedPreset)
161
+ parsed.ai.recentIds = (parsed.ai.recentIds ?? [parsed.ai.selectedId])
162
+ .filter((id) => parsed.ai!.presets.some((preset) => preset.id === id))
163
+ .slice(0, 3)
164
+ }
98
165
  return parsed
99
166
  } catch (error: unknown) {
100
167
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyState()
@@ -151,7 +218,7 @@ export class ControlStore {
151
218
  throw new Error('Telegram identity must be a positive numeric ID')
152
219
  return this.withLock(async () => {
153
220
  const state = this.prune(await this.readState())
154
- if (state.owner) return 'owner-exists'
221
+ if (telegramOwner(state.owner)) return 'owner-exists'
155
222
  if (
156
223
  state.pending.some(
157
224
  (request) => request.telegramUserId === telegramUserId && request.telegramChatId === telegramChatId,
@@ -174,20 +241,63 @@ export class ControlStore {
174
241
  })
175
242
  }
176
243
 
244
+ async bootstrapApplicationOwner(operatorId: number): Promise<Owner> {
245
+ if (!isPositiveId(operatorId)) throw new Error('Supply the real administrator Telegram user ID')
246
+ return this.withLock(async () => {
247
+ const state = await this.readState()
248
+ if (state.owner) throw new Error('An owner already exists; application bootstrap cannot replace it')
249
+ const owner: Owner = { generation: crypto.randomUUID(), telegramUserId: operatorId, telegramChatId: operatorId, pairedAt: new Date(this.clock()).toISOString() }
250
+ state.owner = owner
251
+ state.pending = []
252
+ await this.writeState(state)
253
+ return owner
254
+ })
255
+ }
256
+
257
+ async registerOwner(id: string): Promise<Owner> {
258
+ if (!/^[a-zA-Z0-9_:.-]{1,200}$/.test(id)) throw new Error('Invalid owner ID')
259
+ return this.withLock(async () => {
260
+ const state = await this.readState()
261
+ if (state.owner) {
262
+ if (ownerId(state.owner) !== id) throw new Error('Installation already has a different owner')
263
+ return state.owner
264
+ }
265
+ const bindings = await readFile(path.join(path.dirname(this.statePath), 'application-bindings.json'), 'utf8')
266
+ .then(text => JSON.parse(text), error => { if (error.code === 'ENOENT') return []; throw error })
267
+ if (!Array.isArray(bindings) || bindings.some(binding => !validOwner(binding?.owner)) || state.activeSession || state.sessions?.length)
268
+ throw new Error('Existing unowned state requires explicit ownership recovery')
269
+ state.owner = {id, generation: crypto.randomUUID(), pairedAt: new Date(this.clock()).toISOString()}
270
+ await this.writeState(state)
271
+ return state.owner
272
+ })
273
+ }
274
+
275
+ async unlinkTelegram(): Promise<void> {
276
+ await this.withLock(async () => {
277
+ const state = await this.readState()
278
+ if (!state.owner) throw new Error('No installation owner')
279
+ state.owner = {id: ownerId(state.owner), generation: state.owner.generation, pairedAt: state.owner.pairedAt}
280
+ state.pending = []
281
+ await this.writeState(state)
282
+ })
283
+ }
284
+
177
285
  async approveOwner(telegramUserId: number, group = false): Promise<Owner> {
178
286
  if (!(group ? Number.isSafeInteger(telegramUserId) && telegramUserId < 0 : isPositiveId(telegramUserId))) throw new Error('Supply a positive user ID or negative group ID')
179
287
  return this.withLock(async () => {
180
288
  const state = this.prune(await this.readState())
181
- if (state.owner) throw new Error('An owner is already paired; revoke locally before replacing it')
289
+ if (telegramOwner(state.owner)) throw new Error('An owner is already paired; unlink locally before replacing its Telegram channel')
182
290
  const request = state.pending.find((candidate) => group
183
291
  ? candidate.kind === 'group' && candidate.telegramChatId === telegramUserId
184
292
  : candidate.kind === undefined && candidate.telegramUserId === telegramUserId)
185
293
  if (!request) throw new Error('No active pairing request exists for that Telegram user ID')
186
294
  const owner: Owner = {
295
+ generation: state.owner ? state.owner.generation : crypto.randomUUID(),
296
+ ...(state.owner ? {id: ownerId(state.owner), telegramLinkedAt: crypto.randomUUID()} : {}),
187
297
  ...(group ? {kind: 'group' as const} : {}),
188
298
  telegramUserId: request.telegramUserId,
189
299
  telegramChatId: request.telegramChatId,
190
- pairedAt: new Date(this.clock()).toISOString(),
300
+ pairedAt: state.owner?.pairedAt ?? new Date(this.clock()).toISOString(),
191
301
  }
192
302
  state.owner = owner
193
303
  state.pending = []
@@ -243,14 +353,81 @@ export class ControlStore {
243
353
  })
244
354
  }
245
355
 
246
- async resetSession(): Promise<SessionState> {
356
+ async listSessions(): Promise<SessionState[]> {
357
+ const state = await this.status()
358
+ return [...(state.activeSession ? [state.activeSession] : []), ...(state.sessions ?? []).filter(session => !session.applicationScope || session.telegramShared).slice().reverse()]
359
+ }
360
+
361
+ async switchSession(sessionId: string, expectedSession?: string | null, guard?: ControlGuard): Promise<SessionState> {
362
+ return this.withLock(async () => {
363
+ const state = await this.readState()
364
+ await requireControlGuard(state, guard)
365
+ if (expectedSession !== undefined && (state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
366
+ if (state.activeSession?.sessionId === sessionId) return state.activeSession
367
+ const session = state.sessions?.find(s => s.sessionId === sessionId)
368
+ if (!session || session.archived || (session.applicationScope && !session.telegramShared)) throw new Error('Conversation unavailable. Open /chats again.')
369
+ if (session.cli === 'agy')
370
+ throw new Error('Antigravity only resumes its latest conversation; selecting an older session is not supported.')
371
+ const ai = state.ai
372
+ // Older sessions did not record their model. Reuse a known preset for the
373
+ // same CLI; never resume an engine ID through a different client.
374
+ const preset = session.preset ?? ai?.presets.find(p => p.cli === session.cli)
375
+ if (!ai || !preset || preset.cli !== session.cli)
376
+ throw new Error('This older conversation has no saved AI binding. Start a new conversation.')
377
+ if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli!) && !session.nativeSessionId)
378
+ throw new Error('This conversation has no native session ID. Start a new conversation.')
379
+ assertEffort(preset.effort, preset.model, preset.cli)
380
+ rememberPreset(state)
381
+ state.sessions = state.sessions!.filter(s => s.sessionId !== sessionId)
382
+ if (state.activeSession) state.sessions.push(state.activeSession)
383
+ state.activeSession = session
384
+ ai.presets = [...ai.presets.filter(p => p.id !== preset.id), persistedPreset(preset)]
385
+ ai.selectedId = preset.id
386
+ await this.writeState(state)
387
+ return session
388
+ })
389
+ }
390
+
391
+ async archiveSession(sessionId: string, archived: boolean): Promise<void> {
392
+ await this.withLock(async () => {
393
+ const state = await this.readState()
394
+ const session = state.activeSession?.sessionId === sessionId ? state.activeSession
395
+ : state.sessions?.find(s => s.sessionId === sessionId)
396
+ if (!session) throw new Error('Conversation unavailable. Open /chats again.')
397
+ if (archived && state.activeSession === session) {
398
+ rememberPreset(state)
399
+ state.sessions ??= []
400
+ state.sessions.push(session)
401
+ state.activeSession = null
402
+ if (state.ai) state.ai.selectedId = state.ai.defaultId
403
+ }
404
+ session.archived = archived
405
+ await this.writeState(state)
406
+ })
407
+ }
408
+
409
+ async renameSession(title: string): Promise<void> {
410
+ title = title.replace(/\s+/g, ' ').trim()
411
+ if (!title || title.length > 80) throw new Error('Use /rename followed by a name of 1–80 characters.')
412
+ await this.withLock(async () => {
413
+ const state = await this.readState()
414
+ if (!state.activeSession) throw new Error('Open a conversation first with /chats or /new.')
415
+ state.activeSession.title = title
416
+ await this.writeState(state)
417
+ })
418
+ }
419
+
420
+ async resetSession(expectedSession?: string | null, guard?: ControlGuard): Promise<SessionState> {
247
421
  return this.withLock(async () => {
248
422
  const state = await this.readState()
423
+ await requireControlGuard(state, guard)
424
+ if (expectedSession !== undefined && (state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
249
425
  const next: SessionState = {
250
426
  sessionId: crypto.randomUUID(),
251
427
  hasStarted: false,
252
428
  cli: state.ai?.presets.find((p) => p.id === state.ai!.defaultId)?.cli,
253
429
  }
430
+ rememberPreset(state)
254
431
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
255
432
  if (state.ai) state.ai.selectedId = state.ai.defaultId
256
433
  state.activeSession = next
@@ -262,7 +439,7 @@ export class ControlStore {
262
439
  async aiState(initial: AiPreset) {
263
440
  return this.withLock(async () => {
264
441
  const state = await this.readState()
265
- state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
442
+ state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
266
443
  await this.writeState(state)
267
444
  return state.ai
268
445
  })
@@ -274,29 +451,101 @@ export class ControlStore {
274
451
  const state = await this.readState()
275
452
  const first = initial.cli === 'codex' || initial.cli === 'codex-gui'
276
453
  ? initial : discovered.find((p) => p.cli === initial.cli) ?? initial
277
- state.ai ??= { presets: [persistedPreset(first)], defaultId: first.id, selectedId: first.id }
454
+ state.ai ??= { presets: [persistedPreset(first)], defaultId: first.id, selectedId: first.id, recentIds: [] }
278
455
  const ai = state.ai
279
456
  // Refresh discovery entries, but never rewrite an active/default or user-saved choice.
280
457
  const preserved = ai.presets.filter((p) => !p.id.startsWith('detected_') ||
281
458
  p.id === ai.selectedId || p.id === ai.defaultId).map(persistedPreset)
282
459
  ai.presets = [...preserved, ...discovered.map(persistedPreset).filter((p) => !preserved.some((old) => old.id === p.id))]
460
+ ai.recentIds = (ai.recentIds ?? []).filter((id) => ai.presets.some((preset) => preset.id === id)).slice(0, 3)
283
461
  if (initial.id === 'chat-default' && !ai.presets.some(p => p.id === initial.id)) ai.presets.push(persistedPreset(initial))
284
462
  await this.writeState(state)
285
463
  })
286
464
  }
287
465
 
288
- async captureChoice(initial: AiPreset): Promise<ExecutionChoice> {
466
+ async captureChoice(initial: AiPreset, title?: string): Promise<ExecutionChoice> {
289
467
  return this.withLock(async () => {
290
468
  const state = await this.readState()
291
- state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
469
+ state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
292
470
  const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
293
471
  state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
294
472
  if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
473
+ if (state.activeSession.cli === preset.cli) state.activeSession.preset = preset
474
+ if (!state.activeSession.title && !state.activeSession.hasStarted && title?.trim())
475
+ state.activeSession.title = title.replace(/\s+/g, ' ').trim().slice(0, 80)
295
476
  await this.writeState(state)
296
477
  return { sessionId: state.activeSession.sessionId, preset }
297
478
  })
298
479
  }
299
480
 
481
+ async captureApplicationChoice(initial: AiPreset, scope: string, shareTelegram = false, requested?: AiPreset, expectedNativeSessionId?: string): Promise<ExecutionChoice> {
482
+ if (!/^[a-f0-9]{64}$/.test(scope)) throw new Error('Invalid application scope')
483
+ return this.withLock(async () => {
484
+ const state = await this.readState()
485
+ state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
486
+ state.sessions ??= []
487
+ const previous = currentApplicationSession(state, scope)
488
+ if (expectedNativeSessionId !== undefined && previous?.nativeSessionId !== expectedNativeSessionId) throw new Error('Application request conflicts with native session; import the existing scope before cutover')
489
+ const activate = (session: SessionState) => {
490
+ if (!shareTelegram) return
491
+ session.telegramShared = true
492
+ session.archived = false
493
+ if (state.activeSession?.sessionId !== session.sessionId) {
494
+ state.sessions = state.sessions!.filter(item => item.sessionId !== session.sessionId)
495
+ if (state.activeSession) state.sessions.push(state.activeSession)
496
+ state.activeSession = session
497
+ }
498
+ state.ai!.presets = [...state.ai!.presets.filter(item => item.id !== session.preset!.id), session.preset!]
499
+ state.ai!.selectedId = session.preset!.id
500
+ }
501
+ if (previous?.preset) {
502
+ if (requested && requested.cli !== previous.cli) throw new Error('Application request conflicts with existing session engine')
503
+ if (requested) previous.preset = requested
504
+ activate(previous)
505
+ if (shareTelegram || requested) await this.writeState(state)
506
+ return { sessionId: previous.sessionId, preset: previous.preset }
507
+ }
508
+ const preset = requested ?? state.ai.presets.find(item => item.id === state.ai!.selectedId)!
509
+ if (preset.cli === 'agy') throw new Error('Application scopes require an engine with explicit session selection')
510
+ const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset, applicationScope: scope }
511
+ state.sessions.push(session)
512
+ activate(session)
513
+ await this.writeState(state)
514
+ return { sessionId: session.sessionId, preset }
515
+ })
516
+ }
517
+
518
+ async applicationSession(scope: string): Promise<SessionState | undefined> {
519
+ return currentApplicationSession(await this.status(), scope) ?? undefined
520
+ }
521
+
522
+ async changeApplicationSession(scope: string, guard: ControlGuard, preset?: AiPreset): Promise<SessionState> {
523
+ if (!/^[a-f0-9]{64}$/.test(scope) || guard.applicationScope !== scope || guard.expectedSession === undefined)
524
+ throw new Error('Invalid application scope control')
525
+ return this.withLock(async () => {
526
+ const state = await this.readState()
527
+ await requireControlGuard(state, guard)
528
+ const previous = currentApplicationSession(state, scope)
529
+ if (previous && (previous.telegramShared || previous === state.activeSession))
530
+ throw new Error('Application scope is shared; use shared controls')
531
+ const nextPreset = preset ?? state.ai?.presets.find(item => item.id === state.ai!.defaultId)
532
+ if (!nextPreset || !isPreset(nextPreset) || nextPreset.cli === 'agy') throw new Error('Invalid application AI selection')
533
+ if (preset && previous?.cli === preset.cli) {
534
+ previous.preset = persistedPreset(preset)
535
+ await this.writeState(state)
536
+ return previous
537
+ }
538
+ // Retire the binding, not its native session. Admitted work still resolves
539
+ // the old immutable session ID; private history stays absent from /chats.
540
+ if (previous) previous.archived = true
541
+ const next: SessionState = {sessionId:crypto.randomUUID(), hasStarted:false,
542
+ cli:nextPreset.cli, preset:persistedPreset(nextPreset), applicationScope:scope}
543
+ ;(state.sessions ??= []).push(next)
544
+ await this.writeState(state)
545
+ return next
546
+ })
547
+ }
548
+
300
549
  async executionSession(choice: ExecutionChoice): Promise<SessionState> {
301
550
  const state = await this.status()
302
551
  const session = state.activeSession?.sessionId === choice.sessionId ? state.activeSession
@@ -324,11 +573,12 @@ export class ControlStore {
324
573
  })
325
574
  }
326
575
 
327
- async savePreset(preset: AiPreset): Promise<void> {
576
+ async savePreset(preset: AiPreset, guard?: ControlGuard): Promise<void> {
328
577
  if (!isPreset(preset)) throw new Error('Invalid AI preset')
329
578
  assertEffort(preset.effort, preset.model, preset.cli)
330
579
  await this.withLock(async () => {
331
580
  const state = await this.readState()
581
+ await requireControlGuard(state, guard)
332
582
  if (!state.ai) throw new Error('AI settings not initialized')
333
583
  if (state.ai.presets.length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
334
584
  throw new Error('Keep it small: at most 12 saved AIs.')
@@ -337,9 +587,10 @@ export class ControlStore {
337
587
  })
338
588
  }
339
589
 
340
- async selectPreset(id: string, expectedSession: string | null, fresh = false): Promise<boolean> {
590
+ async selectPreset(id: string, expectedSession: string | null, fresh = false, guard?: ControlGuard): Promise<boolean> {
341
591
  return this.withLock(async () => {
342
592
  const state = await this.readState()
593
+ await requireControlGuard(state, guard)
343
594
  const ai = state.ai
344
595
  const preset = ai?.presets.find((p) => p.id === id)
345
596
  if (!ai || !preset) throw new Error('Saved AI no longer exists')
@@ -348,10 +599,12 @@ export class ControlStore {
348
599
  const current = ai.presets.find((p) => p.id === ai.selectedId)!
349
600
  if (state.activeSession && (current.cli !== preset.cli || !state.activeSession.cli) && !fresh) return false
350
601
  if (fresh || !state.activeSession) {
602
+ rememberPreset(state)
351
603
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
352
604
  state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
353
605
  }
354
606
  ai.selectedId = id
607
+ ai.recentIds = [id, ...(ai.recentIds ?? []).filter((recentId) => recentId !== id)].slice(0, 3)
355
608
  await this.writeState(state)
356
609
  return true
357
610
  })
@@ -0,0 +1,89 @@
1
+ import { InlineKeyboard, type Context } from 'grammy'
2
+ import { ControlStore, sessionTitle } from './control-state.js'
3
+ import type { RunStore } from './runs.js'
4
+
5
+ // IDs identify existing relay bindings only; the engine still owns all context.
6
+ export const createConversationMenu = (control: ControlStore, runs: Pick<RunStore, 'list'>) => {
7
+ const render = async (ctx: Context, text: string, keyboard: InlineKeyboard) => {
8
+ if (!ctx.callbackQuery?.message) { await ctx.reply(text, { reply_markup: keyboard }); return }
9
+ try { await ctx.editMessageText(text, { reply_markup: keyboard }) }
10
+ catch (error) {
11
+ if (!String((error as {description?: string})?.description).includes('message is not modified')) throw error
12
+ }
13
+ }
14
+ const sessionsWithNames = async () => {
15
+ const sessions = await control.listSessions()
16
+ if (sessions.every(s => s.title)) return sessions
17
+ // Display existing owner input only. Do not create another history store or
18
+ // ask an engine to generate titles just to render a menu. Commands and JSON
19
+ // event records (including approval callbacks) are not readable chat names.
20
+ const history = await runs.list()
21
+ return sessions.flatMap((session, index) => {
22
+ if (session.title) return [session]
23
+ const first = history.find(run => run.execution?.sessionId === session.sessionId &&
24
+ run.messageId && !run.taskId && !run.scheduled && !run.external && !run.replyOnly &&
25
+ run.texts[0]?.trim() && !/^[/{]/.test(run.texts[0].trim()))
26
+ // New only reserves a routing ID. Do not present empty routing placeholders
27
+ // as engine conversations. Retain the records for already accepted work.
28
+ if (!session.hasStarted && !session.nativeSessionId && !first) return []
29
+ const title = first
30
+ ? `${first.texts[0].replace(/\s+/g, ' ').trim().slice(0, 40)} · ${first.createdAt.slice(0, 16).replace('T', ' ')} UTC`
31
+ : `Untitled conversation ${index + 1}`
32
+ return [{ ...session, title }]
33
+ })
34
+ }
35
+ const list = async (ctx: Context, archived = false, page = 0) => {
36
+ const all = await sessionsWithNames()
37
+ const sessions = all.filter(s => Boolean(s.archived) === archived)
38
+ page = Math.min(page, Math.max(0, Math.ceil(sessions.length / 8) - 1))
39
+ const active = await control.getActiveSession()
40
+ const keyboard = new InlineKeyboard()
41
+ for (const session of sessions.slice(page * 8, page * 8 + 8))
42
+ keyboard.text(`${session.sessionId === active?.sessionId ? '✓ ' : ''}${sessionTitle(session)}`.slice(0, 64), `chat:open:${session.sessionId}`).row()
43
+ if (page > 0) keyboard.text('Previous', `chat:list:${Number(archived)}:${page - 1}`)
44
+ if ((page + 1) * 8 < sessions.length) keyboard.text('Next', `chat:list:${Number(archived)}:${page + 1}`)
45
+ if (page > 0 || (page + 1) * 8 < sessions.length) keyboard.row()
46
+ keyboard.text(archived ? 'Conversations' : 'Archived conversations', `chat:list:${Number(!archived)}:0`)
47
+ .text('+ New conversation', 'menu:new')
48
+ await render(ctx, archived
49
+ ? sessions.length ? 'Archived conversations' : 'No archived conversations.'
50
+ : sessions.length ? 'Conversations\nSelect a name to continue.' : 'No active conversations.\nSend a message to start a chat, or open Archived conversations.', keyboard)
51
+ }
52
+ return {
53
+ list,
54
+ async handle(ctx: Context): Promise<boolean> {
55
+ const data = ctx.callbackQuery?.data
56
+ if (!data?.startsWith('chat:')) return false
57
+ await ctx.answerCallbackQuery().catch(() => {})
58
+ try {
59
+ const page = /^chat:list:([01]):(\d{1,6})$/.exec(data)
60
+ if (page) { await list(ctx, page[1] === '1', Number(page[2])); return true }
61
+ const action = /^chat:(open|archive|restore):([0-9a-f-]{36})$/i.exec(data)
62
+ if (!action) throw new Error('Conversation unavailable. Open /chats again.')
63
+ const [, verb, id] = action
64
+ const session = (await sessionsWithNames()).find(s => s.sessionId === id)
65
+ if (!session) throw new Error('Conversation unavailable. Open /chats again.')
66
+ if (verb === 'archive' || verb === 'restore') {
67
+ await control.archiveSession(id, verb === 'archive')
68
+ await ctx.reply(`${verb === 'archive' ? 'Archived' : 'Restored'}: ${sessionTitle(session)}${verb === 'archive' ? '\nExisting work keeps its conversation. Archiving does not stop it.' : ''}`)
69
+ await list(ctx, verb === 'restore' ? false : true)
70
+ } else if (session.archived) {
71
+ await render(ctx, sessionTitle(session), new InlineKeyboard().text('Restore conversation', `chat:restore:${id}`).row().text('Back', 'chat:list:1:0'))
72
+ } else {
73
+ const keyboard = new InlineKeyboard().text('Archive this conversation', `chat:archive:${id}`).row()
74
+ .text('Back to conversations', 'chat:list:0:0')
75
+ try { await control.switchSession(id) }
76
+ catch (error) {
77
+ // Even an older session that cannot resume can still be archived.
78
+ await render(ctx, `${sessionTitle(session)}\n${error instanceof Error ? error.message : 'Unable to continue.'}`, keyboard)
79
+ return true
80
+ }
81
+ await render(ctx, `Current conversation: ${sessionTitle(session)}\nSend a message to continue. To rename it, use /rename followed by a name.`, keyboard)
82
+ }
83
+ } catch (error) {
84
+ await ctx.reply(error instanceof Error ? error.message : 'Conversation selection failed.')
85
+ }
86
+ return true
87
+ },
88
+ }
89
+ }
@@ -0,0 +1,5 @@
1
+ import type {Owner} from './control-state.js';
2
+ export type DeliveryContext={version:1;connectionId:string;plugin:string;revision:string;owner:Owner};
3
+ export function authorizeDeliveryContext(context:unknown,owner:unknown):DeliveryContext;
4
+ export function currentDeliveryOwner(controlDir:string):Promise<Owner|null>;
5
+ export function captureDeliveryContext(controlDir:string,plugin:string,revision:string):Promise<DeliveryContext|undefined>;
@@ -0,0 +1,25 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ const validOwner=owner=>owner&&typeof owner==='object'&&Number.isSafeInteger(owner.telegramUserId)&&owner.telegramUserId>0&&Number.isSafeInteger(owner.telegramChatId)&&(owner.kind==='group'?owner.telegramChatId<0:owner.kind===undefined&&owner.telegramChatId>0)&&typeof owner.pairedAt==='string'&&Number.isFinite(Date.parse(owner.pairedAt))&&(owner.id===undefined||typeof owner.id==='string'&&/^[a-zA-Z0-9_:.-]{1,200}$/.test(owner.id))&&(owner.generation===undefined||typeof owner.generation==='string'&&/^[a-f0-9-]{36}$/.test(owner.generation))&&(owner.telegramLinkedAt===undefined||typeof owner.telegramLinkedAt==='string');
6
+ const ownerId=owner=>owner.id??`telegram:${owner.telegramUserId}:${owner.telegramChatId}`;
7
+ const ownerEpoch=owner=>owner.generation??owner.pairedAt;
8
+ const telegramEpoch=owner=>owner.telegramLinkedAt??owner.pairedAt;
9
+ const sameDeliveryOwner=(left,right)=>validOwner(left)&&validOwner(right)&&ownerId(left)===ownerId(right)&&ownerEpoch(left)===ownerEpoch(right)&&left.kind===right.kind&&left.telegramUserId===right.telegramUserId&&left.telegramChatId===right.telegramChatId&&telegramEpoch(left)===telegramEpoch(right);
10
+ export function authorizeDeliveryContext(context,owner) {
11
+ if(!context||context.version!==1||typeof context.connectionId!=='string'||!/^[a-zA-Z0-9_-]{1,100}$/.test(context.connectionId)||typeof context.plugin!=='string'||!/^[a-z][a-z0-9-]{0,39}$/.test(context.plugin)||typeof context.revision!=='string'||!context.revision||!sameDeliveryOwner(context.owner,owner))throw Error('Owner delivery context is invalid or revoked');
12
+ return context;
13
+ }
14
+ export async function currentDeliveryOwner(controlDir) {
15
+ const state=JSON.parse(await readFile(path.join(controlDir,'control-state.json'),'utf8'));
16
+ if(state.version!==1)throw Error('Invalid owner control state');
17
+ return state.owner;
18
+ }
19
+ export async function captureDeliveryContext(controlDir,plugin,revision) {
20
+ const owner=await currentDeliveryOwner(controlDir).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
21
+ if(!owner)return undefined;
22
+ // A channel-neutral installation has no Telegram delivery destination.
23
+ if(owner.telegramUserId===undefined&&owner.telegramChatId===undefined)return undefined;
24
+ return authorizeDeliveryContext({version:1,connectionId:randomUUID(),plugin,revision,owner},owner);
25
+ }