@jc_stack/ez-agents 0.1.0-beta.34 → 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 (137) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +67 -0
  5. package/Dockerfile +7 -9
  6. package/README.md +10 -13
  7. package/bin/ez +97 -0
  8. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  9. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  10. package/bin/ezenciel-agents.mjs +1 -1
  11. package/compose.yaml +40 -4
  12. package/docker/entrypoint.sh +1 -1
  13. package/docker/healthcheck.mjs +12 -4
  14. package/docker/run.ts +5 -6
  15. package/docker/smoke.mjs +28 -6
  16. package/docs/application-channel.md +32 -10
  17. package/docs/architecture/authority-boundaries.md +2 -2
  18. package/docs/channel-backend.md +4 -3
  19. package/docs/docker-runtime.md +79 -11
  20. package/docs/local-qa.md +2 -2
  21. package/docs/plugin-guide.md +202 -0
  22. package/docs/plugins.md +48 -2
  23. package/docs/releasing.md +13 -0
  24. package/docs/responsive-channels.md +1 -1
  25. package/docs/scheduling.md +4 -4
  26. package/docs/setup.md +11 -5
  27. package/docs/upgrades.md +46 -23
  28. package/package.json +5 -5
  29. package/scripts/smoke-scheduler.ts +10 -3
  30. package/scripts/smoke.ts +14 -11
  31. package/src/agent-guidance.ts +6 -9
  32. package/src/agent-install.ts +28 -7
  33. package/src/application-channel.ts +78 -7
  34. package/src/application-cli.ts +2 -2
  35. package/src/approval-cli.ts +14 -7
  36. package/src/approval.ts +27 -37
  37. package/src/channel-backend.ts +2 -3
  38. package/src/config.ts +12 -27
  39. package/src/control-state.ts +175 -8
  40. package/src/conversation-menu.ts +6 -17
  41. package/src/delivery-socket-client.d.mts +4 -0
  42. package/src/delivery-socket-client.mjs +104 -0
  43. package/src/delivery-socket.ts +354 -0
  44. package/src/execution-authority.ts +21 -5
  45. package/src/executor.ts +20 -23
  46. package/src/files.ts +38 -11
  47. package/src/host-executor.ts +28 -14
  48. package/src/inbox.ts +15 -49
  49. package/src/index.ts +95 -52
  50. package/src/install-tools.mjs +97 -22
  51. package/src/ledger-endpoint.d.mts +2 -0
  52. package/src/ledger-endpoint.mjs +17 -0
  53. package/src/menu.ts +7 -2
  54. package/src/message-history.ts +13 -18
  55. package/src/message.ts +35 -18
  56. package/src/plugin-broker.mjs +396 -0
  57. package/src/plugins/connection.mjs +2 -2
  58. package/src/plugins/manager.mjs +132 -23
  59. package/src/plugins/native-tasks.mjs +2 -2
  60. package/src/prompt-suffix.ts +12 -0
  61. package/src/react.ts +6 -4
  62. package/src/runs.ts +156 -243
  63. package/src/schedule-cli.ts +29 -10
  64. package/src/scheduled-tasks.ts +53 -10
  65. package/src/scheduler.ts +20 -7
  66. package/src/service.ts +3 -3
  67. package/src/software-status.ts +36 -4
  68. package/src/task-executor.ts +2 -2
  69. package/src/task-workspace.ts +4 -3
  70. package/src/tasks.ts +1 -1
  71. package/src/telegram-provisioning-cli.ts +22 -0
  72. package/src/telegram-provisioning.ts +214 -0
  73. package/src/updates/control.mjs +77 -7
  74. package/src/updates/runtime.mjs +28 -8
  75. package/src/updates/status.mjs +25 -3
  76. package/src/updates/supervisor.mjs +15 -16
  77. package/src/version.ts +20 -0
  78. package/src/workspace.ts +3 -9
  79. package/templates/failure-review.md +1 -1
  80. package/templates/maintainer-purpose.md +1 -1
  81. package/test/agent-guidance.test.ts +14 -80
  82. package/test/agent-install.test.ts +14 -0
  83. package/test/ai.test.ts +36 -0
  84. package/test/application-channel.test.ts +61 -13
  85. package/test/application-only.test.ts +14 -13
  86. package/test/channel-backend.test.ts +3 -7
  87. package/test/channel-delivery.test.ts +4 -1
  88. package/test/channel-owner.test.ts +6 -3
  89. package/test/codex-context.test.ts +2 -2
  90. package/test/config.test.ts +38 -43
  91. package/test/control-state.test.ts +83 -4
  92. package/test/conversation-menu.test.ts +2 -4
  93. package/test/delivery-transport.test.ts +72 -0
  94. package/test/docker-runtime.test.ts +27 -14
  95. package/test/engine-handoff.test.ts +7 -6
  96. package/test/event-sources.test.ts +2 -2
  97. package/test/execution-authority.test.ts +9 -9
  98. package/test/executor.test.ts +10 -11
  99. package/test/failure.test.ts +12 -5
  100. package/test/files.test.ts +2 -2
  101. package/test/helpers/ledger.ts +18 -0
  102. package/test/host-executor.test.ts +74 -0
  103. package/test/inbox.test.ts +8 -10
  104. package/test/install-config.test.ts +1 -0
  105. package/test/install-tools.test.mjs +74 -7
  106. package/test/intake-relay.test.ts +58 -11
  107. package/test/message-history.test.ts +39 -47
  108. package/test/native-tasks.test.ts +1 -1
  109. package/test/plugin-broker.test.mjs +172 -0
  110. package/test/plugin-manager.test.mjs +119 -2
  111. package/test/reaction.test.ts +4 -0
  112. package/test/relay.test.ts +5 -7
  113. package/test/repair-policy.test.ts +1 -4
  114. package/test/schedule-cli.test.ts +14 -9
  115. package/test/scheduled-tasks.test.ts +29 -6
  116. package/test/scheduler-host.test.ts +5 -5
  117. package/test/scheduler-relay.test.ts +15 -9
  118. package/test/security.test.ts +6 -3
  119. package/test/software-status.test.ts +21 -3
  120. package/test/telegram-provisioning.test.ts +160 -0
  121. package/test/updates.test.mjs +93 -13
  122. package/test/workspace.test.ts +4 -1
  123. package/bin/ezenciel-agents-watch.mjs +0 -8
  124. package/compose.workforce-watch.yaml +0 -33
  125. package/docker/recovery.ts +0 -11
  126. package/docs/pagerduty.md +0 -42
  127. package/docs/workforce-watch.md +0 -101
  128. package/src/pagerduty.ts +0 -109
  129. package/src/reply-context.ts +0 -43
  130. package/src/update-attention.ts +0 -18
  131. package/src/workforce-watch-cli.ts +0 -14
  132. package/src/workforce-watch.ts +0 -155
  133. package/templates/agent/AGENTS.md +0 -5
  134. package/templates/agent-guidance.md +0 -17
  135. package/test/pagerduty.test.ts +0 -104
  136. package/test/update-attention.test.ts +0 -20
  137. package/test/workforce-watch.test.ts +0 -180
