@jc_stack/ez-agents 0.1.0-beta.31 → 0.1.0-beta.32
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/AGENTS.md +1 -1
- package/CHANGELOG.md +26 -1
- package/README.md +36 -25
- package/docs/application-channel.md +29 -2
- package/docs/architecture/ai-selection.md +10 -1
- package/docs/architecture/authority-boundaries.md +64 -14
- package/docs/architecture/event-sources.md +12 -5
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/host-service.md +3 -0
- package/docs/plugin-contributions.md +2 -0
- package/docs/plugins.md +23 -0
- package/docs/responsive-channels.md +3 -0
- package/docs/selective-monitoring.md +55 -3
- package/docs/setup.md +2 -1
- package/package.json +1 -1
- package/src/application-channel.ts +40 -9
- package/src/application-origin.ts +4 -0
- package/src/approval.ts +19 -3
- package/src/codex-session.ts +2 -2
- package/src/desktop-bridge.ts +70 -5
- package/src/event-sources.ts +13 -6
- package/src/executor.ts +4 -2
- package/src/files.ts +12 -0
- package/src/host-executor-protocol.ts +2 -2
- package/src/host-executor.ts +20 -4
- package/src/index.ts +81 -34
- package/src/plugins/manager.mjs +52 -20
- package/src/runs.ts +19 -2
- package/src/task-attachments.ts +56 -0
- package/src/task-capability.ts +45 -0
- package/src/task-cli.ts +7 -4
- package/src/task-executor.ts +8 -4
- package/src/task-mcp.ts +25 -8
- package/src/tasks.ts +132 -26
- package/src/telegram-source.ts +34 -13
- package/src/updates/artifact.mjs +2 -2
- package/templates/agent-guidance.md +14 -0
- package/test/application-channel.test.ts +52 -1
- package/test/codex-session.test.ts +2 -1
- package/test/desktop-bridge.test.ts +98 -1
- package/test/event-sources.test.ts +68 -8
- package/test/executor.test.ts +2 -2
- package/test/host-executor.test.ts +32 -3
- package/test/intake-relay.test.ts +79 -2
- package/test/local-qa.test.mjs +7 -3
- package/test/plugin-manager.test.mjs +31 -0
- package/test/runs.test.ts +19 -0
- package/test/task-attachments.test.ts +77 -0
- package/test/task-capability.test.ts +48 -0
- package/test/task-native.test.ts +4 -1
- package/test/tasks.test.ts +53 -1
- package/test/telegram-source.test.ts +28 -1
|
@@ -7,7 +7,10 @@ import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
|
|
|
7
7
|
import { ownsRun } from './identity.js'
|
|
8
8
|
import { applicationId, validApplicationOrigin } from './application-origin.js'
|
|
9
9
|
import { isPreset, type AiPreset, type ModelChoice } from './ai.js'
|
|
10
|
+
import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
|
|
10
11
|
import { assertEffort } from './model-policy.js'
|
|
12
|
+
import { ApprovalStore } from './approval.js'
|
|
13
|
+
import { Tasks } from './tasks.js'
|
|
11
14
|
|
|
12
15
|
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
13
16
|
export const applicationScope = (bindingId: string, scope: string) => hash(JSON.stringify([bindingId, scope]))
|
|
@@ -68,7 +71,7 @@ export class ApplicationChannel {
|
|
|
68
71
|
private admissions = Promise.resolve()
|
|
69
72
|
readonly bindings: ApplicationBindings
|
|
70
73
|
constructor(private options: {
|
|
71
|
-
controlDir: string; initial: AiPreset
|
|
74
|
+
controlDir: string; workspace?: string; initial: AiPreset
|
|
72
75
|
wake: () => void
|
|
73
76
|
cancel: (id: string) => Promise<void>
|
|
74
77
|
aiControls?: {
|
|
@@ -192,8 +195,8 @@ export class ApplicationChannel {
|
|
|
192
195
|
if ('followTelegram' in rest) throw new Error('Invalid application request: choose one conversation option')
|
|
193
196
|
input = {...rest, followTelegram: followOwner}
|
|
194
197
|
}
|
|
195
|
-
const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; model?: unknown; effort?: unknown } }
|
|
196
|
-
if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || !value.text.trim() || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
|
|
198
|
+
const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; model?: unknown; effort?: unknown } }
|
|
199
|
+
if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || (!value.text.trim() && value.attachment === undefined) || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','attachment','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
|
|
197
200
|
if (value.expectedNativeSessionId !== undefined && (typeof value.expectedNativeSessionId !== 'string' || !/^[a-zA-Z0-9_-]{1,160}$/.test(value.expectedNativeSessionId))) throw new Error('Invalid application native session assertion')
|
|
198
201
|
if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
|
|
199
202
|
if (value.followTelegram !== undefined && typeof value.followTelegram !== 'boolean') throw new Error('Invalid application Telegram following')
|
|
@@ -205,7 +208,17 @@ export class ApplicationChannel {
|
|
|
205
208
|
assertEffort(candidate.effort, candidate.model, candidate.cli)
|
|
206
209
|
requestedPreset = candidate
|
|
207
210
|
}
|
|
208
|
-
|
|
211
|
+
let bytes: Buffer | undefined
|
|
212
|
+
if (value.attachment !== undefined) {
|
|
213
|
+
const attachment = value.attachment
|
|
214
|
+
if (!attachment || typeof attachment.name !== 'string' || !attachment.name.length || attachment.name.length > 255 ||
|
|
215
|
+
typeof attachment.data !== 'string' || attachment.data.length > Math.ceil(MAX_INCOMING_ATTACHMENT_BYTES / 3) * 4 ||
|
|
216
|
+
Object.keys(attachment).some(key => !['name', 'data'].includes(key))) throw new Error('Invalid application attachment')
|
|
217
|
+
bytes = Buffer.from(attachment.data, 'base64')
|
|
218
|
+
if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || bytes.toString('base64') !== attachment.data) throw new Error('Invalid application attachment encoding or size')
|
|
219
|
+
}
|
|
220
|
+
const attachmentHash = bytes ? hash(JSON.stringify([value.attachment!.name, bytes.toString('base64')])) : undefined
|
|
221
|
+
const application = { bindingId, requestId: value.requestId, scope: value.scope, ...(attachmentHash ? {inputText: value.text, attachmentHash} : {}), ...(value.followTelegram ? { followTelegram: true } : {}), ...(value.context === undefined ? {} : { context: value.context }) }
|
|
209
222
|
if (!validApplicationOrigin(application)) throw new Error('Invalid application context')
|
|
210
223
|
const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
|
|
211
224
|
if (!binding) throw new Error('Application authority revoked')
|
|
@@ -215,13 +228,18 @@ export class ApplicationChannel {
|
|
|
215
228
|
if (existing) {
|
|
216
229
|
await this.bindings.authorize(existing)
|
|
217
230
|
if (requestedPreset && (existing.execution?.preset.cli !== requestedPreset.cli || existing.execution?.preset.model !== requestedPreset.model || existing.execution?.preset.effort !== requestedPreset.effort)) throw new Error('Application request ID conflicts with prior AI selection')
|
|
218
|
-
if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || existing.texts[0] !== value.text) throw new Error('Application request ID conflicts with prior scope or text')
|
|
231
|
+
if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || (existing.application?.inputText ?? existing.texts[0]) !== value.text || existing.application?.attachmentHash !== attachmentHash) throw new Error('Application request ID conflicts with prior scope or text')
|
|
219
232
|
return existing // Retried context never replaces already admitted capabilities.
|
|
220
233
|
}
|
|
221
234
|
const control = new ControlStore(this.options.controlDir, 900000)
|
|
222
235
|
if (!sameOwner(binding.owner, (await control.status()).owner)) throw new Error('Application authority revoked')
|
|
236
|
+
if (bytes && !this.options.workspace) throw new Error('Invalid application attachment: workspace unavailable')
|
|
237
|
+
const staged = bytes ? await stageChatAttachment(this.options.workspace!, value.attachment!.name as string, bytes, value.text) : undefined
|
|
238
|
+
let run: RunRecord
|
|
239
|
+
try {
|
|
223
240
|
const execution = value.followTelegram ? await control.captureChoice(this.options.initial) : await control.captureApplicationChoice(this.options.initial, applicationScope(bindingId, value.scope), binding.shareTelegram === true && value.activateTelegram === true, requestedPreset, value.expectedNativeSessionId as string | undefined)
|
|
224
|
-
|
|
241
|
+
run = await this.runs.create({ id, ownerId: ownerId(binding.owner), ownerEpoch: ownerEpoch(binding.owner), texts: [staged?.text ?? value.text], execution, application }) }
|
|
242
|
+
catch (error) { if (staged) await unlink(staged.fullPath).catch(() => {}); throw error }
|
|
225
243
|
this.options.wake()
|
|
226
244
|
return run
|
|
227
245
|
})
|
|
@@ -234,11 +252,11 @@ export class ApplicationChannel {
|
|
|
234
252
|
await this.bindings.authorize(run)
|
|
235
253
|
const state = await new ControlStore(this.options.controlDir, 900000).status()
|
|
236
254
|
const session = state.activeSession?.sessionId === run.execution?.sessionId ? state.activeSession : state.sessions?.find(item => item.sessionId === run.execution?.sessionId)
|
|
237
|
-
return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
|
|
255
|
+
return { ...(session ? { sessionId: session.sessionId, nativeSessionId: session.nativeSessionId, cli: session.cli } : {}), ...(run.scheduled?.originRunId ? {originRunId:run.scheduled.originRunId} : {}), id: run.id, scope: (run.application ?? run.delivery)!.scope, status: run.status, messages: await this.runs.applicationMessages(run.id), approvals:await this.runs.applicationApprovals(run.id), ...(run.status === 'failed' ? { error: 'Agent execution failed; inspect the core run' } : {}) }
|
|
238
256
|
}
|
|
239
257
|
async deliver(run: RunRecord, item: OutboxItem): Promise<void> {
|
|
240
258
|
await this.bindings.authorize(run)
|
|
241
|
-
if (item.type && item.type
|
|
259
|
+
if (item.type && !['message','approval'].includes(item.type)) throw new Error('Application channel supports text messages and approvals only')
|
|
242
260
|
await this.runs.markOutboxSent(item.id)
|
|
243
261
|
}
|
|
244
262
|
private async route(request: IncomingMessage, response: ServerResponse) {
|
|
@@ -277,12 +295,25 @@ export class ApplicationChannel {
|
|
|
277
295
|
}
|
|
278
296
|
if (request.method === 'POST' && path === '/v1/runs') {
|
|
279
297
|
const chunks: Buffer[] = []; let size = 0
|
|
280
|
-
for await (const chunk of request) { size += chunk.length; if (size >
|
|
298
|
+
for await (const chunk of request) { size += chunk.length; if (size > Math.ceil(MAX_INCOMING_ATTACHMENT_BYTES / 3) * 4 + 256 * 1024) throw new Error('Application request too large'); chunks.push(chunk) }
|
|
281
299
|
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
282
300
|
if (applicationId(input?.requestId)) admissionId = `r_app_${hash(JSON.stringify([binding.bindingId, input.requestId]))}`
|
|
283
301
|
const run = await this.submit(binding.bindingId, input)
|
|
284
302
|
send(202, await this.snapshot(binding.bindingId, run.id)); return
|
|
285
303
|
}
|
|
304
|
+
const approvalMatch=path.match(/^\/v1\/approvals\/(task_[a-f0-9]{32})$/)
|
|
305
|
+
if(approvalMatch && request.method==='POST') {
|
|
306
|
+
const approval=await new ApprovalStore(this.options.controlDir).getDecision(approvalMatch[1])
|
|
307
|
+
const run=approval?.runId ? await this.runs.get(approval.runId) : null
|
|
308
|
+
if(!run || run.application?.bindingId!==binding.bindingId)throw new Error('Unknown application approval')
|
|
309
|
+
await this.bindings.authorize(run)
|
|
310
|
+
const chunks:Buffer[]=[];let size=0;for await(const chunk of request){size+=chunk.length;if(size>1024)throw new Error('Invalid application approval');chunks.push(chunk)}
|
|
311
|
+
const input=JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
312
|
+
if(!input || !['approved','denied'].includes(input.decision) || Object.keys(input).some(key=>key!=='decision'))throw new Error('Invalid application approval')
|
|
313
|
+
await new ApprovalStore(this.options.controlDir).recordOwnerDecision(approvalMatch[1],input.decision,binding.owner)
|
|
314
|
+
if(!await new Tasks(this.options.controlDir).decide(approvalMatch[1]))throw new Error('Unknown application approval')
|
|
315
|
+
send(200,{id:approvalMatch[1],decision:input.decision});return
|
|
316
|
+
}
|
|
286
317
|
const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
|
|
287
318
|
if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
|
|
288
319
|
await this.snapshot(binding.bindingId, match[1])
|
|
@@ -3,6 +3,8 @@ export type ApplicationOrigin = {
|
|
|
3
3
|
scope: string
|
|
4
4
|
requestId: string
|
|
5
5
|
followTelegram?: boolean
|
|
6
|
+
inputText?: string
|
|
7
|
+
attachmentHash?: string
|
|
6
8
|
context?: Record<string, unknown>
|
|
7
9
|
}
|
|
8
10
|
export const applicationId = (value: unknown): value is string =>
|
|
@@ -11,5 +13,7 @@ export const validApplicationOrigin = (value: unknown): value is ApplicationOrig
|
|
|
11
13
|
const origin = value as ApplicationOrigin | undefined
|
|
12
14
|
return !!origin && /^[a-f0-9-]{36}$/.test(origin.bindingId) && applicationId(origin.scope) && applicationId(origin.requestId) &&
|
|
13
15
|
(origin.followTelegram === undefined || typeof origin.followTelegram === 'boolean') &&
|
|
16
|
+
(origin.inputText === undefined || (typeof origin.inputText === 'string' && origin.inputText.length <= 16000)) &&
|
|
17
|
+
(origin.attachmentHash === undefined || /^[a-f0-9]{64}$/.test(origin.attachmentHash)) &&
|
|
14
18
|
(origin.context === undefined || (!!origin.context && typeof origin.context === 'object' && !Array.isArray(origin.context) && JSON.stringify(origin.context).length <= 48000))
|
|
15
19
|
}
|
package/src/approval.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { assertId } from './identity.js'
|
|
4
|
+
import { ownerEpoch, ownerId, type Owner } from './control-state.js'
|
|
4
5
|
|
|
5
6
|
export type ApprovalDecision = 'pending' | 'approved' | 'denied'
|
|
6
7
|
|
|
7
8
|
export type ApprovalRecord = {
|
|
8
|
-
version: 1
|
|
9
|
+
version: 1 | 2
|
|
9
10
|
actionId: string
|
|
10
11
|
runId?: string
|
|
11
12
|
prompt: string
|
|
@@ -14,19 +15,22 @@ export type ApprovalRecord = {
|
|
|
14
15
|
decidedAt?: string
|
|
15
16
|
decidedBy?: number
|
|
16
17
|
decisionUpdateId?: number
|
|
18
|
+
decidedOwnerId?: string
|
|
19
|
+
decidedOwnerEpoch?: string
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
const isApprovalRecord = (value: unknown): value is ApprovalRecord => {
|
|
20
23
|
if (!value || typeof value !== 'object') return false
|
|
21
24
|
const candidate = value as Partial<ApprovalRecord>
|
|
22
25
|
return (
|
|
23
|
-
candidate.version
|
|
26
|
+
[1,2].includes(candidate.version ?? 0) &&
|
|
24
27
|
typeof candidate.actionId === 'string' &&
|
|
25
28
|
/^[a-zA-Z0-9_-]{1,40}$/.test(candidate.actionId) &&
|
|
26
29
|
typeof candidate.prompt === 'string' &&
|
|
27
30
|
['pending', 'approved', 'denied'].includes(candidate.decision ?? '') &&
|
|
28
31
|
typeof candidate.createdAt === 'string' &&
|
|
29
|
-
Number.isFinite(Date.parse(candidate.createdAt))
|
|
32
|
+
Number.isFinite(Date.parse(candidate.createdAt)) &&
|
|
33
|
+
(candidate.version === 1 || typeof candidate.decidedOwnerId === 'string' && typeof candidate.decidedOwnerEpoch === 'string')
|
|
30
34
|
)
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -105,6 +109,18 @@ export class ApprovalStore {
|
|
|
105
109
|
return record
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
async recordOwnerDecision(actionId: string, decision: 'approved' | 'denied', owner: Owner): Promise<ApprovalRecord> {
|
|
113
|
+
await this.ensure()
|
|
114
|
+
const existing = await this.getDecision(actionId)
|
|
115
|
+
if (!existing) throw new Error('Unknown approval request')
|
|
116
|
+
if (existing.decision !== 'pending') throw new Error('Approval already decided')
|
|
117
|
+
if (Date.now() - Date.parse(existing.createdAt) > 900_000) throw new Error('Approval expired')
|
|
118
|
+
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)
|
|
121
|
+
return record
|
|
122
|
+
}
|
|
123
|
+
|
|
108
124
|
async getDecision(actionId: string): Promise<ApprovalRecord | null> {
|
|
109
125
|
try {
|
|
110
126
|
const parsed: unknown = JSON.parse(await readFile(this.filePath(actionId), 'utf8'))
|
package/src/codex-session.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { terminateJob } from './executor.js'
|
|
7
7
|
|
|
8
|
-
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;model?:string;effort?:string;prompt:string;codexSandbox?:'external'}
|
|
8
|
+
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external'}
|
|
9
9
|
type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
|
|
10
10
|
|
|
11
11
|
// Keep Codex's native session alive. Codex itself starts goal continuation turns;
|
|
@@ -72,7 +72,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
72
72
|
send({method:'initialized',params:{}})
|
|
73
73
|
const result=await request('thread/start',{
|
|
74
74
|
cwd:options.workspace,approvalPolicy:'never',sandbox:options.codexSandbox === 'external' ? 'danger-full-access' : 'workspace-write',model:options.model,
|
|
75
|
-
config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[])],
|
|
75
|
+
config:{project_root_markers:['AGENTS.md','.git'],'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[]),...(options.additionalWorkspaces ?? [])],
|
|
76
76
|
'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})},
|
|
77
77
|
})
|
|
78
78
|
threadId=result.thread?.id
|
package/src/desktop-bridge.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { executionDefaults } from './model-policy.js'
|
|
|
3
3
|
import { access, constants } from 'node:fs/promises'
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto'
|
|
5
5
|
import { createConnection, type Socket } from 'node:net'
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
6
7
|
import { homedir } from 'node:os'
|
|
7
8
|
import { delimiter, join } from 'node:path'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
@@ -29,6 +30,7 @@ export type DesktopClient = {
|
|
|
29
30
|
request: (method: string, params?: unknown) => Promise<Record<string, unknown>>
|
|
30
31
|
notify: (method: string, params?: unknown) => void
|
|
31
32
|
wait: (match: (message: Record<string, unknown>) => boolean, timeoutMs: number) => Promise<Record<string, unknown>>
|
|
33
|
+
setServerRequestContext: (threadId?: string, turnId?: string) => void
|
|
32
34
|
close: () => void
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -57,6 +59,8 @@ export const desktopCodexPath = async (home = homedir(), envPath = process.env.P
|
|
|
57
59
|
const writableRoots = (options: DesktopTurnOptions): string[] =>
|
|
58
60
|
[options.controlDir, options.toolsHome].filter((value): value is string => Boolean(value))
|
|
59
61
|
|
|
62
|
+
const approvalPolicy = (scheduledDesktop: boolean): 'on-request' | 'never' => scheduledDesktop ? 'on-request' : 'never'
|
|
63
|
+
|
|
60
64
|
const sendFrame = (socket: Socket, text: string) => {
|
|
61
65
|
const payload = Buffer.from(text)
|
|
62
66
|
const mask = randomBytes(4)
|
|
@@ -71,6 +75,29 @@ const sendFrame = (socket: Socket, text: string) => {
|
|
|
71
75
|
socket.write(Buffer.concat([header, mask, masked]))
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
export const desktopServerRequestResult = (
|
|
79
|
+
message: Record<string, unknown>,
|
|
80
|
+
context?: { threadId: string; turnId?: string },
|
|
81
|
+
): Record<string, unknown> => {
|
|
82
|
+
if (message.method === 'mcpServer/elicitation/request') {
|
|
83
|
+
const params = message.params as { threadId?: unknown; turnId?: unknown; _meta?: Record<string, unknown> } | undefined
|
|
84
|
+
const meta = params?._meta
|
|
85
|
+
const toolParams = meta?.tool_params as Record<string, unknown> | undefined
|
|
86
|
+
const origin = toolParams?.origin
|
|
87
|
+
const browserOrigin = meta?.connector_id === 'browser-use' && meta.codex_approval_kind === 'mcp_tool_call' &&
|
|
88
|
+
meta.codex_request_type === 'approval_request' && meta.tool_name === 'access_browser_origin' &&
|
|
89
|
+
typeof origin === 'string' && /^https?:\/\//.test(origin) && meta.origin === origin
|
|
90
|
+
const chromeApp = meta?.connector_id === 'computer-use' && meta.codex_approval_kind === 'mcp_tool_call' &&
|
|
91
|
+
meta.codex_request_type !== 'approval_request' && meta.tool_name !== 'start_audio_recording' &&
|
|
92
|
+
toolParams?.app === 'com.google.Chrome'
|
|
93
|
+
if (context && params?.threadId === context.threadId && typeof params.turnId === 'string' &&
|
|
94
|
+
(!context.turnId || params.turnId === context.turnId) && (browserOrigin || chromeApp))
|
|
95
|
+
return { action: 'accept', content: null, _meta: { persist: 'session' } }
|
|
96
|
+
return { action: 'decline', content: null, _meta: null }
|
|
97
|
+
}
|
|
98
|
+
return { decision: 'decline' }
|
|
99
|
+
}
|
|
100
|
+
|
|
74
101
|
export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopClient => {
|
|
75
102
|
let buffer = Buffer.concat(pending)
|
|
76
103
|
let nextId = 1
|
|
@@ -79,6 +106,7 @@ export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopCli
|
|
|
79
106
|
const failedWaits = new Set<() => void>()
|
|
80
107
|
const notifications: Record<string, unknown>[] = []
|
|
81
108
|
const watchers: Array<(message: Record<string, unknown>) => void> = []
|
|
109
|
+
let serverRequestContext: { threadId: string; turnId?: string } | undefined
|
|
82
110
|
const deliver = (message: Record<string, unknown>) => {
|
|
83
111
|
const id = message.id
|
|
84
112
|
if (typeof id === 'number' && replies.has(id) && (message.result !== undefined || message.error)) {
|
|
@@ -89,8 +117,13 @@ export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopCli
|
|
|
89
117
|
else reply.resolve(message.result as Record<string, unknown>)
|
|
90
118
|
return
|
|
91
119
|
}
|
|
92
|
-
if (typeof id === 'number' && typeof message.method === 'string') {
|
|
93
|
-
|
|
120
|
+
if ((typeof id === 'number' || typeof id === 'string') && typeof message.method === 'string') {
|
|
121
|
+
const result = desktopServerRequestResult(message, serverRequestContext)
|
|
122
|
+
if (result.action === 'accept' && serverRequestContext && !serverRequestContext.turnId) {
|
|
123
|
+
const turnId = (message.params as { turnId?: unknown } | undefined)?.turnId
|
|
124
|
+
if (typeof turnId === 'string') serverRequestContext.turnId = turnId
|
|
125
|
+
}
|
|
126
|
+
sendFrame(socket, JSON.stringify({ id, result }))
|
|
94
127
|
return
|
|
95
128
|
}
|
|
96
129
|
notifications.push(message)
|
|
@@ -168,6 +201,9 @@ export const attachClient = (socket: Socket, pending: Buffer[] = []): DesktopCli
|
|
|
168
201
|
failedWaits.add(fail)
|
|
169
202
|
watchers.push(watcher)
|
|
170
203
|
}),
|
|
204
|
+
setServerRequestContext: (threadId, turnId) => {
|
|
205
|
+
serverRequestContext = threadId ? { threadId, turnId } : undefined
|
|
206
|
+
},
|
|
171
207
|
close: () => socket.destroy(),
|
|
172
208
|
}
|
|
173
209
|
}
|
|
@@ -200,6 +236,32 @@ export const connectDesktop = (socketPath = desktopControlSocket()): Promise<Des
|
|
|
200
236
|
socket.on('data', onData)
|
|
201
237
|
})
|
|
202
238
|
|
|
239
|
+
const startDesktopDaemon = async (
|
|
240
|
+
home = homedir(),
|
|
241
|
+
envPath = process.env.PATH || '',
|
|
242
|
+
run: typeof execFile = execFile,
|
|
243
|
+
): Promise<void> => {
|
|
244
|
+
const codex = await desktopCodexPath(home, envPath)
|
|
245
|
+
if (!codex) throw new Error(DESKTOP_UNAVAILABLE)
|
|
246
|
+
await new Promise<void>((resolve, reject) => run(codex, ['app-server', 'daemon', 'start'], {
|
|
247
|
+
env: { HOME: home, PATH: envPath }, timeout: 15_000, maxBuffer: 64 * 1024,
|
|
248
|
+
}, (error) => error ? reject(new Error(DESKTOP_UNAVAILABLE)) : resolve()))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const connectManagedDesktop = async (
|
|
252
|
+
connect: typeof connectDesktop = connectDesktop,
|
|
253
|
+
start: typeof startDesktopDaemon = startDesktopDaemon,
|
|
254
|
+
): Promise<DesktopClient> => {
|
|
255
|
+
try { return await connect() }
|
|
256
|
+
catch {
|
|
257
|
+
// The native Codex daemon owns its lifecycle and is idempotent. New desktop
|
|
258
|
+
// builds do not necessarily create the control socket merely by opening the
|
|
259
|
+
// app, so ask Codex to restore its own bridge before failing closed.
|
|
260
|
+
await start()
|
|
261
|
+
return connect()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
203
265
|
export const runDesktopTurn = async (
|
|
204
266
|
options: DesktopTurnOptions,
|
|
205
267
|
io: { connect?: typeof connectDesktop; emit?: (line: string) => void; signal?: AbortSignal } = {},
|
|
@@ -208,7 +270,8 @@ export const runDesktopTurn = async (
|
|
|
208
270
|
const emit = io.emit ?? ((line: string) => process.stdout.write(`${line}\n`))
|
|
209
271
|
let client: DesktopClient | undefined
|
|
210
272
|
try {
|
|
211
|
-
|
|
273
|
+
const scheduledDesktop = options.runId.startsWith('r_schedule_')
|
|
274
|
+
client = await (io.connect ?? connectManagedDesktop)()
|
|
212
275
|
await client.request('initialize', { clientInfo: { name: 'ezenciel-agents', title: 'ez', version: '1' } })
|
|
213
276
|
client.notify('initialized', {})
|
|
214
277
|
const roots = writableRoots(options)
|
|
@@ -227,7 +290,7 @@ export const runDesktopTurn = async (
|
|
|
227
290
|
const started = await client.request('thread/start', {
|
|
228
291
|
cwd: options.workspace,
|
|
229
292
|
config,
|
|
230
|
-
approvalPolicy:
|
|
293
|
+
approvalPolicy: approvalPolicy(scheduledDesktop),
|
|
231
294
|
sandbox: 'workspace-write',
|
|
232
295
|
model: options.model,
|
|
233
296
|
serviceName: 'ezenciel-agents',
|
|
@@ -238,17 +301,19 @@ export const runDesktopTurn = async (
|
|
|
238
301
|
}
|
|
239
302
|
if (!nativeThread(threadId)) throw new Error(DESKTOP_UNAVAILABLE)
|
|
240
303
|
emit(JSON.stringify({ type: 'thread.started', thread_id: threadId }))
|
|
304
|
+
client.setServerRequestContext(scheduledDesktop ? threadId! : undefined)
|
|
241
305
|
const turn = await client.request('turn/start', {
|
|
242
306
|
threadId,
|
|
243
307
|
input: [{ type: 'text', text: options.prompt }],
|
|
244
308
|
model: options.model,
|
|
245
309
|
effort: options.effort,
|
|
246
310
|
cwd: options.workspace,
|
|
247
|
-
approvalPolicy:
|
|
311
|
+
approvalPolicy: approvalPolicy(scheduledDesktop),
|
|
248
312
|
sandboxPolicy: { type: 'workspaceWrite', writableRoots: roots, networkAccess: Boolean(options.toolsHome) },
|
|
249
313
|
})
|
|
250
314
|
const turnId = (turn.turn as { id?: string } | undefined)?.id
|
|
251
315
|
if (!turnId) throw new Error(DESKTOP_UNAVAILABLE)
|
|
316
|
+
if (scheduledDesktop) client.setServerRequestContext(threadId!, turnId)
|
|
252
317
|
const interrupt = () => { void client?.request('turn/interrupt', { threadId, turnId }).catch(() => {}) }
|
|
253
318
|
io.signal?.addEventListener('abort', interrupt, { once: true })
|
|
254
319
|
if (io.signal?.aborted) interrupt()
|
package/src/event-sources.ts
CHANGED
|
@@ -2,21 +2,22 @@ import { mkdir, open, readFile, rename, unlink, lstat } from 'node:fs/promises'
|
|
|
2
2
|
import { join, isAbsolute } from 'node:path'
|
|
3
3
|
import { randomUUID, createHash } from 'node:crypto'
|
|
4
4
|
import { request } from 'node:http'
|
|
5
|
-
import type
|
|
5
|
+
import { sameOwner, validOwner, type Owner } from './control-state.js'
|
|
6
6
|
|
|
7
|
-
export type ExternalOrigin = { sourceId: string; bindingId: string; eventIds: string[] }
|
|
7
|
+
export type ExternalOrigin = { sourceId: string; bindingId: string; conversationId?: string; eventIds: string[] }
|
|
8
8
|
export type SourceEvent = { id: string; conversationId: string; receivedAt: number; text: string }
|
|
9
9
|
export type EventSource = { id: string; bindingId: string; socketPath: string; initialCursor: number; owner: Owner }
|
|
10
10
|
const identifier = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(s)
|
|
11
11
|
export const validOrigin = (o: unknown): o is ExternalOrigin => {
|
|
12
12
|
const v = o as ExternalOrigin | undefined
|
|
13
|
-
return !!v && identifier(v.sourceId) && identifier(v.bindingId) &&
|
|
13
|
+
return !!v && identifier(v.sourceId) && identifier(v.bindingId) &&
|
|
14
|
+
(v.conversationId === undefined || typeof v.conversationId === 'string' && v.conversationId.length > 0 && v.conversationId.length <= 200) &&
|
|
15
|
+
Array.isArray(v.eventIds) && v.eventIds.length > 0 && v.eventIds.length <= 10 && v.eventIds.every(identifier)
|
|
14
16
|
}
|
|
15
17
|
const cursorOK = (v: unknown): v is number => Number.isSafeInteger(v) && Number(v) >= 0
|
|
16
18
|
export const validEvents = (v: unknown): v is SourceEvent[] => Array.isArray(v) && v.length <= 10 && v.every(e =>
|
|
17
19
|
identifier(e?.id) && typeof e.conversationId === 'string' && e.conversationId.length > 0 && e.conversationId.length <= 200 &&
|
|
18
20
|
Number.isFinite(e.receivedAt) && typeof e.text === 'string' && e.text.length <= 16000) && new Set(v.map(e => e.id)).size === v.length
|
|
19
|
-
const sameOwner = (a: Owner, b: Owner) => a.telegramUserId === b.telegramUserId && a.telegramChatId === b.telegramChatId
|
|
20
21
|
async function read<T>(path: string, fallback: T): Promise<T> {
|
|
21
22
|
try { return JSON.parse(await readFile(path, 'utf8')) as T } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return fallback; throw new Error('Unreadable event-source state') }
|
|
22
23
|
}
|
|
@@ -49,8 +50,7 @@ export class EventSources {
|
|
|
49
50
|
const value = await read<{ version: number; sources: EventSource[] }>(this.registry, { version: 1, sources: [] })
|
|
50
51
|
if (value.version !== 1 || !Array.isArray(value.sources) || value.sources.some(s => !identifier(s.id) || !identifier(s.bindingId) ||
|
|
51
52
|
typeof s.socketPath !== 'string' || !isAbsolute(s.socketPath) || !cursorOK(s.initialCursor) ||
|
|
52
|
-
!
|
|
53
|
-
(s.owner.kind === 'group' ? s.owner.telegramChatId! >= 0 : s.owner.kind !== undefined || s.owner.telegramChatId! <= 0)) ||
|
|
53
|
+
!validOwner(s.owner)) ||
|
|
54
54
|
new Set(value.sources.map(s => s.id)).size !== value.sources.length) throw new Error('Invalid event-source registry')
|
|
55
55
|
return value.sources
|
|
56
56
|
}
|
|
@@ -106,6 +106,13 @@ export class EventSources {
|
|
|
106
106
|
if (!validEvents(result?.events) || result.events.some((e: SourceEvent) => !origin.eventIds.includes(e.id))) throw new Error('Invalid event recheck')
|
|
107
107
|
return result.events
|
|
108
108
|
}
|
|
109
|
+
async release(origin: ExternalOrigin, owner: Owner): Promise<void> {
|
|
110
|
+
if (!validOrigin(origin)) return
|
|
111
|
+
const source = (await this.available(owner)).find(s => s.id === origin.sourceId && s.bindingId === origin.bindingId)
|
|
112
|
+
if (!source) return
|
|
113
|
+
const head = await sourceCall(source.socketPath, 'events-head')
|
|
114
|
+
if (head.releaseEvents === true) await sourceCall(source.socketPath, 'events-release', {ids:origin.eventIds})
|
|
115
|
+
}
|
|
109
116
|
}
|
|
110
117
|
export const eventRunId = (source: EventSource, events: SourceEvent[]) => 'event_' + createHash('sha256')
|
|
111
118
|
.update(JSON.stringify([source.bindingId, events.map(e => e.id)])).digest('hex')
|
package/src/executor.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type ExecutorOptions = {
|
|
|
21
21
|
binDir: string
|
|
22
22
|
toolsHome?: string
|
|
23
23
|
sharedWorkspace?: string
|
|
24
|
+
additionalWorkspaces?: string[]
|
|
24
25
|
cli?: string
|
|
25
26
|
sessionId?: string
|
|
26
27
|
isResume?: boolean
|
|
@@ -75,7 +76,7 @@ export type CliAdapter = {
|
|
|
75
76
|
command: string
|
|
76
77
|
description: string
|
|
77
78
|
buildArgs: (
|
|
78
|
-
options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'codexAutoCompactTokens' | 'codexSandbox'> & { controlDir?: string },
|
|
79
|
+
options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume' | 'model' | 'effort' | 'toolsHome' | 'sharedWorkspace' | 'additionalWorkspaces' | 'codexAutoCompactTokens' | 'codexSandbox'> & { controlDir?: string },
|
|
79
80
|
promptFile: string,
|
|
80
81
|
promptText: string,
|
|
81
82
|
) => string[]
|
|
@@ -94,6 +95,7 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
|
|
|
94
95
|
}
|
|
95
96
|
if (opts.controlDir) args.push('--add-dir', opts.controlDir)
|
|
96
97
|
if (opts.sharedWorkspace) args.push('--add-dir', opts.sharedWorkspace)
|
|
98
|
+
for (const workspace of opts.additionalWorkspaces ?? []) args.push('--add-dir', workspace)
|
|
97
99
|
if (opts.toolsHome) args.push('--add-dir', opts.toolsHome, '-c', 'sandbox_workspace_write.network_access=true')
|
|
98
100
|
if (opts.model) args.push('--model', opts.model)
|
|
99
101
|
if (opts.effort) args.push('-c', `model_reasoning_effort=${JSON.stringify(opts.effort)}`)
|
|
@@ -248,7 +250,7 @@ export const startExecutorJob = async (
|
|
|
248
250
|
const nativeSession = !host && key === 'codex' && options.runId.startsWith('r_schedule_')
|
|
249
251
|
// Chat-mode experiment: only direct chat input at the engine boundary.
|
|
250
252
|
const applicationReminder = !host && (run?.application || run?.delivery)
|
|
251
|
-
? '\n\n[Application channel] This is an owner-authorized application conversation. Send text replies using ezenciel-agents-message; stdout alone is not delivered.
|
|
253
|
+
? '\n\n[Application channel] This is an owner-authorized application conversation. Send text replies using ezenciel-agents-message; stdout alone is not delivered. Outgoing file delivery, reactions and approval controls are unsupported here. Domain tools can retrieve private context from ezenciel-agents-schedule context under run.application.context; do not expose credentials from that data. The application scope is ' + JSON.stringify((run.application ?? run.delivery)!.scope) + '.'
|
|
252
254
|
: ''
|
|
253
255
|
const chatReminder = !host && !run?.taskId && run?.messageId !== undefined
|
|
254
256
|
? '\n\n[Chat context] You are replying in chat. Send replies with ezenciel-agents-message --text "..."; your final answer alone is not delivered. Before lengthy tool or repository work, briefly acknowledge through that CLI. Keep chat responsive: use ezenciel-agents-schedule for long-running work and native subagents for useful independent parts. Decide when to delegate and what to send.'
|
package/src/files.ts
CHANGED
|
@@ -73,3 +73,15 @@ export const workspaceFile = async (workspace: string, file: string, regular = t
|
|
|
73
73
|
if (regular && !(await stat(target)).isFile()) throw new Error('Expected a regular file')
|
|
74
74
|
return target
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
// Canonical native-engine attachment metadata for every inbound channel.
|
|
78
|
+
export const MAX_INCOMING_ATTACHMENT_BYTES = 10 * 1024 * 1024
|
|
79
|
+
export const stageChatAttachment = async (workspace: string, name: string, bytes: Buffer, comment: string) => {
|
|
80
|
+
const type = detectFileType(bytes)
|
|
81
|
+
if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
|
|
82
|
+
(type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
|
|
83
|
+
const staged = await stageIncomingFile(workspace, name, bytes)
|
|
84
|
+
const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
|
|
85
|
+
return { text: `[Attached ${kind} staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
|
|
86
|
+
attachment: {path: staged.relativePath, type: staged.fileType}, fullPath: staged.fullPath }
|
|
87
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
// Telegram batches, registered plugin events and locally created runs cross this transport.
|
|
2
|
-
export const isHostRunId = (id: string): boolean => /^(?:r_[a-zA-Z0-9_]+|tg_\d
|
|
1
|
+
// Telegram batches (including scheduler recovery), registered plugin events and locally created runs cross this transport.
|
|
2
|
+
export const isHostRunId = (id: string): boolean => /^(?:r_[a-zA-Z0-9_]+|tg_(?:\d+|replay_\d+_\d{8})|event_[a-f0-9]{64})$/.test(id)
|
package/src/host-executor.ts
CHANGED
|
@@ -13,11 +13,14 @@ import type { ChildProcess } from 'node:child_process'
|
|
|
13
13
|
import { taskWorkspace } from './task-workspace.js'
|
|
14
14
|
import { packageVersion } from './version.js'
|
|
15
15
|
import { installedPluginVersions } from './software-status.js'
|
|
16
|
+
import { processSnapshot } from './process-tree.js'
|
|
16
17
|
|
|
17
18
|
export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
|
|
18
|
-
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
|
|
19
|
+
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
|
|
19
20
|
export type HostInstallation = { cli: string; agents: HostBinding[] }
|
|
20
21
|
|
|
22
|
+
const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
|
|
23
|
+
|
|
21
24
|
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
|
|
22
25
|
resolveExecutor(installation.cli)
|
|
23
26
|
if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
|
|
@@ -27,12 +30,17 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
27
30
|
const tasks = new Set<Promise<void>>()
|
|
28
31
|
const locks: string[] = []
|
|
29
32
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
33
|
+
const additionalWorkspaces = new Map<HostBinding, string[]>()
|
|
30
34
|
const catalog = (agent: HostBinding) => readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
|
|
31
35
|
try {
|
|
32
36
|
for (const agent of installation.agents) {
|
|
33
37
|
if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
|
|
34
38
|
if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
|
|
35
39
|
if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
|
|
40
|
+
if (agent.additionalWorkspaces) {
|
|
41
|
+
if (!Array.isArray(agent.additionalWorkspaces) || agent.additionalWorkspaces.some(workspace=>typeof workspace!=='string' || !path.isAbsolute(workspace))) throw new Error('Additional workspaces require absolute paths')
|
|
42
|
+
additionalWorkspaces.set(agent,await Promise.all(agent.additionalWorkspaces.map(workspace=>realpath(workspace))))
|
|
43
|
+
}
|
|
36
44
|
if (agent.toolsHome) {
|
|
37
45
|
if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
|
|
38
46
|
const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
|
|
@@ -41,9 +49,17 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
41
49
|
const directory = path.join(agent.controlDir,'host-executor')
|
|
42
50
|
await mkdir(directory,{recursive:true,mode:0o700})
|
|
43
51
|
const lock=path.join(directory,'worker.lock')
|
|
44
|
-
try {
|
|
52
|
+
try {
|
|
53
|
+
const prior=JSON.parse(await readFile(lock,'utf8'))
|
|
54
|
+
const currentStart=await processStart(prior.pid)
|
|
55
|
+
if (!prior.started || !currentStart || prior.started===currentStart) {
|
|
56
|
+
try { process.kill(prior.pid,0); throw new Error('Host executor already running') }
|
|
57
|
+
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
58
|
+
}
|
|
59
|
+
await rm(lock)
|
|
60
|
+
}
|
|
45
61
|
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
46
|
-
await writeFile(lock,JSON.stringify({pid:process.pid}),{mode:0o600,flag:'wx'})
|
|
62
|
+
await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
|
|
47
63
|
locks.push(lock)
|
|
48
64
|
await installAgentGuidance(agent.workspace)
|
|
49
65
|
await writeFile(path.join(directory,'models.json'),JSON.stringify(await catalog(agent)),{mode:0o600})
|
|
@@ -114,7 +130,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
114
130
|
const cli = opts.cli || installation.cli
|
|
115
131
|
resolveExecutor(cli)
|
|
116
132
|
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,model:opts.model,effort:opts.effort},await catalog(agent))
|
|
117
|
-
const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,cli,
|
|
133
|
+
const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
118
134
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
119
135
|
sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort,codexAutoCompactTokens:opts.codexAutoCompactTokens}
|
|
120
136
|
job=await launch(request.texts,options)
|