@jc_stack/ez-agents 0.1.0-beta.27 → 0.1.0-beta.29

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 (75) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +25 -1
  3. package/CHANGELOG.md +29 -0
  4. package/CONTRIBUTING.md +28 -0
  5. package/Dockerfile +1 -0
  6. package/README.md +79 -8
  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/docker-runtime.md +20 -0
  16. package/docs/managed-applications.md +68 -0
  17. package/docs/plugin-catalog.md +1 -0
  18. package/docs/plugin-connection.md +76 -0
  19. package/docs/plugins.md +40 -4
  20. package/docs/upgrades.md +11 -1
  21. package/package.json +7 -2
  22. package/src/application-channel.ts +308 -0
  23. package/src/application-cli.ts +41 -0
  24. package/src/application-client.mjs +87 -0
  25. package/src/application-origin.ts +15 -0
  26. package/src/codex-session.ts +5 -3
  27. package/src/config.ts +20 -2
  28. package/src/control-state.ts +256 -15
  29. package/src/conversation-menu.ts +89 -0
  30. package/src/delivery-context.d.mts +5 -0
  31. package/src/delivery-context.mjs +25 -0
  32. package/src/event-sources.ts +2 -2
  33. package/src/execution-authority.ts +2 -0
  34. package/src/executor.ts +10 -4
  35. package/src/host-executor.ts +7 -1
  36. package/src/identity.ts +11 -3
  37. package/src/index.ts +149 -54
  38. package/src/menu.ts +26 -9
  39. package/src/message-history.ts +52 -0
  40. package/src/message.ts +48 -7
  41. package/src/owner.ts +7 -1
  42. package/src/plugins/connection-artifacts.mjs +31 -0
  43. package/src/plugins/connection.mjs +124 -0
  44. package/src/plugins/manager.mjs +63 -18
  45. package/src/plugins/native-tasks.d.mts +4 -0
  46. package/src/plugins/native-tasks.mjs +66 -0
  47. package/src/plugins/workspace-lease.d.mts +3 -0
  48. package/src/plugins/workspace-lease.mjs +44 -0
  49. package/src/runs.ts +67 -9
  50. package/src/schedule-cli.ts +11 -6
  51. package/src/scheduler.ts +17 -7
  52. package/src/updates/control.mjs +4 -0
  53. package/src/web-launcher.ts +19 -0
  54. package/templates/agent-guidance.md +58 -2
  55. package/templates/deployments.md +24 -0
  56. package/test/application-channel.test.ts +283 -0
  57. package/test/application-client.test.mjs +84 -0
  58. package/test/application-controls.test.ts +224 -0
  59. package/test/application-only.test.ts +100 -0
  60. package/test/channel-delivery.test.ts +63 -0
  61. package/test/channel-owner.test.ts +161 -0
  62. package/test/codex-session.test.ts +8 -5
  63. package/test/config.test.ts +15 -0
  64. package/test/connection-artifacts.test.mjs +32 -0
  65. package/test/conversation-menu.test.ts +67 -0
  66. package/test/conversations.test.ts +84 -0
  67. package/test/executor.test.ts +56 -0
  68. package/test/host-executor.test.ts +28 -0
  69. package/test/intake-relay.test.ts +126 -5
  70. package/test/message-history.test.ts +127 -0
  71. package/test/native-tasks.test.ts +36 -0
  72. package/test/plugin-connection.test.mjs +124 -0
  73. package/test/plugin-manager.test.mjs +34 -0
  74. package/test/runtime-identity.test.mjs +18 -0
  75. package/test/updates.test.mjs +39 -0