package/src/approval.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
- import path from 'node:path'
3
1
  import { assertId } from './identity.js'
4
2
  import { ownerEpoch, ownerId, type Owner } from './control-state.js'
5
3
 
@@ -34,28 +32,32 @@ const isApprovalRecord = (value: unknown): value is ApprovalRecord => {
34
32
  )
35
33
  }
36
34
 
37
- export class ApprovalStore {
38
- private readonly approvalsDir: string
35
+ // Stateless pipe: approvals live in relay memory. Engine children request
36
+ // decisions through the delivery socket (folded into the approval send);
37
+ // callbacks and task arbitration record them in-relay. A restart drops
38
+ // pending approvals by design.
39
+ const approvalsByControl = new Map<string, Map<string, ApprovalRecord>>()
39
40
 
40
- constructor(controlDir: string) {
41
- this.approvalsDir = path.join(controlDir, 'approvals')
42
- }
41
+ const approvalsFor = (controlDir: string): Map<string, ApprovalRecord> => {
42
+ let map = approvalsByControl.get(controlDir)
43
+ if (!map) { map = new Map(); approvalsByControl.set(controlDir, map) }
44
+ return map
45
+ }
43
46
 
44
- private async ensure(): Promise<void> {
45
- await mkdir(this.approvalsDir, { recursive: true, mode: 0o700 })
46
- }
47
+ export class ApprovalStore {
48
+ constructor(private readonly controlDir: string) {}
47
49
 
48
- private filePath(actionId: string): string {
50
+ private key(actionId: string): string {
49
51
  const safe = assertId(actionId)
50
52
  if (safe.length > 40) throw new Error('Approval identifier must be at most 40 characters')
51
- return path.join(this.approvalsDir, `${safe}.json`)
53
+ return safe
52
54
  }
53
55
 
54
56
  async requestApproval(actionId: string, prompt: string, runId?: string): Promise<ApprovalRecord> {
55
57
  if (!prompt.trim()) throw new Error('Approval prompt is empty')
56
58
  if (runId) assertId(runId)
57
- await this.ensure()
58
- const existing = await this.getDecision(actionId)
59
+ const store = approvalsFor(this.controlDir)
60
+ const existing = store.get(this.key(actionId)) ?? null
59
61
  if (existing) {
60
62
  if (existing.runId !== runId || existing.prompt !== prompt)
61
63
  throw new Error('Approval ID already belongs to another request')
@@ -70,10 +72,7 @@ export class ApprovalStore {
70
72
  decision: 'pending',
71
73
  createdAt: new Date().toISOString(),
72
74
  }
73
- const file = this.filePath(actionId)
74
- const temporary = `${file}.${process.pid}.tmp`
75
- await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
76
- await rename(temporary, file)
75
+ store.set(this.key(actionId), record)
77
76
  return record
78
77
  }
79
78
 
@@ -83,8 +82,8 @@ export class ApprovalStore {
83
82
  decidedBy: number,
84
83
  decisionUpdateId?: number,
85
84
  ): Promise<ApprovalRecord> {
86
- await this.ensure()
87
- const existing = await this.getDecision(actionId)
85
+ const store = approvalsFor(this.controlDir)
86
+ const existing = store.get(this.key(actionId)) ?? null
88
87
  if (!existing) throw new Error('Unknown approval request')
89
88
  if (
90
89
  decisionUpdateId !== undefined &&
@@ -102,34 +101,25 @@ export class ApprovalStore {
102
101
  decidedBy,
103
102
  decisionUpdateId,
104
103
  }
105
- const file = this.filePath(actionId)
106
- const temporary = `${file}.${process.pid}.tmp`
107
- await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
108
- await rename(temporary, file)
104
+ store.set(this.key(actionId), record)
109
105
  return record
110
106
  }
111
107
 
112
108
  async recordOwnerDecision(actionId: string, decision: 'approved' | 'denied', owner: Owner): Promise<ApprovalRecord> {
113
- await this.ensure()
114
- const existing = await this.getDecision(actionId)
109
+ const store = approvalsFor(this.controlDir)
110
+ const existing = store.get(this.key(actionId)) ?? null
115
111
  if (!existing) throw new Error('Unknown approval request')
116
112
  if (existing.decision !== 'pending') throw new Error('Approval already decided')
117
113
  if (Date.now() - Date.parse(existing.createdAt) > 900_000) throw new Error('Approval expired')
118
114
  const record: ApprovalRecord = {...existing,version:2,decision,decidedAt:new Date().toISOString(),decidedOwnerId:ownerId(owner),decidedOwnerEpoch:ownerEpoch(owner)}
119
- const file=this.filePath(actionId),temporary=`${file}.${process.pid}.tmp`
120
- await writeFile(temporary,`${JSON.stringify(record,null,2)}\n`,{mode:0o600});await rename(temporary,file)
115
+ store.set(this.key(actionId), record)
121
116
  return record
122
117
  }
123
118
 
124
119
  async getDecision(actionId: string): Promise<ApprovalRecord | null> {
125
- try {
126
- const parsed: unknown = JSON.parse(await readFile(this.filePath(actionId), 'utf8'))
127
- if (!isApprovalRecord(parsed) || parsed.actionId !== actionId)
128
- throw new Error('Invalid approval record shape')
129
- return parsed
130
- } catch (error: unknown) {
131
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
132
- throw error
133
- }
120
+ const record = approvalsFor(this.controlDir).get(this.key(actionId)) ?? null
121
+ if (record && (!isApprovalRecord(record) || record.actionId !== actionId))
122
+ throw new Error('Invalid approval record shape')
123
+ return record
134
124
  }
135
125
  }
@@ -1,7 +1,6 @@
1
- import { readFile } from 'node:fs/promises'
2
1
  import type { Config } from './config.js'
3
2
  import type { RunRecord } from './runs.js'
4
- import { workspaceFile } from './files.js'
3
+ import { readStagedAttachment } from './files.js'
5
4
 
6
5
  // Application-owned jobs; no CLI state, provider credentials or business routing here.
7
6
  export async function dispatchChannel(config: Config, run: RunRecord): Promise<string | null> {
@@ -14,7 +13,7 @@ export async function dispatchChannel(config: Config, run: RunRecord): Promise<s
14
13
  for (const item of run.items ?? []) {
15
14
  let attachment
16
15
  if (item.attachment) {
17
- const bytes = await readFile(await workspaceFile(config.workspace, item.attachment.path))
16
+ const bytes = await readStagedAttachment([config.controlDir, config.workspace], item.attachment.path)
18
17
  if (bytes.length > 12 * 1024 * 1024) throw new Error('Channel attachment exceeds 12 MB')
19
18
  attachment = { type: item.attachment.type, data: bytes.toString('base64') }
20
19
  }
package/src/config.ts CHANGED
@@ -26,10 +26,6 @@ export type Config = ControlConfig & {
26
26
  applicationHost?: string
27
27
  geminiApiKey?: string
28
28
  openaiApiKey?: string
29
- pagerDutyRoutingKey?: string
30
- pagerDutyStocksHealthUrl?: string
31
- pagerDutyPollMs?: number
32
- pagerDutyFailureThreshold?: number
33
29
  }
34
30
 
35
31
  const positiveInteger = (value: string | undefined, name: string, fallback?: number): number => {
@@ -49,12 +45,20 @@ export const loadControlConfig = (env: NodeJS.ProcessEnv = process.env): Control
49
45
  }
50
46
  }
51
47
 
48
+ export const telegramToken = (env: NodeJS.ProcessEnv = process.env): string => {
49
+ const token = env.TELEGRAM_BOT_TOKEN?.trim() || ''
50
+ if (token && !/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) throw new Error('TELEGRAM_BOT_TOKEN is malformed; refusing to start Telegram transport')
51
+ return token
52
+ }
53
+
52
54
  export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
53
- if (env.EZ_TELEGRAM_ENABLED !== undefined && !['true', 'false'].includes(env.EZ_TELEGRAM_ENABLED)) throw new Error('EZ_TELEGRAM_ENABLED must be true or false')
54
- const telegramEnabled = env.EZ_TELEGRAM_ENABLED !== 'false'
55
- const telegramBotToken = telegramEnabled ? env.TELEGRAM_BOT_TOKEN?.trim() || '' : ''
55
+ const legacy = env.EZ_TELEGRAM_ENABLED
56
+ if (legacy !== undefined && legacy !== 'true' && legacy !== 'false') throw new Error('EZ_TELEGRAM_ENABLED must be true or false')
57
+ const telegramBotToken = telegramToken(env)
58
+ const telegramEnabled = Boolean(telegramBotToken)
59
+ if (legacy !== undefined && (legacy === 'false') === telegramEnabled)
60
+ throw new Error('EZ_TELEGRAM_ENABLED was removed; it contradicts the inferred Telegram transport (a token means enabled). Unset EZ_TELEGRAM_ENABLED, and remove any stale TELEGRAM_BOT_TOKEN to stay application-only.')
56
61
  if (!telegramEnabled && !env.EZ_APPLICATION_PORT) throw new Error('Application-only execution requires EZ_APPLICATION_PORT')
57
- if (telegramEnabled && !telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
58
62
 
59
63
  if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
60
64
  if (env.EZ_APPLICATION_PORT && env.EZ_CHANNEL_BACKEND_URL) throw new Error('Application input requires the native Ez executor, not a channel backend')
@@ -63,17 +67,6 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
63
67
  const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
64
68
  if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
65
69
  if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || transport !== 'local')) throw new Error('External Codex sandbox requires native local execution')
66
- const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
67
- const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
68
- if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
69
- throw new Error('PAGERDUTY_ROUTING_KEY is required when EZ_PAGERDUTY_STOCKS_HEALTH_URL is set')
70
- if (pagerDutyStocksHealthUrl) {
71
- let url: URL
72
- try { url = new URL(pagerDutyStocksHealthUrl) }
73
- catch { throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL') }
74
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash)
75
- throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL without credentials or a fragment')
76
- }
77
70
  return {
78
71
  ...loadControlConfig(env),
79
72
  telegramEnabled,
@@ -92,13 +85,5 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
92
85
  channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
93
86
  geminiApiKey: env.GEMINI_API_KEY?.trim(),
94
87
  openaiApiKey: env.OPENAI_API_KEY?.trim(),
95
- pagerDutyRoutingKey,
96
- pagerDutyStocksHealthUrl,
97
- pagerDutyPollMs: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
98
- ? positiveInteger(env.EZ_PAGERDUTY_POLL_SECONDS, 'EZ_PAGERDUTY_POLL_SECONDS', 30) * 1_000
99
- : undefined,
100
- pagerDutyFailureThreshold: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
101
- ? positiveInteger(env.EZ_PAGERDUTY_FAILURE_THRESHOLD, 'EZ_PAGERDUTY_FAILURE_THRESHOLD', 3)
102
- : undefined,
103
88
  }
104
89
  }
@@ -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,7 +580,57 @@ 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: [] }
@@ -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
+ }