@jc_stack/ez-agents 0.1.0-beta.12 → 0.1.0-beta.13
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 +1 -1
- package/AGENTS.md +10 -1
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +112 -10
- package/SECURITY.md +7 -1
- package/bin/ezenciel-agents-schedule +2 -0
- package/bin/ezenciel-agents-schedule.mjs +16 -0
- package/bin/ezenciel-agents-task +2 -0
- package/bin/ezenciel-agents-task.mjs +16 -0
- package/compose.yaml +2 -1
- package/docker/recovery.ts +2 -2
- package/docker/run.ts +2 -2
- package/docs/architecture/authority-boundaries.md +114 -12
- package/docs/architecture/event-sources.md +12 -7
- package/docs/channel-backend.md +36 -0
- package/docs/local-qa.md +45 -0
- package/docs/plugin-catalog.md +54 -0
- package/docs/plugin-contributions.md +3 -0
- package/docs/plugins.md +49 -0
- package/docs/scheduling.md +127 -0
- package/docs/selective-monitoring.md +106 -0
- package/docs/setup.md +7 -0
- package/docs/standalone-cli.md +62 -0
- package/package.json +7 -2
- package/scripts/smoke-scheduler.ts +90 -0
- package/scripts/stage-qa.mjs +42 -0
- package/src/channel-backend.ts +46 -0
- package/src/codex-session.ts +96 -0
- package/src/config.ts +6 -1
- package/src/desktop-bridge.ts +29 -11
- package/src/execution-authority.ts +24 -0
- package/src/executor.ts +66 -15
- package/src/host-executor.ts +30 -10
- package/src/inbox.ts +4 -0
- package/src/index.ts +130 -34
- package/src/plugins/exposure.mjs +13 -0
- package/src/plugins/manager.mjs +27 -12
- package/src/process-tree.ts +33 -0
- package/src/runs.ts +50 -17
- package/src/schedule-cli.ts +69 -0
- package/src/schedule-time.ts +85 -0
- package/src/scheduler.ts +121 -0
- package/src/source-cli.ts +1 -1
- package/src/task-cli.ts +16 -0
- package/src/task-executor.ts +63 -0
- package/src/task-mcp.ts +36 -0
- package/src/task-rpc.ts +45 -0
- package/src/task-workspace.ts +22 -0
- package/src/tasks.ts +192 -0
- package/src/updates/binding.mjs +1 -0
- package/src/updates/status.mjs +7 -1
- package/templates/agent/TOOLS.md +54 -1
- package/templates/standalone-tools.md +20 -0
- package/test/channel-backend.test.ts +100 -0
- package/test/codex-context.test.ts +36 -1
- package/test/codex-session.test.ts +49 -0
- package/test/config.test.ts +2 -2
- package/test/desktop-bridge.test.ts +19 -0
- package/test/event-sources.test.ts +47 -11
- package/test/execution-authority.test.ts +42 -0
- package/test/executor.test.ts +42 -1
- package/test/helpers/owner-run.ts +13 -0
- package/test/host-executor.test.ts +9 -3
- package/test/local-qa.test.mjs +38 -0
- package/test/plugin-manager.test.mjs +70 -1
- package/test/schedule-cli.test.ts +49 -0
- package/test/scheduler-host.test.ts +55 -0
- package/test/scheduler-relay.test.ts +67 -0
- package/test/scheduler.test.ts +104 -0
- package/test/task-native.test.ts +87 -0
- package/test/tasks.test.ts +179 -0
package/src/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import { Tasks } from './tasks.js'
|
|
2
|
+
import { taskRequests } from './task-rpc.js'
|
|
3
|
+
import { executionBlockReason } from './execution-authority.js'
|
|
4
|
+
import { dispatchChannel } from './channel-backend.js'
|
|
1
5
|
import { randomUUID } from 'node:crypto'
|
|
2
6
|
import { stat } from 'node:fs/promises'
|
|
7
|
+
import { Scheduler } from './scheduler.js'
|
|
8
|
+
import { taskWorkspace } from './task-workspace.js'
|
|
3
9
|
import { queueUpdateAttention } from './update-attention.js'
|
|
4
10
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
5
11
|
import { dirname, join, basename } from 'node:path'
|
|
@@ -29,7 +35,7 @@ import { softwareStatus } from './software-status.js'
|
|
|
29
35
|
export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
30
36
|
const safeError = (error: unknown): string => {
|
|
31
37
|
let message = error instanceof Error ? error.message : 'Unknown error'
|
|
32
|
-
for (const secret of [config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
|
|
38
|
+
for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
|
|
33
39
|
if (secret) message = message.replaceAll(secret, '[redacted]')
|
|
34
40
|
}
|
|
35
41
|
return message
|
|
@@ -40,10 +46,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
40
46
|
const runs = new RunStore(config.controlDir)
|
|
41
47
|
const inbox = new InboxStore(config.controlDir)
|
|
42
48
|
const sources = new EventSources(config.controlDir)
|
|
49
|
+
const scheduler = new Scheduler(config.controlDir)
|
|
50
|
+
const background = new Map<string, ChildProcess>()
|
|
51
|
+
const tasks = new Tasks(config.controlDir)
|
|
52
|
+
const drainTaskRequests = taskRequests(tasks)
|
|
43
53
|
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace)
|
|
44
54
|
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
45
55
|
|
|
46
56
|
let activeTypingTimer: ReturnType<typeof setInterval> | null = null
|
|
57
|
+
let activeBackend = false
|
|
47
58
|
let activeChild: ChildProcess | null = null
|
|
48
59
|
let shuttingDown = false
|
|
49
60
|
let nextSendAt = 0
|
|
@@ -91,16 +102,38 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
91
102
|
|
|
92
103
|
const startJob = async (run: RunRecord): Promise<void> => {
|
|
93
104
|
await withStartLock(async () => {
|
|
94
|
-
if (shuttingDown ||
|
|
105
|
+
if (shuttingDown || activeBackend) return
|
|
95
106
|
// stat uses the effective UID; access uses the relay's isolated real UID.
|
|
96
107
|
if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
|
|
97
108
|
if ((await runs.get(run.id))?.status !== 'queued') return
|
|
98
|
-
if (await runs.running()) return
|
|
109
|
+
if (!run.scheduled && await runs.running(false)) return
|
|
99
110
|
const owner = (await control.status()).owner
|
|
100
111
|
if (!owner || owner.telegramUserId !== run.telegramUserId || owner.telegramChatId !== run.chatId) {
|
|
101
112
|
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
|
|
102
113
|
return
|
|
103
114
|
}
|
|
115
|
+
if (config.channelBackendUrl) {
|
|
116
|
+
if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); return }
|
|
117
|
+
activeBackend = true
|
|
118
|
+
await runs.patch(run.id, { status: 'running', backendSubmitted: true })
|
|
119
|
+
void dispatchChannel(config, run).then(async reply => {
|
|
120
|
+
if (reply === null) { await runs.patch(run.id, { status: 'queued' }); return }
|
|
121
|
+
if (reply) await runs.enqueueMessage(run.id, reply, { id: `${run.id}_backend`, replyToMessageId: run.messageId })
|
|
122
|
+
await runs.patch(run.id, { status: 'completed', endedAt: new Date().toISOString() })
|
|
123
|
+
}).catch(async error => {
|
|
124
|
+
console.error('Channel backend unavailable', safeError(error))
|
|
125
|
+
// The backend deduplicates the stable run ID. Retry transport, never create another operation.
|
|
126
|
+
await new Promise(resolve => setTimeout(resolve, 5000))
|
|
127
|
+
await runs.patch(run.id, { status: error?.permanent ? 'failed' : 'queued' })
|
|
128
|
+
}).finally(() => { activeBackend = false })
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (run.scheduled && (!await scheduler.current(run, owner) || await scheduler.cancelled(run.id))) {
|
|
132
|
+
await runs.patch(run.id, {status:'cancelled',endedAt:new Date().toISOString()})
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
|
|
136
|
+
if (run.scheduled ? background.size >= 4 : activeChild) return
|
|
104
137
|
let texts = run.texts
|
|
105
138
|
if (run.external) {
|
|
106
139
|
// Availability failures leave durable queued work for a later check.
|
|
@@ -112,15 +145,25 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
112
145
|
}
|
|
113
146
|
texts = events.map(event => JSON.stringify(event))
|
|
114
147
|
}
|
|
148
|
+
if (run.taskId) {
|
|
149
|
+
try { await tasks.authorize(run) } catch {
|
|
150
|
+
await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); return
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const blockReason = run.taskId ? undefined : executionBlockReason(run, owner)
|
|
154
|
+
if (blockReason) {
|
|
155
|
+
await runs.patch(run.id, { status: 'cancelled', blockReason, endedAt: new Date().toISOString() })
|
|
156
|
+
return
|
|
157
|
+
}
|
|
115
158
|
try {
|
|
116
159
|
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
117
|
-
if (!started.execution) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
118
|
-
const session = run.external
|
|
160
|
+
if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
161
|
+
const session = run.external || run.taskId || run.scheduled
|
|
119
162
|
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
120
|
-
: await control.executionSession(started.execution)
|
|
121
|
-
const selected = started.execution
|
|
163
|
+
: await control.executionSession(started.execution!)
|
|
164
|
+
const selected = run.taskId ? { cli: 'codex', model: undefined, effort: undefined } : started.execution!.preset
|
|
122
165
|
const { child, cleanup } = await launch(texts, {
|
|
123
|
-
workspace: config.workspace,
|
|
166
|
+
workspace: run.scheduled ? await taskWorkspace(config.workspace,run.id) : config.workspace,
|
|
124
167
|
timeoutMs: config.executorTimeoutMs,
|
|
125
168
|
runId: started.id,
|
|
126
169
|
controlDir: config.controlDir,
|
|
@@ -131,9 +174,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
131
174
|
sessionId: session.nativeSessionId || session.sessionId,
|
|
132
175
|
isResume: session.hasStarted,
|
|
133
176
|
eventSource: run.external?.sourceId,
|
|
134
|
-
onSession: run.external ? undefined : (id) => control.saveNativeSession(session.sessionId, id),
|
|
177
|
+
onSession: run.scheduled ? async (id) => { await runs.patch(run.id,{nativeSessionId:id}) } : run.external || run.taskId ? undefined : (id) => control.saveNativeSession(session.sessionId, id),
|
|
135
178
|
})
|
|
136
|
-
|
|
179
|
+
if (run.scheduled) background.set(run.id,child)
|
|
180
|
+
else activeChild = child
|
|
137
181
|
const finished = new Promise<number | null>((resolve) => {
|
|
138
182
|
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
139
183
|
else child.once('close', resolve)
|
|
@@ -147,8 +191,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
147
191
|
isResume: session.hasStarted,
|
|
148
192
|
})
|
|
149
193
|
|
|
150
|
-
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
151
|
-
if (!run.external) activeTypingTimer = setInterval(() => {
|
|
194
|
+
if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
|
|
195
|
+
if (!run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
|
|
152
196
|
void bot.api.sendChatAction(run.chatId, 'typing').catch(() => {})
|
|
153
197
|
}, 4000)
|
|
154
198
|
|
|
@@ -160,24 +204,27 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
160
204
|
await withStartLock(async () => {
|
|
161
205
|
try {
|
|
162
206
|
await cleanup()
|
|
163
|
-
if (code === 0 && !run.external) await control.markSessionStarted(session.sessionId)
|
|
164
|
-
await runs.patch(started.id, { status: code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString() })
|
|
207
|
+
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
208
|
+
await runs.patch(started.id, { status: run.scheduled && await scheduler.cancelled(run.id) ? 'cancelled' : code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString() })
|
|
165
209
|
} catch (error) {
|
|
166
210
|
await runs.patch(started.id, { status: 'failed', endedAt: new Date().toISOString() })
|
|
167
211
|
console.error('Session completion failed', safeError(error))
|
|
168
212
|
} finally {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
213
|
+
if (run.scheduled) background.delete(run.id)
|
|
214
|
+
else {
|
|
215
|
+
activeChild = null
|
|
216
|
+
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
217
|
+
activeTypingTimer = null
|
|
218
|
+
}
|
|
172
219
|
}
|
|
173
220
|
})
|
|
174
221
|
console.info('run ended', { run_id: started.id, code })
|
|
175
|
-
const next = await runs.nextQueued()
|
|
222
|
+
const next = await runs.nextQueued(false)
|
|
176
223
|
if (next && !shuttingDown) await startJob(next)
|
|
177
224
|
})().catch((error) => console.error('Run completion failed', error.message))
|
|
178
225
|
})
|
|
179
226
|
} catch (error) {
|
|
180
|
-
if (activeTypingTimer) {
|
|
227
|
+
if (!run.scheduled && activeTypingTimer) {
|
|
181
228
|
clearInterval(activeTypingTimer)
|
|
182
229
|
activeTypingTimer = null
|
|
183
230
|
}
|
|
@@ -186,7 +233,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
186
233
|
await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
|
|
187
234
|
setImmediate(() => {
|
|
188
235
|
void runs
|
|
189
|
-
.nextQueued()
|
|
236
|
+
.nextQueued(false)
|
|
190
237
|
.then((next) => next && startJob(next))
|
|
191
238
|
.catch(console.error)
|
|
192
239
|
})
|
|
@@ -202,8 +249,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
202
249
|
if (shuttingDown) return
|
|
203
250
|
const owner = (await control.status()).owner
|
|
204
251
|
if (!owner) return
|
|
252
|
+
if (!config.channelBackendUrl) {
|
|
253
|
+
await drainTaskRequests()
|
|
254
|
+
for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active') {
|
|
255
|
+
try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
|
|
256
|
+
}
|
|
205
257
|
await queueUpdateAttention(config.controlDir,owner,runs,await control.captureChoice(aiMenu.initial))
|
|
206
|
-
|
|
258
|
+
}
|
|
259
|
+
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
207
260
|
try {
|
|
208
261
|
const batch = await sources.batch(source)
|
|
209
262
|
unavailableSources.delete(source.id)
|
|
@@ -213,18 +266,27 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
213
266
|
await sources.remember(source, batch)
|
|
214
267
|
const groups = new Map<string, SourceEvent[]>()
|
|
215
268
|
for (const event of batch.events) groups.set(event.conversationId, [...(groups.get(event.conversationId) || []), event])
|
|
216
|
-
for (const events of groups.values())
|
|
269
|
+
for (const events of groups.values()) {
|
|
270
|
+
const task = await tasks.match(source.id, source.bindingId, events)
|
|
271
|
+
await runs.create({
|
|
272
|
+
taskId: task?.id,
|
|
217
273
|
id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
|
|
218
274
|
texts: [], execution: await control.captureChoice(aiMenu.initial),
|
|
219
275
|
external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
|
|
220
276
|
})
|
|
277
|
+
}
|
|
221
278
|
// Acknowledgement follows durable run creation; replay uses the saved batch.
|
|
222
279
|
await sources.advance(source, batch.cursor)
|
|
223
280
|
})
|
|
224
281
|
} catch { unavailableSources.add(source.id) }
|
|
225
282
|
}
|
|
283
|
+
await runs.running(true)
|
|
284
|
+
if (!config.channelBackendUrl) await scheduler.tick(owner,runs)
|
|
285
|
+
for (const [id,child] of background) {
|
|
286
|
+
if (await scheduler.cancelled(id)) terminateJob(child)
|
|
287
|
+
}
|
|
226
288
|
for (const run of (await runs.list()).filter(r => r.status === 'queued')) {
|
|
227
|
-
if (
|
|
289
|
+
if (shuttingDown) break
|
|
228
290
|
await startJob(run)
|
|
229
291
|
}
|
|
230
292
|
})().finally(() => { sourceWork = undefined })
|
|
@@ -239,6 +301,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
239
301
|
let intakeWork: Promise<void> | undefined
|
|
240
302
|
const collectItem = (item: IncomingItem) => {
|
|
241
303
|
const message = normalizing?.message
|
|
304
|
+
item.sentAt = message?.date
|
|
305
|
+
item.caption = message?.caption
|
|
306
|
+
item.albumId = message?.media_group_id
|
|
242
307
|
if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
|
|
243
308
|
if (message && !message.text && message.reply_to_message) {
|
|
244
309
|
const quoted = message.reply_to_message
|
|
@@ -284,6 +349,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
284
349
|
telegramUserId: first.fromId,
|
|
285
350
|
messageId: first.messageId,
|
|
286
351
|
texts: collected.map((item) => item.text),
|
|
352
|
+
items: collected,
|
|
287
353
|
execution: batch.entries[0].execution,
|
|
288
354
|
})
|
|
289
355
|
await inbox.finish(batch.id)
|
|
@@ -328,6 +394,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
328
394
|
!owner ||
|
|
329
395
|
origin.telegramUserId !== owner.telegramUserId ||
|
|
330
396
|
origin.chatId !== owner.telegramChatId ||
|
|
397
|
+
(origin.scheduled && origin.scheduled.pairedAt !== owner.pairedAt) ||
|
|
331
398
|
item.chatId !== origin.chatId
|
|
332
399
|
)
|
|
333
400
|
throw new Error('Outbox ownership mismatch')
|
|
@@ -342,7 +409,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
342
409
|
])
|
|
343
410
|
console.info('run reaction sent', { run_id: item.runId, emoji })
|
|
344
411
|
} else if (item.type === 'document' && item.documentPath) {
|
|
345
|
-
const docPath = await workspaceFile(config.workspace, item.documentPath)
|
|
412
|
+
const docPath = await workspaceFile(origin.scheduled ? await taskWorkspace(config.workspace,origin.id) : config.workspace, item.documentPath)
|
|
346
413
|
await paceSend()
|
|
347
414
|
attemptedDelivery = true
|
|
348
415
|
const sent = await bot.api.sendDocument(item.chatId, new InputFile(docPath), {
|
|
@@ -424,7 +491,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
424
491
|
const commands = mainCommands
|
|
425
492
|
const controlCommand = (text?: string) => text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
|
|
426
493
|
const statusText = async () => {
|
|
427
|
-
const running = await runs.running()
|
|
494
|
+
const running = await runs.running(false)
|
|
428
495
|
const all = await runs.list()
|
|
429
496
|
const incoming = await inbox.status()
|
|
430
497
|
const delivery = await runs.deliveryStatus()
|
|
@@ -437,8 +504,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
437
504
|
`Default: ${ai.presets.find((p) => p.id === ai.defaultId)!.name}`,
|
|
438
505
|
`Session: ${session?.sessionId.slice(0, 8) || 'none'}`,
|
|
439
506
|
`Work: ${running ? `running ${running.id}` : 'idle'}`,
|
|
507
|
+
`Background: ${all.filter(r => r.scheduled && r.status === 'running').map(r=>r.id).join(', ') || 'idle'}`,
|
|
440
508
|
`Queue: ${all.filter((r) => r.status === 'queued').length} runs; ${incoming.pending} incoming messages`,
|
|
441
509
|
`Failed: ${all.filter((r) => r.status === 'failed').length} runs; ${incoming.failed} incoming batches`,
|
|
510
|
+
`Blocked: ${all.filter((r) => r.blockReason === 'external-execution-unavailable').length} external runs (isolated execution unavailable)`,
|
|
442
511
|
`Delivery: ${delivery.failed} failed; ${delivery.unknown} unknown/in-flight (inspect before retrying)`,
|
|
443
512
|
...(unavailableSources.size ? [`Unavailable event sources: ${[...unavailableSources].join(', ')}`] : []),
|
|
444
513
|
'/stop stops active work only. /cancel clears pending work only.',
|
|
@@ -454,7 +523,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
454
523
|
await withStartLock(async () => {
|
|
455
524
|
count += await inbox.cancel()
|
|
456
525
|
for (const run of await runs.list()) {
|
|
457
|
-
if (run.status !== 'queued') continue
|
|
526
|
+
if (run.status !== 'queued' || run.backendSubmitted) continue
|
|
458
527
|
await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
|
|
459
528
|
count++
|
|
460
529
|
}
|
|
@@ -505,12 +574,22 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
505
574
|
return
|
|
506
575
|
}
|
|
507
576
|
|
|
577
|
+
if (config.channelBackendUrl && ['/new', '/ai', '/settings'].includes(text ?? '')) {
|
|
578
|
+
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
508
582
|
// Steering & session commands
|
|
583
|
+
if (text === '/stop' && config.channelBackendUrl) {
|
|
584
|
+
await ctx.reply('This channel uses an application backend. Stopping its active job is not supported here; check the application. /cancel removes only pending relay work.')
|
|
585
|
+
return
|
|
586
|
+
}
|
|
509
587
|
if (text === '/stop') {
|
|
510
|
-
const running = await runs.running()
|
|
511
|
-
if (running && running.pid) {
|
|
588
|
+
const running = await runs.running(false)
|
|
589
|
+
if ((running && running.pid) || background.size) {
|
|
512
590
|
try {
|
|
513
591
|
if (activeChild) terminateJob(activeChild)
|
|
592
|
+
for (const [id,child] of background) { await scheduler.cancel(id); terminateJob(child) }
|
|
514
593
|
} catch {}
|
|
515
594
|
if (activeTypingTimer) {
|
|
516
595
|
clearInterval(activeTypingTimer)
|
|
@@ -586,6 +665,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
586
665
|
const prompt = `[Attached image staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
|
|
587
666
|
collectItem({
|
|
588
667
|
text: prompt,
|
|
668
|
+
attachment: { path: staged.relativePath, type: staged.fileType },
|
|
589
669
|
messageId: ctx.message.message_id,
|
|
590
670
|
updateId: ctx.update.update_id,
|
|
591
671
|
chatId: ctx.chat.id,
|
|
@@ -614,6 +694,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
614
694
|
const prompt = `[Attached document staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
|
|
615
695
|
collectItem({
|
|
616
696
|
text: prompt,
|
|
697
|
+
attachment: { path: staged.relativePath, type: staged.fileType },
|
|
617
698
|
messageId: ctx.message.message_id,
|
|
618
699
|
updateId: ctx.update.update_id,
|
|
619
700
|
chatId: ctx.chat.id,
|
|
@@ -693,6 +774,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
693
774
|
const original = ctx.callbackQuery.message?.text || ''
|
|
694
775
|
const updated = `${escapeHtml(original)}\n\n<b>Decision:</b> ${isApproved ? 'Approved ✅' : 'Denied ❌'}`
|
|
695
776
|
await ctx.editMessageText(updated, { parse_mode: 'HTML' }).catch(() => {})
|
|
777
|
+
if (await tasks.decide(actionId)) { void drainSources(); return }
|
|
696
778
|
collectItem({
|
|
697
779
|
text: JSON.stringify({ event: 'approval_decision', actionId, decision, prompt: request?.prompt }),
|
|
698
780
|
messageId: ctx.callbackQuery.message?.message_id,
|
|
@@ -702,6 +784,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
702
784
|
})
|
|
703
785
|
console.info('Approval decision recorded', { actionId, decision: isApproved ? 'approved' : 'denied' })
|
|
704
786
|
}
|
|
787
|
+
} else if (config.channelBackendUrl && ['menu:new', 'menu:ai', 'menu:settings'].includes(data)) {
|
|
788
|
+
await ctx.answerCallbackQuery()
|
|
789
|
+
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
705
790
|
} else if (await aiMenu.handle(ctx)) {
|
|
706
791
|
return
|
|
707
792
|
} else if (data.startsWith('menu:')) {
|
|
@@ -728,10 +813,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
728
813
|
} else if (action === 'cancel') {
|
|
729
814
|
await ctx.answerCallbackQuery()
|
|
730
815
|
await ctx.reply(await cancelPending())
|
|
816
|
+
} else if (action === 'stop' && config.channelBackendUrl) {
|
|
817
|
+
await ctx.answerCallbackQuery()
|
|
818
|
+
await ctx.reply('This channel uses an application backend. Stopping its active job is not supported here; check the application.')
|
|
731
819
|
} else if (action === 'stop') {
|
|
732
|
-
const running = await runs.running()
|
|
733
|
-
if (running && running.pid) {
|
|
820
|
+
const running = await runs.running(false)
|
|
821
|
+
if ((running && running.pid) || background.size) {
|
|
734
822
|
if (activeChild) terminateJob(activeChild)
|
|
823
|
+
for (const [id,child] of background) { await scheduler.cancel(id); terminateJob(child) }
|
|
735
824
|
await ctx.answerCallbackQuery({ text: 'Run stopped' })
|
|
736
825
|
await ctx.reply(
|
|
737
826
|
'🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
|
|
@@ -755,6 +844,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
755
844
|
if (intakeTimer) clearTimeout(intakeTimer)
|
|
756
845
|
if (sourceTimer) clearInterval(sourceTimer)
|
|
757
846
|
if (activeChild) terminateJob(activeChild)
|
|
847
|
+
for (const child of background.values()) terminateJob(child)
|
|
758
848
|
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
759
849
|
if (sourceWork) await sourceWork.catch(() => {})
|
|
760
850
|
if (bot.isRunning()) await bot.stop()
|
|
@@ -762,9 +852,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
762
852
|
|
|
763
853
|
const start = async () => {
|
|
764
854
|
await initializeWorkspace(config.workspace)
|
|
855
|
+
await scheduler.recover(runs)
|
|
765
856
|
sourceTimer = setInterval(() => {
|
|
766
857
|
void drainSources().catch(error => console.error('Event-source drain failed', safeError(error)))
|
|
767
858
|
}, 1000)
|
|
859
|
+
const taskTimer = setInterval(() => { void drainTaskRequests().catch(console.error) }, 250)
|
|
768
860
|
const drainTimer = setInterval(() => {
|
|
769
861
|
void drainOutbox().catch((error) => console.error('Outbox drain failed', error.message))
|
|
770
862
|
}, 250)
|
|
@@ -773,12 +865,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
773
865
|
console.log(`ezenciel-agents listening with workspace ${config.workspace}`)
|
|
774
866
|
console.log(`Authority control state: ${config.controlDir}`)
|
|
775
867
|
console.log(`CLI executor: ${config.executorCli}`)
|
|
776
|
-
await aiMenu.refresh()
|
|
868
|
+
if (!config.channelBackendUrl) await aiMenu.refresh()
|
|
777
869
|
|
|
778
870
|
// Reconcile stale runs and start any queued run on boot
|
|
779
|
-
|
|
871
|
+
if (config.channelBackendUrl) {
|
|
872
|
+
for (const run of await runs.list()) if (run.status === 'running' && !run.pid) await runs.patch(run.id, { status: 'queued' })
|
|
873
|
+
}
|
|
874
|
+
const currentRunning = await runs.running(false)
|
|
780
875
|
if (!currentRunning) {
|
|
781
|
-
const pendingRun = await runs.nextQueued()
|
|
876
|
+
const pendingRun = await runs.nextQueued(false)
|
|
782
877
|
if (pendingRun) {
|
|
783
878
|
console.info('Processing queued run on startup:', pendingRun.id)
|
|
784
879
|
void startJob(pendingRun)
|
|
@@ -796,10 +891,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
796
891
|
})
|
|
797
892
|
} finally {
|
|
798
893
|
clearInterval(drainTimer)
|
|
894
|
+
clearInterval(taskTimer)
|
|
799
895
|
if (sourceTimer) clearInterval(sourceTimer)
|
|
800
896
|
}
|
|
801
897
|
}
|
|
802
|
-
return { bot, start, stop, drainOutbox, drainInbox, drainSources }
|
|
898
|
+
return { bot, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests }
|
|
803
899
|
}
|
|
804
900
|
|
|
805
901
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Self-reported exposure is discovery metadata, never an authority grant.
|
|
2
|
+
const fields = ['receivesExternalContent', 'sendsExternally', 'changesRecords', 'requiresReview'];
|
|
3
|
+
export function exposure(value) {
|
|
4
|
+
if (value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
5
|
+
Object.keys(value).some(key => !fields.includes(key)) ||
|
|
6
|
+
Object.values(value).some(item => typeof item !== 'boolean')))
|
|
7
|
+
throw new Error('Invalid plugin exposure declaration');
|
|
8
|
+
return Object.fromEntries(fields.map(key => [key, value?.[key] ?? true]));
|
|
9
|
+
}
|
|
10
|
+
export function commandExposure(manifest) {
|
|
11
|
+
return Object.fromEntries(Object.entries(manifest.commands).map(([name, command]) =>
|
|
12
|
+
[name, { declared: command.exposure !== undefined, ...exposure(command.exposure) }]));
|
|
13
|
+
}
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { exposure, commandExposure } from './exposure.mjs';
|
|
1
2
|
import * as fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -83,7 +84,7 @@ export function validate(m,d,files) {
|
|
|
83
84
|
if(JSON.stringify(Object.keys(m.commands).sort())!==JSON.stringify(Object.keys(d.commands).sort())) throw Error('Command bindings must match manifest');
|
|
84
85
|
for(const [alias,c] of Object.entries(m.commands)) {
|
|
85
86
|
id(alias); if(reserved.has(alias)) throw Error('Reserved alias');
|
|
86
|
-
keys(c,['executable','args']); strings(c.args);
|
|
87
|
+
keys(c,['executable','args','exposure']); strings(c.args); exposure(c.exposure);
|
|
87
88
|
if(!files.has(c.executable)) throw Error('Missing package executable');
|
|
88
89
|
const b=d.commands[alias];keys(b,['service','argv','suffix']);
|
|
89
90
|
if(!d.services[b.service] || !strings(b.argv).length) throw Error('Invalid command service'); strings(b.suffix||[]);
|
|
@@ -118,14 +119,22 @@ function dockerEnv() {
|
|
|
118
119
|
export function run(argv,{capture=false,container}={}) {
|
|
119
120
|
return new Promise((resolve,reject)=>{
|
|
120
121
|
const child=spawn('docker',argv,{env:dockerEnv(),stdio:capture?['ignore','pipe','pipe']:['inherit','inherit','inherit']});
|
|
121
|
-
let stdout='',stderr='',cancelled=false;
|
|
122
|
+
let stdout='',stderr='',cancelled=false,killTimer;
|
|
122
123
|
if(capture) {child.stdout.on('data',b=>stdout+=b);child.stderr.on('data',b=>stderr+=b);}
|
|
123
|
-
const cancel=signal=>{cancelled=true;child.kill(signal);};
|
|
124
|
+
const cancel=signal=>{cancelled=true;child.kill(signal);killTimer??=setTimeout(()=>child.kill('SIGKILL'),2000);};
|
|
124
125
|
const term=()=>cancel('SIGTERM'),int=()=>cancel('SIGINT');
|
|
125
126
|
process.on('SIGTERM',term);process.on('SIGINT',int);
|
|
126
|
-
child.once('error',reject);
|
|
127
|
+
child.once('error',error=>{clearTimeout(killTimer);process.off('SIGTERM',term);process.off('SIGINT',int);reject(error);});
|
|
127
128
|
child.once('close',async(code,signal)=>{process.off('SIGTERM',term);process.off('SIGINT',int);
|
|
128
|
-
|
|
129
|
+
clearTimeout(killTimer);
|
|
130
|
+
if(cancelled&&container) {
|
|
131
|
+
try {
|
|
132
|
+
const cleanup=await run(['container','rm','--force',container],{capture:true});
|
|
133
|
+
// Compose --rm may already have removed this exact command container.
|
|
134
|
+
if(cleanup.code!==0&&!cleanup.stderr.includes(`No such container: ${container}`))
|
|
135
|
+
return reject(Error(`Cancelled command container cleanup failed: ${cleanup.stderr||cleanup.stdout}`));
|
|
136
|
+
} catch(error) {return reject(error);}
|
|
137
|
+
}
|
|
129
138
|
resolve({code:cancelled?130:code??(signal?130:1),stdout,stderr});});
|
|
130
139
|
});
|
|
131
140
|
}
|
|
@@ -142,7 +151,8 @@ async function registry(home) {
|
|
|
142
151
|
for(const [alias,plugin] of Object.entries(r.commands)) if(!r.plugins[plugin]?.deployment?.commands?.[alias]) throw Error('Corrupt command registry');
|
|
143
152
|
return r;
|
|
144
153
|
}
|
|
145
|
-
export async function init(home,workspace,catalogFile,hostConfig) {
|
|
154
|
+
export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
|
|
155
|
+
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
146
156
|
if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
|
|
147
157
|
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);
|
|
148
158
|
if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
|
|
@@ -171,7 +181,7 @@ export async function init(home,workspace,catalogFile,hostConfig) {
|
|
|
171
181
|
}
|
|
172
182
|
});
|
|
173
183
|
const index=path.join(workspace,'TOOLS.md');
|
|
174
|
-
const prior=await fs.readFile(index,'utf8').catch(e=>{if(e.code==='ENOENT')return fs.readFile(new URL('../../templates/agent/TOOLS.md',import.meta.url),'utf8');throw e;});
|
|
184
|
+
const prior=await fs.readFile(index,'utf8').catch(e=>{if(e.code==='ENOENT')return fs.readFile(new URL(standalone?'../../templates/standalone-tools.md':'../../templates/agent/TOOLS.md',import.meta.url),'utf8');throw e;});
|
|
175
185
|
await fs.writeFile(index,prior+'\n## Registered plugins\n\nUse `'+path.join(home,'bin','ez')+'` for this agent only.\nDiscover reviewed packages with `ez plugins available`; inspect with `ez plugins inspect <id>`.\nOn an authorized installation request, run `ez plugins install <id>`, then `ez plugins start <id>`.\nRead the installed skill paths from `ez plugins list` before onboarding or provider operations.\nUse `ez tools list` for aliases and `ez <alias> --help` for native commands.\nInstallation does not grant send authority. The registry is the only plugin installation, command and lifecycle authority. Do not create standalone provider launchers or deployments.\n',{mode:0o600});
|
|
176
186
|
if(hostConfig && path.basename(hostConfig)==='host-executor.json') await (await import('../updates/binding.mjs')).bindUpdates(home,hostConfig);
|
|
177
187
|
return {ok:true,launcher:path.join(home,'bin','ez'),workspace};
|
|
@@ -214,22 +224,27 @@ export async function main(args) {
|
|
|
214
224
|
// Only the fixed launcher may supply the leading home binding. Never consume plugin arguments here.
|
|
215
225
|
let home;if(args[0]==='--home') {home=args[1];args=args.slice(2);}
|
|
216
226
|
if(args[0]==='enable-updates') {args.shift();const h=take('--home'),host=take('--host-config');if(args.length||!h||!host)throw Error('Supply --home and --host-config');return emit(await (await import('../updates/binding.mjs')).bindUpdates(h,host));}
|
|
217
|
-
if(args
|
|
227
|
+
if(!home && (args.length===0 || (args.length===1 && ['--help','-h'].includes(args[0])))) return emit({usage:'ezenciel-agents-tools init --standalone --home /absolute/tools --workspace /absolute/workspace',relay:'Omit --standalone and supply --host-config for a relay binding',discovery:'Use the returned launcher from any local executor; read workspace/TOOLS.md'});
|
|
228
|
+
if(args[0]==='init') {args.shift();const standalone=args.includes('--standalone');if(standalone)args.splice(args.indexOf('--standalone'),1);const options=[take('--home'),take('--workspace'),take('--catalog'),take('--host-config')];if(args.length)throw Error('Unknown init arguments');return emit(await init(...options,standalone));}
|
|
218
229
|
if(!home || !path.isAbsolute(home)) throw Error('Use the agent-bound launcher, or init --home /absolute/tools --workspace /absolute/mind --catalog /absolute/catalog.json');
|
|
219
230
|
home=await fs.realpath(home);
|
|
220
231
|
const config=await json(path.join(home,'config.json'));
|
|
221
232
|
if(config.schemaVersion!==1 || !path.isAbsolute(config.workspace)) throw Error('Invalid binding');
|
|
222
233
|
const [group,action,...rest]=args;
|
|
223
|
-
if(group==='status'){if(args.length!==1)throw Error('Use status without arguments');return emit(await (await import('../updates/status.mjs')).status(home));}
|
|
234
|
+
if(group==='status'){if(args.length!==1)throw Error('Use status without arguments');await registry(home);return emit(await (await import('../updates/status.mjs')).status(home));}
|
|
224
235
|
if(group==='updates')return emit(await (await import('../updates/control.mjs')).command(home,args.slice(1)));
|
|
225
|
-
if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list','<registered CLI> ...'],scope:home});
|
|
236
|
+
if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list|exposure','<registered CLI> ...'],scope:home});
|
|
226
237
|
if(group==='plugins'&&(!action||args.includes('--help'))) return emit({commands:['available','list','inspect <id>','install <id>','start <id>','stop <id>','status <id>','logs <id>','uninstall <id>','catalog-add <id> --source PATH --revision HASH','export <id> <artifact> --output PATH'],uninstall:'Stops and removes containers/network and unregisters aliases; retains all volumes and secrets. No data deletion flag.',scope:home});
|
|
227
238
|
if(group==='plugins'||group==='tools') {
|
|
228
239
|
args=rest;args=args.filter(a=>a!=='--json');
|
|
229
240
|
if(action==='available'&&group==='plugins') return emit(config.catalog);
|
|
230
241
|
const r=await registry(home);
|
|
231
242
|
if(action==='list') return emit(group==='tools'?r.commands:r.plugins);
|
|
232
|
-
if(group==='tools'
|
|
243
|
+
if(group==='tools' && action==='exposure') {
|
|
244
|
+
if(args.length) throw Error('Use tools exposure without arguments');
|
|
245
|
+
return emit(Object.fromEntries(Object.entries(r.plugins).map(([name, record]) => [name, commandExposure(record.manifest)])));
|
|
246
|
+
}
|
|
247
|
+
if(group==='tools') throw Error('Use tools list or tools exposure; installation registers CLI bindings');
|
|
233
248
|
const name=args.shift();id(name);
|
|
234
249
|
if(action==='inspect'||action==='install'||action==='catalog-add') {
|
|
235
250
|
const source=take('--source')||config.catalog[name]?.source,revision=take('--revision')||config.catalog[name]?.revision;
|
|
@@ -238,7 +253,7 @@ export async function main(args) {
|
|
|
238
253
|
const p=await snapshot(source);if(p.manifest.id!==name||p.revision!==revision)throw Error('Inspect and pin the exact catalog package first');
|
|
239
254
|
return locked(home,async()=>{const latest=await json(path.join(home,'config.json'));latest.catalog[name]={source:p.source,revision:p.revision};await atomic(path.join(home,'config.json'),latest);emit({ok:true,plugin:name,revision:p.revision,installed:false});});
|
|
240
255
|
}
|
|
241
|
-
if(action==='inspect') {const p=await snapshot(source);return emit({id:p.manifest.id,source:p.source,revision:p.revision,catalogRevision:config.catalog[name]?.revision??null,catalogMatches:config.catalog[name]?.source===p.source&&config.catalog[name]?.revision===p.revision,inspection:'Read-only; does not update the catalog pin. After review, pass --revision to install or use catalog-add --source --revision.',manifest:p.manifest,deployment:p.deployment});}
|
|
256
|
+
if(action==='inspect') {const p=await snapshot(source);return emit({id:p.manifest.id,source:p.source,revision:p.revision,catalogRevision:config.catalog[name]?.revision??null,catalogMatches:config.catalog[name]?.source===p.source&&config.catalog[name]?.revision===p.revision,inspection:'Read-only; does not update the catalog pin. After review, pass --revision to install or use catalog-add --source --revision.',manifest:p.manifest,exposure:commandExposure(p.manifest),deployment:p.deployment});}
|
|
242
257
|
return emit(await install(home,config,name,source,revision));
|
|
243
258
|
}
|
|
244
259
|
const record=r.plugins[name];if(!record)throw Error('Plugin not installed');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
|
|
5
|
+
type ProcessInfo = {parent: number; birth: string}
|
|
6
|
+
type Snapshot = Map<number, ProcessInfo>
|
|
7
|
+
|
|
8
|
+
export const matchingProcessIds = (original: Snapshot, current: Snapshot): number[] =>
|
|
9
|
+
[...original].filter(([pid, info]) => info.birth && current.get(pid)?.birth === info.birth).map(([pid]) => pid)
|
|
10
|
+
|
|
11
|
+
export const processSnapshot = async (): Promise<Snapshot> => {
|
|
12
|
+
if (process.platform === 'win32') return new Map()
|
|
13
|
+
if (process.platform !== 'linux') {
|
|
14
|
+
const {stdout} = await promisify(execFile)('/bin/ps', ['-axo', 'pid=,ppid=,lstart='], {timeout: 2000})
|
|
15
|
+
return new Map(stdout.trim().split('\n').flatMap(line => {
|
|
16
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/)
|
|
17
|
+
return match ? [[Number(match[1]), {parent:Number(match[2]),birth:match[3]}] as const] : []
|
|
18
|
+
}))
|
|
19
|
+
}
|
|
20
|
+
const scan = async (): Promise<Snapshot> => new Map(await Promise.all(
|
|
21
|
+
(await readdir('/proc')).filter(id => /^\d+$/.test(id)).map(async id => {
|
|
22
|
+
const stat = await readFile(`/proc/${id}/stat`, {encoding:'utf8',signal:AbortSignal.timeout(2000)}).catch(() => '')
|
|
23
|
+
const fields = stat.slice(stat.lastIndexOf(')')+2).split(' ')
|
|
24
|
+
return [Number(id), {parent:Number(fields[1]),birth:fields[19] || ''}] as const
|
|
25
|
+
}),
|
|
26
|
+
))
|
|
27
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
28
|
+
try {
|
|
29
|
+
return await Promise.race([scan(), new Promise<never>((_,reject) => {
|
|
30
|
+
timer = setTimeout(() => reject(new Error('Process inspection timed out')),2000)
|
|
31
|
+
})])
|
|
32
|
+
} finally { clearTimeout(timer) }
|
|
33
|
+
}
|