@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.
- package/.env.example +9 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +28 -0
- package/Dockerfile +1 -0
- package/README.md +79 -8
- package/bin/ezenciel-agents-application +2 -0
- package/bin/ezenciel-agents-application.mjs +16 -0
- package/compose.yaml +8 -0
- package/docker/entrypoint.sh +20 -2
- package/docker/healthcheck.mjs +1 -1
- package/docker/run.ts +3 -3
- package/docker/smoke.mjs +41 -2
- package/docs/application-channel.md +366 -0
- package/docs/docker-runtime.md +20 -0
- package/docs/managed-applications.md +68 -0
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugin-connection.md +76 -0
- package/docs/plugins.md +40 -4
- package/docs/upgrades.md +11 -1
- package/package.json +7 -2
- package/src/application-channel.ts +308 -0
- package/src/application-cli.ts +41 -0
- package/src/application-client.mjs +87 -0
- package/src/application-origin.ts +15 -0
- package/src/codex-session.ts +5 -3
- package/src/config.ts +20 -2
- package/src/control-state.ts +256 -15
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +10 -4
- package/src/host-executor.ts +7 -1
- package/src/identity.ts +11 -3
- package/src/index.ts +149 -54
- package/src/menu.ts +26 -9
- package/src/message-history.ts +52 -0
- package/src/message.ts +48 -7
- package/src/owner.ts +7 -1
- package/src/plugins/connection-artifacts.mjs +31 -0
- package/src/plugins/connection.mjs +124 -0
- package/src/plugins/manager.mjs +63 -18
- package/src/plugins/native-tasks.d.mts +4 -0
- package/src/plugins/native-tasks.mjs +66 -0
- package/src/plugins/workspace-lease.d.mts +3 -0
- package/src/plugins/workspace-lease.mjs +44 -0
- package/src/runs.ts +67 -9
- package/src/schedule-cli.ts +11 -6
- package/src/scheduler.ts +17 -7
- package/src/updates/control.mjs +4 -0
- package/src/web-launcher.ts +19 -0
- package/templates/agent-guidance.md +58 -2
- package/templates/deployments.md +24 -0
- package/test/application-channel.test.ts +283 -0
- package/test/application-client.test.mjs +84 -0
- package/test/application-controls.test.ts +224 -0
- package/test/application-only.test.ts +100 -0
- package/test/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/codex-session.test.ts +8 -5
- package/test/config.test.ts +15 -0
- package/test/connection-artifacts.test.mjs +32 -0
- package/test/conversation-menu.test.ts +67 -0
- package/test/conversations.test.ts +84 -0
- package/test/executor.test.ts +56 -0
- package/test/host-executor.test.ts +28 -0
- package/test/intake-relay.test.ts +126 -5
- package/test/message-history.test.ts +127 -0
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +34 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/updates.test.mjs +39 -0
package/src/control-state.ts
CHANGED
|
@@ -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
|
|
9
|
-
telegramChatId
|
|
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 ||
|
|
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
|
-
|
|
105
|
+
session))) &&
|
|
71
106
|
(candidate.activeSession == null ||
|
|
72
|
-
(
|
|
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;
|
|
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
|
|
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
|
+
}
|
package/src/event-sources.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/host-executor.ts
CHANGED
|
@@ -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
|
-
|
|
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))
|