@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.
Files changed (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +42 -5
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
@@ -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
- export type PairingRequest = {
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 identity = (person: unknown): boolean => {
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((p) => identity(p) && Number.isFinite(Date.parse(p.expiresAt))) &&
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, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
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) state.activeSession.preset = preset
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)
@@ -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
- export const createConversationMenu = (control: ControlStore, runs: Pick<RunStore, 'list'>) => {
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
- 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()
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
- 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 }]
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
+ }