@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.
- package/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +67 -0
- package/Dockerfile +7 -9
- package/README.md +10 -13
- 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 +40 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +79 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/application-channel.ts +78 -7
- 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/config.ts +12 -27
- package/src/control-state.ts +175 -8
- 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 +20 -23
- package/src/files.ts +38 -11
- package/src/host-executor.ts +28 -14
- package/src/inbox.ts +15 -49
- package/src/index.ts +95 -52
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +7 -2
- 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 +132 -23
- 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/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- 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 +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +36 -0
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -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 +2 -2
- package/test/config.test.ts +38 -43
- 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 +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +10 -11
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +74 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -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 +119 -2
- 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/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 +93 -13
- package/test/workspace.test.ts +4 -1
- 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 -43
- 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 -5
- package/templates/agent-guidance.md +0 -17
- 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/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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
+
export class ApprovalStore {
|
|
48
|
+
constructor(private readonly controlDir: string) {}
|
|
47
49
|
|
|
48
|
-
private
|
|
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
|
|
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
|
-
|
|
58
|
-
const existing =
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
const existing =
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
const existing =
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
}
|
package/src/channel-backend.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
const telegramBotToken =
|
|
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
|
}
|
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,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: [] }
|
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
|
+
}
|