@@ -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,8 +46,15 @@ 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
@@ -53,28 +82,56 @@ const isState = (value: unknown): value is ControlState => {
53
82
  if (!person || typeof person !== 'object') return false
54
83
  const p = person as Owner
55
84
  return isPositiveId(p.telegramUserId) && (p.kind === 'group'
56
- ? Number.isSafeInteger(p.telegramChatId) && p.telegramChatId < 0
85
+ ? Number.isSafeInteger(p.telegramChatId) && p.telegramChatId! < 0
57
86
  : p.kind === undefined && isPositiveId(p.telegramChatId))
58
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))
59
94
  return (
60
95
  candidate.version === 1 &&
61
96
  Array.isArray(candidate.pending) &&
62
97
  candidate.pending.every((p) => identity(p) && Number.isFinite(Date.parse(p.expiresAt))) &&
63
- (candidate.owner === null || identity(candidate.owner)) &&
98
+ (candidate.owner === null || validOwner(candidate.owner)) &&
64
99
  (candidate.ai === undefined || (Array.isArray(candidate.ai.presets) &&
65
100
  candidate.ai.presets.every(isPreset) &&
66
101
  candidate.ai.presets.some((p) => p.id === candidate.ai!.defaultId) &&
67
102
  candidate.ai.presets.some((p) => p.id === candidate.ai!.selectedId) &&
68
103
  (candidate.ai.recentIds === undefined || isRecentIds(candidate.ai.recentIds)))) &&
69
104
  (candidate.sessions === undefined || (Array.isArray(candidate.sessions) && candidate.sessions.every(
70
- (s) => /^[0-9a-f-]{36}$/i.test(s.sessionId) && typeof s.hasStarted === 'boolean'))) &&
105
+ session))) &&
71
106
  (candidate.activeSession == null ||
72
- (typeof candidate.activeSession.sessionId === 'string' &&
73
- /^[0-9a-f-]{36}$/i.test(candidate.activeSession.sessionId) &&
74
- typeof candidate.activeSession.hasStarted === 'boolean'))
107
+ session(candidate.activeSession))
75
108
  )
76
109
  }
77
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
+
78
135
  const wait = (milliseconds: number): Promise<void> =>
79
136
  new Promise((resolve) => setTimeout(resolve, milliseconds))
80
137
 
