@jc_stack/ez-agents 0.1.0-beta.33 → 0.1.0-beta.36
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/.dockerignore +1 -0
- package/.env.example +9 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +92 -0
- package/Dockerfile +11 -9
- package/README.md +32 -27
- package/THIRD_PARTY_NOTICES.md +4 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +42 -5
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +32 -7
- package/docs/application-channel.md +38 -10
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +106 -21
- package/docs/host-service.md +17 -6
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +63 -2
- package/docs/releasing.md +13 -0
- package/docs/repair.md +1 -1
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +96 -16
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +12 -4
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +44 -9
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +88 -17
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/codex-session.ts +5 -3
- package/src/config.ts +18 -28
- package/src/control-state.ts +187 -9
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +110 -31
- package/src/files.ts +38 -11
- package/src/host-executor.ts +50 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +96 -52
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +100 -23
- package/src/isolation.ts +25 -0
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +16 -8
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +178 -26
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +11 -7
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +6 -3
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +29 -9
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +31 -18
- package/src/version.ts +20 -0
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +24 -20
- package/templates/agent-purpose.md +1 -0
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +18 -88
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +84 -0
- package/test/application-channel.test.ts +66 -15
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +16 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +63 -3
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +47 -37
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +12 -8
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +47 -11
- package/test/failure.test.ts +18 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +80 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +18 -6
- package/test/install-tools.test.mjs +80 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +143 -5
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/scheduler.test.ts +2 -2
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +102 -13
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +27 -11
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -44
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -15
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
- package/templates/agent-guidance.md +0 -114
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
package/src/control-state.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { assertEffort } from './model-policy.js'
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
3
|
import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
4
|
import path from 'node:path'
|
|
4
|
-
import { isPreset, persistedPreset, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
5
|
+
import { isPreset, persistedPreset, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
|
|
5
6
|
|
|
6
7
|
export type Owner = {
|
|
7
8
|
id?: string
|
|
@@ -32,7 +33,7 @@ export const validOwner = (owner: unknown): owner is Owner => {
|
|
|
32
33
|
Number.isSafeInteger(p.telegramChatId) && (p.kind === 'group' ? p.telegramChatId! < 0 : p.kind === undefined && p.telegramChatId! > 0))
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
type TelegramPairingRequest = {
|
|
36
37
|
kind?: 'group'
|
|
37
38
|
title?: string
|
|
38
39
|
telegramUserId: number
|
|
@@ -41,6 +42,18 @@ export type PairingRequest = {
|
|
|
41
42
|
expiresAt: string
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
type ApplicationPairingRequest = {
|
|
46
|
+
application: {
|
|
47
|
+
bindingId: string
|
|
48
|
+
tokenHash: string
|
|
49
|
+
owner: Owner
|
|
50
|
+
}
|
|
51
|
+
requestedAt: string
|
|
52
|
+
expiresAt: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type PairingRequest = TelegramPairingRequest | ApplicationPairingRequest
|
|
56
|
+
|
|
44
57
|
export type SessionState = {
|
|
45
58
|
sessionId: string
|
|
46
59
|
hasStarted: boolean
|
|
@@ -71,6 +84,20 @@ const emptyState = (): ControlState => ({ version: 1, owner: null, pending: [] }
|
|
|
71
84
|
const isPositiveId = (value: unknown): value is number =>
|
|
72
85
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
|
73
86
|
|
|
87
|
+
const applicationPairing = (request: PairingRequest): request is ApplicationPairingRequest =>
|
|
88
|
+
'application' in request
|
|
89
|
+
|
|
90
|
+
const telegramPairing = (request: PairingRequest): request is TelegramPairingRequest =>
|
|
91
|
+
!applicationPairing(request)
|
|
92
|
+
|
|
93
|
+
const applicationBindingId = (value: unknown): value is string =>
|
|
94
|
+
typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value)
|
|
95
|
+
|
|
96
|
+
const pairingToken = (value: unknown): value is string =>
|
|
97
|
+
typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value)
|
|
98
|
+
|
|
99
|
+
const pairingDigest = (value: string): string => createHash('sha256').update(value).digest('hex')
|
|
100
|
+
|
|
74
101
|
const isRecentIds = (value: unknown): value is string[] =>
|
|
75
102
|
Array.isArray(value) && value.length <= 3 && new Set(value).size === value.length &&
|
|
76
103
|
value.every((id) => typeof id === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(id))
|
|
@@ -78,13 +105,25 @@ const isRecentIds = (value: unknown): value is string[] =>
|
|
|
78
105
|
const isState = (value: unknown): value is ControlState => {
|
|
79
106
|
if (!value || typeof value !== 'object') return false
|
|
80
107
|
const candidate = value as Partial<ControlState>
|
|
81
|
-
const
|
|
108
|
+
const telegramIdentity = (person: unknown): boolean => {
|
|
82
109
|
if (!person || typeof person !== 'object') return false
|
|
83
110
|
const p = person as Owner
|
|
84
111
|
return isPositiveId(p.telegramUserId) && (p.kind === 'group'
|
|
85
112
|
? Number.isSafeInteger(p.telegramChatId) && p.telegramChatId! < 0
|
|
86
113
|
: p.kind === undefined && isPositiveId(p.telegramChatId))
|
|
87
114
|
}
|
|
115
|
+
const pairing = (request: unknown): request is PairingRequest => {
|
|
116
|
+
if (!request || typeof request !== 'object') return false
|
|
117
|
+
const value = request as Partial<ApplicationPairingRequest & TelegramPairingRequest>
|
|
118
|
+
if (!Number.isFinite(Date.parse(value.expiresAt ?? '')) || !Number.isFinite(Date.parse(value.requestedAt ?? '')))
|
|
119
|
+
return false
|
|
120
|
+
if (value.application !== undefined) {
|
|
121
|
+
const application = value.application
|
|
122
|
+
return Object.keys(value).every((key) => ['application', 'requestedAt', 'expiresAt'].includes(key)) &&
|
|
123
|
+
!!application && applicationBindingId(application.bindingId) && /^[a-f0-9]{64}$/.test(application.tokenHash) && validOwner(application.owner)
|
|
124
|
+
}
|
|
125
|
+
return telegramIdentity(value)
|
|
126
|
+
}
|
|
88
127
|
const session = (s: SessionState) => s && /^[0-9a-f-]{36}$/i.test(s.sessionId) && typeof s.hasStarted === 'boolean' &&
|
|
89
128
|
(s.title === undefined || (typeof s.title === 'string' && s.title.length <= 80)) &&
|
|
90
129
|
(s.archived === undefined || typeof s.archived === 'boolean') &&
|
|
@@ -94,7 +133,7 @@ const isState = (value: unknown): value is ControlState => {
|
|
|
94
133
|
return (
|
|
95
134
|
candidate.version === 1 &&
|
|
96
135
|
Array.isArray(candidate.pending) &&
|
|
97
|
-
candidate.pending.every(
|
|
136
|
+
candidate.pending.every(pairing) &&
|
|
98
137
|
(candidate.owner === null || validOwner(candidate.owner)) &&
|
|
99
138
|
(candidate.ai === undefined || (Array.isArray(candidate.ai.presets) &&
|
|
100
139
|
candidate.ai.presets.every(isPreset) &&
|
|
@@ -145,6 +184,9 @@ export class ControlStore {
|
|
|
145
184
|
private readonly clock: Clock = () => Date.now(),
|
|
146
185
|
) {
|
|
147
186
|
this.statePath = path.join(controlDir, 'control-state.json')
|
|
187
|
+
// The lock stays on the shared control volume: relay (container) and CLI
|
|
188
|
+
// children (host) must exclude each other. Hot reads avoid it entirely
|
|
189
|
+
// (see status/captureChoice fast paths); only real mutations take it.
|
|
148
190
|
this.lockPath = path.join(controlDir, 'control-state.lock')
|
|
149
191
|
}
|
|
150
192
|
|
|
@@ -170,8 +212,16 @@ export class ControlStore {
|
|
|
170
212
|
}
|
|
171
213
|
|
|
172
214
|
private async writeState(state: ControlState): Promise<void> {
|
|
215
|
+
const serialized = `${JSON.stringify(state, null, 2)}\n`
|
|
216
|
+
// Steady-state reads (owner checks, session pins that already match) must
|
|
217
|
+
// not churn control/: skip the write when nothing changed.
|
|
218
|
+
try {
|
|
219
|
+
if ((await readFile(this.statePath, 'utf8')) === serialized) return
|
|
220
|
+
} catch (error: unknown) {
|
|
221
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
222
|
+
}
|
|
173
223
|
const temporary = `${this.statePath}.${process.pid}.tmp`
|
|
174
|
-
await writeFile(temporary,
|
|
224
|
+
await writeFile(temporary, serialized, { mode: 0o600 })
|
|
175
225
|
await rename(temporary, this.statePath)
|
|
176
226
|
}
|
|
177
227
|
|
|
@@ -200,6 +250,12 @@ export class ControlStore {
|
|
|
200
250
|
}
|
|
201
251
|
|
|
202
252
|
async status(): Promise<ControlState> {
|
|
253
|
+
// Hot path (owner checks every message and every source tick): when no
|
|
254
|
+
// pairing expired, the pruned state is returned with zero control/ writes
|
|
255
|
+
// and without taking the lock. Expiry still converges under the lock.
|
|
256
|
+
const current = await this.readState()
|
|
257
|
+
const pruned = this.prune(current)
|
|
258
|
+
if (pruned.pending.length === current.pending.length) return pruned
|
|
203
259
|
return this.withLock(async () => {
|
|
204
260
|
const state = this.prune(await this.readState())
|
|
205
261
|
await this.writeState(state)
|
|
@@ -221,7 +277,7 @@ export class ControlStore {
|
|
|
221
277
|
if (telegramOwner(state.owner)) return 'owner-exists'
|
|
222
278
|
if (
|
|
223
279
|
state.pending.some(
|
|
224
|
-
(request) => request.telegramUserId === telegramUserId && request.telegramChatId === telegramChatId,
|
|
280
|
+
(request) => telegramPairing(request) && request.telegramUserId === telegramUserId && request.telegramChatId === telegramChatId,
|
|
225
281
|
)
|
|
226
282
|
) {
|
|
227
283
|
await this.writeState(state)
|
|
@@ -282,14 +338,75 @@ export class ControlStore {
|
|
|
282
338
|
})
|
|
283
339
|
}
|
|
284
340
|
|
|
341
|
+
async createApplicationTelegramPairing(bindingId: string, expectedOwner: Owner): Promise<{ token: string; expiresAt: string } | null> {
|
|
342
|
+
if (!applicationBindingId(bindingId) || !validOwner(expectedOwner)) throw new Error('Invalid application Telegram pairing request')
|
|
343
|
+
return this.withLock(async () => {
|
|
344
|
+
const state = this.prune(await this.readState())
|
|
345
|
+
if (!sameOwner(expectedOwner, state.owner)) throw new Error('Application authority revoked')
|
|
346
|
+
if (telegramOwner(state.owner)) {
|
|
347
|
+
await this.writeState(state)
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
const retained = state.pending.filter((request) => !applicationPairing(request) || request.application.bindingId !== bindingId)
|
|
351
|
+
if (retained.length >= 3) throw new Error('Telegram pairing capacity reached')
|
|
352
|
+
const token = randomBytes(32).toString('base64url')
|
|
353
|
+
const now = this.clock()
|
|
354
|
+
const expiresAt = new Date(now + this.pairingTtlMs).toISOString()
|
|
355
|
+
state.pending = [...retained, {
|
|
356
|
+
application: { bindingId, tokenHash: pairingDigest(token), owner: expectedOwner },
|
|
357
|
+
requestedAt: new Date(now).toISOString(),
|
|
358
|
+
expiresAt,
|
|
359
|
+
}]
|
|
360
|
+
await this.writeState(state)
|
|
361
|
+
return { token, expiresAt }
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async claimApplicationTelegramPairing(
|
|
366
|
+
token: string,
|
|
367
|
+
telegramUserId: number,
|
|
368
|
+
telegramChatId: number,
|
|
369
|
+
authorize: (bindingId: string, owner: Owner) => Promise<boolean>,
|
|
370
|
+
): Promise<Owner | null> {
|
|
371
|
+
if (!pairingToken(token) || !isPositiveId(telegramUserId) || !isPositiveId(telegramChatId)) return null
|
|
372
|
+
return this.withLock(async () => {
|
|
373
|
+
const state = this.prune(await this.readState())
|
|
374
|
+
const request = state.pending.find((candidate): candidate is ApplicationPairingRequest =>
|
|
375
|
+
applicationPairing(candidate) && /^[a-f0-9]{64}$/.test(candidate.application.tokenHash) &&
|
|
376
|
+
timingSafeEqual(Buffer.from(candidate.application.tokenHash, 'hex'), Buffer.from(pairingDigest(token), 'hex')))
|
|
377
|
+
if (!request || telegramOwner(state.owner) || !sameOwner(request.application.owner, state.owner)) {
|
|
378
|
+
if (request) state.pending = state.pending.filter((candidate) => candidate !== request)
|
|
379
|
+
await this.writeState(state)
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
if (!await authorize(request.application.bindingId, request.application.owner)) {
|
|
383
|
+
state.pending = state.pending.filter((candidate) => candidate !== request)
|
|
384
|
+
await this.writeState(state)
|
|
385
|
+
return null
|
|
386
|
+
}
|
|
387
|
+
const owner: Owner = {
|
|
388
|
+
id: ownerId(state.owner!),
|
|
389
|
+
generation: state.owner!.generation,
|
|
390
|
+
telegramUserId,
|
|
391
|
+
telegramChatId,
|
|
392
|
+
telegramLinkedAt: crypto.randomUUID(),
|
|
393
|
+
pairedAt: state.owner!.pairedAt,
|
|
394
|
+
}
|
|
395
|
+
state.owner = owner
|
|
396
|
+
state.pending = []
|
|
397
|
+
await this.writeState(state)
|
|
398
|
+
return owner
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
285
402
|
async approveOwner(telegramUserId: number, group = false): Promise<Owner> {
|
|
286
403
|
if (!(group ? Number.isSafeInteger(telegramUserId) && telegramUserId < 0 : isPositiveId(telegramUserId))) throw new Error('Supply a positive user ID or negative group ID')
|
|
287
404
|
return this.withLock(async () => {
|
|
288
405
|
const state = this.prune(await this.readState())
|
|
289
406
|
if (telegramOwner(state.owner)) throw new Error('An owner is already paired; unlink locally before replacing its Telegram channel')
|
|
290
|
-
const request = state.pending.find((candidate) => group
|
|
407
|
+
const request = state.pending.find((candidate): candidate is TelegramPairingRequest => telegramPairing(candidate) && (group
|
|
291
408
|
? candidate.kind === 'group' && candidate.telegramChatId === telegramUserId
|
|
292
|
-
: candidate.kind === undefined && candidate.telegramUserId === telegramUserId)
|
|
409
|
+
: candidate.kind === undefined && candidate.telegramUserId === telegramUserId))
|
|
293
410
|
if (!request) throw new Error('No active pairing request exists for that Telegram user ID')
|
|
294
411
|
const owner: Owner = {
|
|
295
412
|
generation: state.owner ? state.owner.generation : crypto.randomUUID(),
|
|
@@ -463,14 +580,75 @@ export class ControlStore {
|
|
|
463
580
|
})
|
|
464
581
|
}
|
|
465
582
|
|
|
583
|
+
async normalizeProviderBindings(catalog: ModelChoice[]): Promise<boolean> {
|
|
584
|
+
const providerFor = (preset: AiPreset): AiPreset => {
|
|
585
|
+
if (preset.provider || preset.cli !== 'codex' || !preset.model) return preset
|
|
586
|
+
const matches = catalog.filter((model) => model.cli === preset.cli && model.model === preset.model && model.provider &&
|
|
587
|
+
(preset.effort === undefined || model.efforts.includes(preset.effort)))
|
|
588
|
+
if (matches.length !== 1) return preset
|
|
589
|
+
const model = matches[0]
|
|
590
|
+
return persistedPreset({
|
|
591
|
+
...preset,
|
|
592
|
+
provider: model.provider,
|
|
593
|
+
})
|
|
594
|
+
}
|
|
595
|
+
return this.withLock(async () => {
|
|
596
|
+
const state = await this.readState()
|
|
597
|
+
if (!state.ai) return false
|
|
598
|
+
let changed = false
|
|
599
|
+
const normalize = (preset: AiPreset | undefined): AiPreset | undefined => {
|
|
600
|
+
if (!preset) return undefined
|
|
601
|
+
const next = providerFor(preset)
|
|
602
|
+
if (next !== preset) changed = true
|
|
603
|
+
return next
|
|
604
|
+
}
|
|
605
|
+
state.ai.presets = state.ai.presets.map((preset) => normalize(preset)!)
|
|
606
|
+
if (state.activeSession?.preset) state.activeSession.preset = normalize(state.activeSession.preset)
|
|
607
|
+
state.sessions = state.sessions?.map((session) => ({
|
|
608
|
+
...session,
|
|
609
|
+
...(session.preset ? { preset: normalize(session.preset) } : {}),
|
|
610
|
+
}))
|
|
611
|
+
if (changed) await this.writeState(state)
|
|
612
|
+
return changed
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
466
616
|
async captureChoice(initial: AiPreset, title?: string): Promise<ExecutionChoice> {
|
|
617
|
+
// Hot path (every inbound message and every source tick): when the pinned
|
|
618
|
+
// session already matches the selected preset, return it with zero
|
|
619
|
+
// control/ writes and without taking the lock. Any divergence (missing
|
|
620
|
+
// session, CLI/provider/model change, untitled fresh session) falls
|
|
621
|
+
// through to the serializing slow path. A concurrent control edit inside
|
|
622
|
+
// the microsecond unlocked window fails closed downstream (the execution
|
|
623
|
+
// session lookup rejects a stale pin; the batch can be retried).
|
|
624
|
+
const settled = await this.readState()
|
|
625
|
+
const settledAi = settled.ai
|
|
626
|
+
const settledPreset = settledAi?.presets.find((p) => p.id === settledAi.selectedId)
|
|
627
|
+
const session = settled.activeSession
|
|
628
|
+
if (settledPreset && session && (session.cli || session.hasStarted) &&
|
|
629
|
+
!(session.cli === settledPreset.cli && session.hasStarted && session.preset &&
|
|
630
|
+
(session.preset.provider !== settledPreset.provider || session.preset.model !== settledPreset.model)) &&
|
|
631
|
+
(session.cli !== settledPreset.cli || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
|
|
632
|
+
Boolean(session.title || session.hasStarted || !title?.trim()))
|
|
633
|
+
return { sessionId: session.sessionId, preset: settledPreset }
|
|
467
634
|
return this.withLock(async () => {
|
|
468
635
|
const state = await this.readState()
|
|
469
636
|
state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
|
|
470
637
|
const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
|
|
471
638
|
state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
|
|
472
639
|
if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
|
|
473
|
-
if (state.activeSession.cli === preset.cli)
|
|
640
|
+
if (state.activeSession.cli === preset.cli) {
|
|
641
|
+
const previous = state.activeSession.preset
|
|
642
|
+
// Same CLI, different provider/model: web/Telegram must not resume the old native thread.
|
|
643
|
+
// Undefined is the native client default and is a distinct selection.
|
|
644
|
+
if (state.activeSession.hasStarted && previous &&
|
|
645
|
+
(previous.provider !== preset.provider || previous.model !== preset.model)) {
|
|
646
|
+
;(state.sessions ??= []).push(state.activeSession)
|
|
647
|
+
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset }
|
|
648
|
+
} else {
|
|
649
|
+
state.activeSession.preset = preset
|
|
650
|
+
}
|
|
651
|
+
}
|
|
474
652
|
if (!state.activeSession.title && !state.activeSession.hasStarted && title?.trim())
|
|
475
653
|
state.activeSession.title = title.replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
476
654
|
await this.writeState(state)
|
package/src/conversation-menu.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { InlineKeyboard, type Context } from 'grammy'
|
|
2
2
|
import { ControlStore, sessionTitle } from './control-state.js'
|
|
3
|
-
import type { RunStore } from './runs.js'
|
|
4
3
|
|
|
5
4
|
// IDs identify existing relay bindings only; the engine still owns all context.
|
|
6
|
-
|
|
5
|
+
// Titles come from owner renames; no run text or delivery history is read here.
|
|
6
|
+
export const createConversationMenu = (control: ControlStore) => {
|
|
7
7
|
const render = async (ctx: Context, text: string, keyboard: InlineKeyboard) => {
|
|
8
8
|
if (!ctx.callbackQuery?.message) { await ctx.reply(text, { reply_markup: keyboard }); return }
|
|
9
9
|
try { await ctx.editMessageText(text, { reply_markup: keyboard }) }
|
|
@@ -13,23 +13,12 @@ export const createConversationMenu = (control: ControlStore, runs: Pick<RunStor
|
|
|
13
13
|
}
|
|
14
14
|
const sessionsWithNames = async () => {
|
|
15
15
|
const sessions = await control.listSessions()
|
|
16
|
-
|
|
17
|
-
//
|
|
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()
|
|
16
|
+
// Display session bindings only. Titles come from explicit owner renames;
|
|
17
|
+
// no prompt, run or delivery history is read to derive a menu label.
|
|
21
18
|
return sessions.flatMap((session, index) => {
|
|
22
19
|
if (session.title) return [session]
|
|
23
|
-
|
|
24
|
-
|
|
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 }]
|
|
20
|
+
if (!session.hasStarted && !session.nativeSessionId) return []
|
|
21
|
+
return [{ ...session, title: `Untitled conversation ${index + 1}` }]
|
|
33
22
|
})
|
|
34
23
|
}
|
|
35
24
|
const list = async (ctx: Context, archived = false, page = 0) => {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type DeliverySocketRequest = { op: string; payload?: unknown }
|
|
2
|
+
export function deliverySocketPath(controlDir: string): string
|
|
3
|
+
export function callDeliverySocket(socketPath: string, op: DeliverySocketRequest, timeoutMs?: number): Promise<unknown>
|
|
4
|
+
export function deliverySocketAlive(socketPath: string, timeoutMs?: number): Promise<boolean>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createConnection } from 'node:net'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
// Plain-JS delivery transport shared by the TypeScript relay and the
|
|
6
|
+
// plain-Node tools (plugin manager, update supervisor, install status). The
|
|
7
|
+
// relay owns run records in memory; every other process reaches them through
|
|
8
|
+
// this socket on the shared control volume. Newline-delimited JSON frames:
|
|
9
|
+
// {id, op, payload} -> {id, ok, result?, error?}.
|
|
10
|
+
//
|
|
11
|
+
// Host-capable deployments publish a loopback TCP endpoint as well: on Docker
|
|
12
|
+
// Desktop/OrbStack the container's Unix socket is not connectable from the
|
|
13
|
+
// macOS host, so host processes fall back to the relay's authenticated
|
|
14
|
+
// loopback listener described by control/delivery-endpoint.json.
|
|
15
|
+
export const deliverySocketPath = controlDir => path.join(controlDir, 'delivery.sock')
|
|
16
|
+
|
|
17
|
+
const encode = value => `${JSON.stringify(value)}\n`
|
|
18
|
+
const endpointName = 'delivery-endpoint.json'
|
|
19
|
+
const connectFailureCodes = new Set(['ENOENT', 'ECONNREFUSED', 'EACCES', 'EPROTOTYPE'])
|
|
20
|
+
|
|
21
|
+
const unavailable = (target, cause) => {
|
|
22
|
+
const error = new Error(`Delivery relay unavailable at ${target}`)
|
|
23
|
+
if (cause !== undefined) error.cause = cause
|
|
24
|
+
error.connectFailure = true
|
|
25
|
+
return error
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A timeout is not a connect failure: the relay may already have processed the
|
|
29
|
+
// request, so replaying the frame elsewhere could duplicate an uncertain write.
|
|
30
|
+
const timedOut = target => new Error(`Delivery relay unavailable at ${target}`)
|
|
31
|
+
|
|
32
|
+
const exchange = (connect, envelope, timeoutMs, target) => new Promise((resolve, reject) => {
|
|
33
|
+
let done = false
|
|
34
|
+
const socket = connect()
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
if (done) return
|
|
37
|
+
done = true
|
|
38
|
+
socket.destroy()
|
|
39
|
+
reject(timedOut(target))
|
|
40
|
+
}, timeoutMs)
|
|
41
|
+
let buffer = ''
|
|
42
|
+
socket.setEncoding('utf8')
|
|
43
|
+
socket.once('error', error => {
|
|
44
|
+
if (done) return
|
|
45
|
+
done = true
|
|
46
|
+
clearTimeout(timer)
|
|
47
|
+
reject(error?.code && connectFailureCodes.has(error.code) ? unavailable(target, error) : error)
|
|
48
|
+
})
|
|
49
|
+
socket.on('data', chunk => {
|
|
50
|
+
buffer += chunk
|
|
51
|
+
let newline
|
|
52
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
53
|
+
const line = buffer.slice(0, newline)
|
|
54
|
+
buffer = buffer.slice(newline + 1)
|
|
55
|
+
if (!line.trim()) continue
|
|
56
|
+
let response
|
|
57
|
+
try { response = JSON.parse(line) } catch { continue }
|
|
58
|
+
if (response.id !== envelope.id) continue
|
|
59
|
+
if (done) return
|
|
60
|
+
done = true
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
socket.destroy()
|
|
63
|
+
if (response.ok) resolve(response.result)
|
|
64
|
+
else reject(new Error(typeof response.error === 'string' ? response.error : 'Delivery request failed'))
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
socket.write(encode(envelope))
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const readEndpoint = async controlDir => {
|
|
71
|
+
try {
|
|
72
|
+
const value = JSON.parse(await readFile(path.join(controlDir, endpointName), 'utf8'))
|
|
73
|
+
if (value?.host !== '127.0.0.1' || !Number.isSafeInteger(value.port) || value.port < 1 || value.port > 65535 ||
|
|
74
|
+
typeof value.token !== 'string' || !/^[a-f0-9]{64}$/.test(value.token)) return null
|
|
75
|
+
return value
|
|
76
|
+
} catch { return null }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const callDeliverySocket = async (socketPath, op, timeoutMs = 130_000) => {
|
|
80
|
+
const id = `${Date.now().toString(36)}_${Math.floor(Math.random() * 0xffffff).toString(16)}`
|
|
81
|
+
try {
|
|
82
|
+
return await exchange(() => createConnection(socketPath), { id, ...op }, timeoutMs, socketPath)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (!error?.connectFailure) throw error
|
|
85
|
+
}
|
|
86
|
+
const endpoint = await readEndpoint(path.dirname(socketPath))
|
|
87
|
+
if (!endpoint) throw unavailable(socketPath)
|
|
88
|
+
const target = `${endpoint.host}:${endpoint.port}`
|
|
89
|
+
try {
|
|
90
|
+
return await exchange(() => createConnection({ host: endpoint.host, port: endpoint.port }), { id, token: endpoint.token, ...op }, timeoutMs, target)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (!error?.connectFailure) throw error
|
|
93
|
+
throw unavailable(`${socketPath} and ${target}`, error)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const deliverySocketAlive = async (socketPath, timeoutMs = 2000) => {
|
|
98
|
+
try {
|
|
99
|
+
await callDeliverySocket(socketPath, { op: 'ping' }, timeoutMs)
|
|
100
|
+
return true
|
|
101
|
+
} catch {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
}
|