@@ -161,7 +218,7 @@ export class ControlStore {
161
218
  throw new Error('Telegram identity must be a positive numeric ID')
162
219
  return this.withLock(async () => {
163
220
  const state = this.prune(await this.readState())
164
- if (state.owner) return 'owner-exists'
221
+ if (telegramOwner(state.owner)) return 'owner-exists'
165
222
  if (
166
223
  state.pending.some(
167
224
  (request) => request.telegramUserId === telegramUserId && request.telegramChatId === telegramChatId,
@@ -184,20 +241,63 @@ export class ControlStore {
184
241
  })
185
242
  }
186
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
+
187
285
  async approveOwner(telegramUserId: number, group = false): Promise<Owner> {
188
286
  if (!(group ? Number.isSafeInteger(telegramUserId) && telegramUserId < 0 : isPositiveId(telegramUserId))) throw new Error('Supply a positive user ID or negative group ID')
189
287
  return this.withLock(async () => {
190
288
  const state = this.prune(await this.readState())
191
- 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')
192
290
  const request = state.pending.find((candidate) => group
193
291
  ? candidate.kind === 'group' && candidate.telegramChatId === telegramUserId
194
292
  : candidate.kind === undefined && candidate.telegramUserId === telegramUserId)
195
293
  if (!request) throw new Error('No active pairing request exists for that Telegram user ID')
196
294
  const owner: Owner = {
295
+ generation: state.owner ? state.owner.generation : crypto.randomUUID(),
296
+ ...(state.owner ? {id: ownerId(state.owner), telegramLinkedAt: crypto.randomUUID()} : {}),
197
297
  ...(group ? {kind: 'group' as const} : {}),
198
298
  telegramUserId: request.telegramUserId,
199
299
  telegramChatId: request.telegramChatId,
200
- pairedAt: new Date(this.clock()).toISOString(),
300
+ pairedAt: state.owner?.pairedAt ?? new Date(this.clock()).toISOString(),
201
301
  }
202
302
  state.owner = owner
203
303
  state.pending = []
@@ -253,14 +353,81 @@ export class ControlStore {
253
353
  })
254
354
  }
255
355
 
256
- 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> {
257
362
  return this.withLock(async () => {
258
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> {
421
+ return this.withLock(async () => {
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.')
259
425
  const next: SessionState = {
260
426
  sessionId: crypto.randomUUID(),
261
427
  hasStarted: false,
262
428
  cli: state.ai?.presets.find((p) => p.id === state.ai!.defaultId)?.cli,
263
429
  }
430
+ rememberPreset(state)
264
431
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
265
432
  if (state.ai) state.ai.selectedId = state.ai.defaultId
266
433
  state.activeSession = next
@@ -296,18 +463,89 @@ export class ControlStore {
296
463
  })
297
464
  }
298
465
 
299
- async captureChoice(initial: AiPreset): Promise<ExecutionChoice> {
466
+ async captureChoice(initial: AiPreset, title?: string): Promise<ExecutionChoice> {
300
467
  return this.withLock(async () => {
301
468
  const state = await this.readState()
302
469
  state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
303
470
  const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
304
471
  state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
305
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)
306
476
  await this.writeState(state)
307
477
  return { sessionId: state.activeSession.sessionId, preset }
308
478
  })
309
479
  }
310
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
+
311
549
  async executionSession(choice: ExecutionChoice): Promise<SessionState> {
312
550
  const state = await this.status()
313
551
  const session = state.activeSession?.sessionId === choice.sessionId ? state.activeSession
@@ -335,11 +573,12 @@ export class ControlStore {
335
573
  })
336
574
  }
337
575
 
338
- async savePreset(preset: AiPreset): Promise<void> {
576
+ async savePreset(preset: AiPreset, guard?: ControlGuard): Promise<void> {
339
577
  if (!isPreset(preset)) throw new Error('Invalid AI preset')
340
578
  assertEffort(preset.effort, preset.model, preset.cli)
341
579
  await this.withLock(async () => {
342
580
  const state = await this.readState()
581
+ await requireControlGuard(state, guard)
343
582
  if (!state.ai) throw new Error('AI settings not initialized')
344
583
  if (state.ai.presets.length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
345
584
  throw new Error('Keep it small: at most 12 saved AIs.')
@@ -348,9 +587,10 @@ export class ControlStore {
348
587
  })
349
588
  }
350
589
 
351
- 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> {
352
591
  return this.withLock(async () => {
353
592
  const state = await this.readState()
593
+ await requireControlGuard(state, guard)
354
594
  const ai = state.ai
355
595
  const preset = ai?.presets.find((p) => p.id === id)
356
596
  if (!ai || !preset) throw new Error('Saved AI no longer exists')
@@ -359,6 +599,7 @@ export class ControlStore {
359
599
  const current = ai.presets.find((p) => p.id === ai.selectedId)!
360
600
  if (state.activeSession && (current.cli !== preset.cli || !state.activeSession.cli) && !fresh) return false
361
601
  if (fresh || !state.activeSession) {
602
+ rememberPreset(state)
362
603
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
363
604
  state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
364
605
  }
@@ -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
+ }
@@ -49,8 +49,8 @@ export class EventSources {
49
49
  const value = await read<{ version: number; sources: EventSource[] }>(this.registry, { version: 1, sources: [] })
50
50
  if (value.version !== 1 || !Array.isArray(value.sources) || value.sources.some(s => !identifier(s.id) || !identifier(s.bindingId) ||
51
51
  typeof s.socketPath !== 'string' || !isAbsolute(s.socketPath) || !cursorOK(s.initialCursor) ||
52
- !Number.isSafeInteger(s.owner?.telegramUserId) || s.owner.telegramUserId <= 0 || !Number.isSafeInteger(s.owner.telegramChatId) ||
53
- (s.owner.kind === 'group' ? s.owner.telegramChatId >= 0 : s.owner.kind !== undefined || s.owner.telegramChatId <= 0)) ||
52
+ !Number.isSafeInteger(s.owner?.telegramUserId) || s.owner.telegramUserId! <= 0 || !Number.isSafeInteger(s.owner.telegramChatId) ||
53
+ (s.owner.kind === 'group' ? s.owner.telegramChatId! >= 0 : s.owner.kind !== undefined || s.owner.telegramChatId! <= 0)) ||
54
54
  new Set(value.sources.map(s => s.id)).size !== value.sources.length) throw new Error('Invalid event-source registry')
55
55
  return value.sources
56
56
  }
@@ -1,3 +1,4 @@
1
+ import { ApplicationBindings } from './application-channel.js'
1
2
  import { ControlStore } from './control-state.js'
2
3
  import { RunStore, type RunRecord } from './runs.js'
3
4
  import type { Owner } from './control-state.js'
@@ -21,5 +22,6 @@ export async function requireOwnerExecution(controlDir: string, runId: string):
21
22
  const owner = (await new ControlStore(controlDir, 900_000).status()).owner
22
23
  const reason = executionBlockReason(run, owner)
23
24
  if (reason) throw new Error(`Execution blocked: ${reason}`)
25
+ if (run.application || run.delivery) await new ApplicationBindings(controlDir).authorize(run)
24
26
  return run
25
27
  }
package/src/executor.ts CHANGED
@@ -27,6 +27,7 @@ export type ExecutorOptions = {
27
27
  eventSource?: string
28
28
  model?: string
29
29
  effort?: string
30
+ codexSandbox?: 'external'
30
31
  codexAutoCompactTokens?: number
31
32
  onSession?: (id: string) => Promise<void>
32
33
  }
@@ -74,7 +75,7 @@ export type CliAdapter = {
74
75
  command: string
75
76
  description: string
76
77
  buildArgs: (
77
- options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'codexAutoCompactTokens'> & { controlDir?: string },
78
+ options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'codexAutoCompactTokens' | 'codexSandbox'> & { controlDir?: string },
78
79
  promptFile: string,
79
80
  promptText: string,
80
81
  ) => string[]
@@ -84,7 +85,8 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
84
85
  codex: {
85
86
  name: 'codex', command: 'codex', description: 'Codex CLI',
86
87
  buildArgs: (opts, _file, prompt) => {
87
- const args = ['exec', '--skip-git-repo-check', '--json', '--sandbox', 'workspace-write', '--disable', 'memories', '--enable', 'skip_host_skill_discovery', '-c', 'approval_policy="never"']
88
+ if (opts.codexSandbox !== undefined && opts.codexSandbox !== 'external') throw new Error('Invalid Codex sandbox selection')
89
+ const args = ['exec', '--skip-git-repo-check', '--json', '--sandbox', opts.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write', '--disable', 'memories', '--enable', 'skip_host_skill_discovery', '-c', 'approval_policy="never"']
88
90
  const limit = opts.codexAutoCompactTokens
89
91
  if (limit !== undefined) {
90
92
  if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error('Invalid Codex compaction token limit')
@@ -232,6 +234,7 @@ export const startExecutorJob = async (
232
234
  options = executionDefaults(executorKey(options.cli), options)
233
235
  if(options.runId.startsWith('r_schedule_') && !/^[a-zA-Z0-9_-]+$/.test(options.runId))throw new Error('Invalid native task run ID')
234
236
  const run = await new RunStore(options.controlDir).get(options.runId)
237
+ if (options.codexSandbox !== undefined && (options.codexSandbox !== 'external' || process.env.EZ_EXECUTOR_TRANSPORT !== 'local' || !run || run.taskId || executorKey(options.cli) !== 'codex')) throw new Error('External Codex sandbox requires an owner-authorized native local run')
235
238
  if (run?.taskId) {
236
239
  if (run.status !== 'running') throw new Error('No active task run')
237
240
  await new Tasks(options.controlDir).authorize(run, process.env.EZ_EXECUTOR_TRANSPORT === 'host')
@@ -244,10 +247,13 @@ export const startExecutorJob = async (
244
247
  const gui = !host && key === 'codex-gui'
245
248
  const nativeSession = !host && key === 'codex' && options.runId.startsWith('r_schedule_')
246
249
  // Chat-mode experiment: only direct chat input at the engine boundary.
250
+ const applicationReminder = !host && (run?.application || run?.delivery)
251
+ ? '\n\n[Application channel] This is an owner-authorized application conversation. Send text replies using ezenciel-agents-message; stdout alone is not delivered. Attachments/reactions/approval controls are unsupported here. Domain tools can retrieve private context from ezenciel-agents-schedule context under run.application.context; do not expose credentials from that data. The application scope is ' + JSON.stringify((run.application ?? run.delivery)!.scope) + '.'
252
+ : ''
247
253
  const chatReminder = !host && !run?.taskId && run?.messageId !== undefined
248
254
  ? '\n\n[Chat context] You are replying in chat. Send replies with ezenciel-agents-message --text "..."; your final answer alone is not delivered. Before lengthy tool or repository work, briefly acknowledge through that CLI. Keep chat responsive: use ezenciel-agents-schedule for long-running work and native subagents for useful independent parts. Decide when to delegate and what to send.'
249
255
  : ''
250
- const promptText = texts.join('\n\n') + chatReminder
256
+ const promptText = texts.join('\n\n') + applicationReminder + chatReminder
251
257
  const promptFile = path.join(outputDirectory, 'prompt.txt')
252
258
  await writeFile(promptFile, promptText, { encoding: 'utf8', mode: 0o600 })
253
259
 
@@ -298,7 +304,7 @@ export const startExecutorJob = async (
298
304
  throw error
299
305
  })
300
306
  child.stdin?.end(host
301
- ? JSON.stringify({texts,options:{...options,onSession:undefined}})
307
+ ? JSON.stringify({texts,options:{...options,onSession:undefined,codexSandbox:undefined}})
302
308
  : nativeSession ? JSON.stringify({...options,onSession:undefined,prompt:promptText})
303
309
  : gui ? JSON.stringify({prompt:promptText,options:{...options,onSession:undefined}})
304
310
  : ['codex', 'claude'].includes(key) ? promptText : undefined)
@@ -59,6 +59,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
59
59
  await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
60
60
  await rm(path.join(directory,file))
61
61
  }
62
+ if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
62
63
  }
63
64
  let catalogAt=Date.now()
64
65
  while (!signal.aborted) {
@@ -90,7 +91,11 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
90
91
  }
91
92
  const sharedWorkspace=sharedWorkspaces.get(agent)
92
93
  const base=path.join(directory,id)
93
- await rename(base+'.request.json',base+'.running.json')
94
+ const releaseWorkspace = !run?.scheduled && agent.toolsHome
95
+ ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
96
+ if (!run?.scheduled && agent.toolsHome && !releaseWorkspace) continue
97
+ try { await rename(base+'.request.json',base+'.running.json') }
98
+ catch (error) { await releaseWorkspace?.(); throw error }
94
99
  const task=(async()=>{
95
100
  let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
96
101
  let cancellation: ReturnType<typeof setInterval> | undefined
@@ -133,6 +138,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
133
138
  await rm(base+'.process.json',{force:true})
134
139
  await rm(base+'.cancel',{force:true})
135
140
  active.delete(base)
141
+ await releaseWorkspace?.()
136
142
  }
137
143
  })()
138
144
  tasks.add(task); void task.finally(()=>tasks.delete(task))