@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.48
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 +2 -0
- package/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +1 -1
- package/Dockerfile +18 -10
- package/THIRD_PARTY_NOTICES.md +6 -3
- package/bin/ez +7 -0
- package/docker/broker-readiness.mjs +54 -0
- package/docker/broker-update-smoke.mjs +86 -0
- package/docker/engine-pnpm-workspace.yaml +4 -0
- package/docker/run.ts +1 -1
- package/docker/smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +26 -5
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/development-and-testing.md +1 -1
- package/docs/docker-runtime.md +39 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/plugin-guide.md +1 -1
- package/docs/plugins.md +1 -1
- package/docs/releasing.md +5 -1
- package/docs/scheduling.md +117 -9
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +38 -1
- package/docs/work-preflight.md +9 -0
- package/package.json +4 -3
- package/pnpm-workspace.yaml +7 -0
- package/scripts/smoke-scheduler.ts +4 -1
- package/src/ai.ts +41 -4
- package/src/application-channel.ts +43 -3
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/client-defaults.ts +3 -2
- package/src/config.ts +9 -0
- package/src/control-state.ts +3 -1
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +38 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +72 -1
- package/src/files.ts +47 -3
- package/src/host-executor.ts +40 -26
- package/src/index.ts +42 -17
- package/src/install-tools.mjs +18 -9
- package/src/menu.ts +4 -2
- package/src/model-policy.ts +4 -0
- package/src/plugin-broker.mjs +47 -4
- package/src/plugins/manager.mjs +11 -4
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +16 -2
- package/src/schedule-cli.ts +80 -10
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +102 -16
- package/src/scripts.ts +167 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +11 -8
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +53 -11
- package/src/updates/supervisor.mjs +23 -13
- package/test/ai.test.ts +85 -5
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/channel-owner.test.ts +2 -2
- package/test/config.test.ts +8 -0
- package/test/desktop-bridge.test.ts +2 -1
- package/test/executor.test.ts +37 -0
- package/test/failure.test.ts +4 -4
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +49 -6
- package/test/install-json.test.mjs +21 -0
- package/test/install-tools.test.mjs +6 -5
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +64 -0
- package/test/plugin-manager.test.mjs +20 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-cli.test.ts +47 -3
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduled-tasks.test.ts +2 -2
- package/test/scheduler-host.test.ts +21 -3
- package/test/scheduler-relay.test.ts +84 -4
- package/test/scheduler.test.ts +21 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +77 -18
package/src/audio.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type AudioOptions = {
|
|
|
6
6
|
geminiApiKey?: string
|
|
7
7
|
openaiApiKey?: string
|
|
8
8
|
voice?: string
|
|
9
|
+
speechProvider?: 'gemini' | 'openrouter'
|
|
10
|
+
speechModel?: string
|
|
11
|
+
openrouterApiKey?: string
|
|
12
|
+
signal?: AbortSignal
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
export class SpeechCreditsDepletedError extends Error {
|
|
@@ -32,27 +36,12 @@ export const pcmToWav = (pcm: Buffer, sampleRate = 24000, channels = 1): Buffer
|
|
|
32
36
|
return Buffer.concat([header, pcm])
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
const convertSpeech = (audio: Buffer, input: 's16le' | 'mp3', format: 'wav' | 'ogg', sampleRate = 24000, channels = 1): Promise<Buffer> => {
|
|
36
40
|
return new Promise((resolve, reject) => {
|
|
37
41
|
const invocation = executorInvocation(
|
|
38
42
|
'ffmpeg',
|
|
39
|
-
[
|
|
40
|
-
'-f',
|
|
41
|
-
's16le',
|
|
42
|
-
'-ar',
|
|
43
|
-
String(sampleRate),
|
|
44
|
-
'-ac',
|
|
45
|
-
String(channels),
|
|
46
|
-
'-i',
|
|
47
|
-
'-',
|
|
48
|
-
'-c:a',
|
|
49
|
-
'libopus',
|
|
50
|
-
'-b:a',
|
|
51
|
-
'32k',
|
|
52
|
-
'-f',
|
|
53
|
-
'ogg',
|
|
54
|
-
'-',
|
|
55
|
-
],
|
|
43
|
+
[...(input === 's16le' ? ['-f', 's16le', '-ar', String(sampleRate), '-ac', String(channels)] : ['-f', 'mp3']),
|
|
44
|
+
'-i', '-', ...(format === 'ogg' ? ['-c:a', 'libopus', '-b:a', '32k', '-f', 'ogg'] : ['-ar', '24000', '-ac', '1', '-f', 's16le']), '-'],
|
|
56
45
|
)
|
|
57
46
|
const child = spawn(invocation.command, invocation.args,
|
|
58
47
|
{ env: executorEnvironment(), stdio: ['pipe', 'pipe', 'pipe'] })
|
|
@@ -60,7 +49,8 @@ export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Pr
|
|
|
60
49
|
const output: Buffer[] = []
|
|
61
50
|
let error = ''
|
|
62
51
|
|
|
63
|
-
|
|
52
|
+
let outputBytes = 0
|
|
53
|
+
child.stdout.on('data', (chunk) => { outputBytes += chunk.length; if(outputBytes > 16 * 1024 * 1024) { child.kill('SIGKILL'); reject(new Error('Encoded speech exceeds limit')) } else output.push(chunk) })
|
|
64
54
|
child.stderr.on('data', (chunk) => {
|
|
65
55
|
error = (error + chunk.toString()).slice(-2048)
|
|
66
56
|
})
|
|
@@ -70,16 +60,18 @@ export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Pr
|
|
|
70
60
|
|
|
71
61
|
child.on('close', (code) => {
|
|
72
62
|
if (code === 0 && output.length > 0) {
|
|
73
|
-
resolve(Buffer.concat(output))
|
|
63
|
+
resolve(format === 'wav' ? pcmToWav(Buffer.concat(output)) : Buffer.concat(output))
|
|
74
64
|
} else {
|
|
75
65
|
reject(new Error(`Voice encoding failed (${code}): ${error}`))
|
|
76
66
|
}
|
|
77
67
|
})
|
|
78
68
|
|
|
79
|
-
child.stdin.end(
|
|
69
|
+
child.stdin.end(audio)
|
|
80
70
|
})
|
|
81
71
|
}
|
|
82
72
|
|
|
73
|
+
export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1) => convertSpeech(pcm, 's16le', 'ogg', sampleRate, channels)
|
|
74
|
+
|
|
83
75
|
export const transcribeAudio = async (
|
|
84
76
|
audioBytes: Buffer,
|
|
85
77
|
mimeType: string,
|
|
@@ -146,6 +138,29 @@ export const synthesizeSpeech = async (
|
|
|
146
138
|
const trimmed = text.trim()
|
|
147
139
|
if (!trimmed) throw new Error('Cannot synthesize speech for empty text')
|
|
148
140
|
|
|
141
|
+
if ((options.speechProvider ?? process.env.EZ_SPEECH_PROVIDER) === 'openrouter') {
|
|
142
|
+
const key = (options.openrouterApiKey ?? process.env.OPENROUTER_API_KEY)?.trim()
|
|
143
|
+
if (!key) throw new Error('OpenRouter speech is not configured')
|
|
144
|
+
const response = await fetch('https://openrouter.ai/api/v1/audio/speech', {
|
|
145
|
+
method: 'POST', signal: options.signal ?? AbortSignal.timeout(60000),
|
|
146
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
|
|
147
|
+
body: JSON.stringify({ model: options.speechModel ?? process.env.EZ_SPEECH_MODEL ?? 'fish-audio/s2.1-pro-free:free',
|
|
148
|
+
input: trimmed, response_format: 'mp3', ...(options.voice ? { voice: options.voice } : {}) })
|
|
149
|
+
})
|
|
150
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error(`OpenRouter speech synthesis error ${response.status}`) }
|
|
151
|
+
const chunks: Buffer[] = []; let size = 0
|
|
152
|
+
const reader = response.body?.getReader()
|
|
153
|
+
if (!reader) throw new Error('OpenRouter returned no audio')
|
|
154
|
+
try {
|
|
155
|
+
for (;;) { const {done,value} = await reader.read(); if(done) break
|
|
156
|
+
size += value.length; if(size > 16 * 1024 * 1024) throw new Error('Speech audio exceeds limit')
|
|
157
|
+
chunks.push(Buffer.from(value)) }
|
|
158
|
+
} finally { await reader.cancel() }
|
|
159
|
+
if (!size) throw new Error('OpenRouter returned no audio')
|
|
160
|
+
const buffer = await convertSpeech(Buffer.concat(chunks), 'mp3', options.format ?? 'ogg')
|
|
161
|
+
return { buffer, mimeType: options.format === 'wav' ? 'audio/wav' : 'audio/ogg' }
|
|
162
|
+
}
|
|
163
|
+
|
|
149
164
|
const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
|
|
150
165
|
if (geminiKey) {
|
|
151
166
|
const url =
|
|
@@ -154,7 +169,7 @@ export const synthesizeSpeech = async (
|
|
|
154
169
|
const response = await fetch(url, {
|
|
155
170
|
method: 'POST',
|
|
156
171
|
headers: { 'content-type': 'application/json', 'x-goog-api-key': geminiKey },
|
|
157
|
-
signal: AbortSignal.timeout(60_000),
|
|
172
|
+
signal: options.signal ?? AbortSignal.timeout(60_000),
|
|
158
173
|
body: JSON.stringify({
|
|
159
174
|
contents: [{ parts: [{ text: trimmed }] }],
|
|
160
175
|
generationConfig: {
|
package/src/client-defaults.ts
CHANGED
|
@@ -78,7 +78,7 @@ export const grokSettings = (text: string): { model?: string; effort?: string }
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
export const discoverDefaults = async (cwd: string, options: {
|
|
81
|
-
home?: string; codexHome?: string; nativeCodexFallback?: boolean; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
|
|
81
|
+
home?: string; codexHome?: string; claudeHome?: string; nativeCodexFallback?: boolean; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
|
|
82
82
|
} = {}): Promise<AiPreset[]> => {
|
|
83
83
|
const home = options.home ?? homedir()
|
|
84
84
|
const available = options.available ?? installed
|
|
@@ -99,7 +99,8 @@ export const discoverDefaults = async (cwd: string, options: {
|
|
|
99
99
|
model = value(config.model); effort = value(config.effort)
|
|
100
100
|
} else if (cli === 'claude') {
|
|
101
101
|
// Match Claude's documented user -> project -> local settings precedence.
|
|
102
|
-
|
|
102
|
+
// User settings come from the agent-bound CLAUDE_CONFIG_DIR when bound.
|
|
103
|
+
const settings = Object.assign({}, await json(join(options.claudeHome ?? join(home, '.claude'), 'settings.json')),
|
|
103
104
|
await json(join(cwd, '.claude/settings.json')), await json(join(cwd, '.claude/settings.local.json')))
|
|
104
105
|
model = value(settings.model); effort = value(settings.effortLevel)
|
|
105
106
|
} else if (cli === 'opencode') {
|
package/src/config.ts
CHANGED
|
@@ -19,12 +19,16 @@ export type Config = ControlConfig & {
|
|
|
19
19
|
isolation?: IsolationClass
|
|
20
20
|
codexSandbox?: 'external'
|
|
21
21
|
codexAutoCompactTokens?: number
|
|
22
|
+
scheduledConcurrency?: number
|
|
22
23
|
executorCli: string
|
|
23
24
|
channelBackendUrl?: string
|
|
24
25
|
channelBackendToken?: string
|
|
25
26
|
applicationPort?: number
|
|
26
27
|
applicationHost?: string
|
|
27
28
|
geminiApiKey?: string
|
|
29
|
+
speechProvider?: 'gemini' | 'openrouter'
|
|
30
|
+
speechModel?: string
|
|
31
|
+
openrouterApiKey?: string
|
|
28
32
|
speechVoiceEn?: string
|
|
29
33
|
speechVoiceEs?: string
|
|
30
34
|
openaiApiKey?: string
|
|
@@ -69,6 +73,7 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
69
73
|
const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
|
|
70
74
|
if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
|
|
71
75
|
if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || transport !== 'local')) throw new Error('External Codex sandbox requires native local execution')
|
|
76
|
+
if (env.EZ_SPEECH_PROVIDER && !['gemini','openrouter'].includes(env.EZ_SPEECH_PROVIDER)) throw new Error('Invalid EZ_SPEECH_PROVIDER')
|
|
72
77
|
return {
|
|
73
78
|
...loadControlConfig(env),
|
|
74
79
|
telegramEnabled,
|
|
@@ -80,12 +85,16 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
80
85
|
isolation,
|
|
81
86
|
codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
|
|
82
87
|
codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
|
|
88
|
+
scheduledConcurrency: positiveInteger(env.EZ_SCHEDULED_CONCURRENCY, 'EZ_SCHEDULED_CONCURRENCY', 6),
|
|
83
89
|
executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'codex',
|
|
84
90
|
applicationPort: env.EZ_APPLICATION_PORT ? positiveInteger(env.EZ_APPLICATION_PORT, 'EZ_APPLICATION_PORT') : undefined,
|
|
85
91
|
applicationHost: env.EZ_APPLICATION_HOST?.trim() || '127.0.0.1',
|
|
86
92
|
channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
|
|
87
93
|
channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
|
|
88
94
|
geminiApiKey: env.GEMINI_API_KEY?.trim(),
|
|
95
|
+
speechProvider: env.EZ_SPEECH_PROVIDER as Config['speechProvider'],
|
|
96
|
+
speechModel: env.EZ_SPEECH_MODEL?.trim(),
|
|
97
|
+
openrouterApiKey: env.OPENROUTER_API_KEY?.trim(),
|
|
89
98
|
speechVoiceEn: env.EZ_SPEECH_VOICE_EN?.trim(),
|
|
90
99
|
speechVoiceEs: env.EZ_SPEECH_VOICE_ES?.trim(),
|
|
91
100
|
openaiApiKey: env.OPENAI_API_KEY?.trim(),
|
package/src/control-state.ts
CHANGED
|
@@ -755,7 +755,9 @@ export class ControlStore {
|
|
|
755
755
|
const state = await this.readState()
|
|
756
756
|
await requireControlGuard(state, guard)
|
|
757
757
|
if (!state.ai) throw new Error('AI settings not initialized')
|
|
758
|
-
|
|
758
|
+
// Auto-detected client defaults come and go with installed clients; only
|
|
759
|
+
// owner-saved choices count toward the cap.
|
|
760
|
+
if (state.ai.presets.filter((p) => !p.id.startsWith('detected_')).length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
|
|
759
761
|
throw new Error('Keep it small: at most 12 saved AIs.')
|
|
760
762
|
state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
|
|
761
763
|
await this.writeState(state)
|
|
@@ -2,3 +2,5 @@ export type DeliverySocketRequest = { op: string; payload?: unknown }
|
|
|
2
2
|
export function deliverySocketPath(controlDir: string): string
|
|
3
3
|
export function callDeliverySocket(socketPath: string, op: DeliverySocketRequest, timeoutMs?: number): Promise<unknown>
|
|
4
4
|
export function deliverySocketAlive(socketPath: string, timeoutMs?: number): Promise<boolean>
|
|
5
|
+
|
|
6
|
+
export function callLocalSocket(socketPath:string, envelope:Record<string,unknown>, timeoutMs?:number):Promise<unknown>
|
|
@@ -67,6 +67,8 @@ const exchange = (connect, envelope, timeoutMs, target) => new Promise((resolve,
|
|
|
67
67
|
socket.write(encode(envelope))
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
+
export const callLocalSocket = (socketPath, envelope, timeoutMs = 130000) => exchange(() => createConnection(socketPath), envelope, timeoutMs, socketPath)
|
|
71
|
+
|
|
70
72
|
const readEndpoint = async controlDir => {
|
|
71
73
|
try {
|
|
72
74
|
const value = JSON.parse(await readFile(path.join(controlDir, endpointName), 'utf8'))
|
package/src/delivery-socket.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sameOwner } from './control-state.js'
|
|
2
|
+
import { ApplicationBindings } from './application-channel.js'
|
|
1
3
|
import { createServer, type Server, type Socket } from 'node:net'
|
|
2
4
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
5
|
import { rename, rm, writeFile } from 'node:fs/promises'
|
|
@@ -8,6 +10,9 @@ import { ApprovalStore } from './approval.js'
|
|
|
8
10
|
import { deliveredMessages } from './message-history.js'
|
|
9
11
|
import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
|
|
10
12
|
import { telegramOwner } from './control-state.js'
|
|
13
|
+
import { Scheduler } from './scheduler.js'
|
|
14
|
+
import {Tasks} from './tasks.js'
|
|
15
|
+
import { runEvidence, type EvidenceOptions } from './run-evidence.js'
|
|
11
16
|
|
|
12
17
|
// Stateless pipe transport. The relay owns the runs/outbox ledger in process
|
|
13
18
|
// memory; engine children, plugin children and the host executor reach it
|
|
@@ -27,15 +32,19 @@ export { deliverySocketPath }
|
|
|
27
32
|
export type DeliverySocketOp =
|
|
28
33
|
| { op: 'ping' }
|
|
29
34
|
| { op: 'status' }
|
|
35
|
+
| { op: 'schedulePreflight'; payload?: Record<string, unknown> }
|
|
36
|
+
| { op: 'triggerSchedule'; payload: { scheduleId: string; revision: string; key: string; callerRunId?: string } }
|
|
30
37
|
| { op: 'enqueue'; payload: Record<string, unknown> }
|
|
31
38
|
| { op: 'wait'; payload: { id: string; timeoutMs?: number } }
|
|
32
39
|
| { op: 'get'; payload: { runId: string } }
|
|
33
40
|
| { op: 'list'; payload?: Record<string, never> }
|
|
34
41
|
| { op: 'patch'; payload: { runId: string; change: Record<string, unknown> } }
|
|
42
|
+
| { op: 'taskApplication'; payload: {runId:string;ticketHash:string} }
|
|
35
43
|
| { op: 'authorize'; payload: { runId: string } }
|
|
36
44
|
| { op: 'receipt'; payload: { context: unknown; id: string } }
|
|
37
45
|
| { op: 'history'; payload: { runId: string; limit?: number; messageId?: number } }
|
|
38
46
|
| { op: 'deliveryStatus'; payload?: Record<string, never> }
|
|
47
|
+
| { op: 'evidence'; payload: EvidenceOptions }
|
|
39
48
|
| { op: 'approvalCheck'; payload: { actionId: string } }
|
|
40
49
|
| { op: 'latestReply'; payload?: Record<string, never> }
|
|
41
50
|
|
|
@@ -208,6 +217,30 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
|
|
|
208
217
|
queued: all.filter((run) => run.status === 'queued').length,
|
|
209
218
|
}
|
|
210
219
|
}
|
|
220
|
+
case 'schedulePreflight': {
|
|
221
|
+
const {scheduleId,revision}=op.payload ?? {}
|
|
222
|
+
if(!nonEmptyString(scheduleId) || !nonEmptyString(revision)) throw Error('Schedule preflight identity required')
|
|
223
|
+
const scheduler=new Scheduler(controlDir),s=await scheduler.get(scheduleId),owner=await readOnlyOwner(controlDir)
|
|
224
|
+
if(!owner || !sameOwner(owner,s.owner) || !s.enabled || s.revision!==revision || !s.preflight) throw Error('Schedule preflight binding unavailable')
|
|
225
|
+
if(s.delivery) {
|
|
226
|
+
const binding=(await new ApplicationBindings(controlDir).list()).find(b=>b.bindingId===s.delivery!.bindingId)
|
|
227
|
+
if(!binding || !sameOwner(owner,binding.owner)) throw Error('Schedule preflight delivery revoked')
|
|
228
|
+
}
|
|
229
|
+
return s.preflight
|
|
230
|
+
}
|
|
231
|
+
case 'triggerSchedule': {
|
|
232
|
+
const {scheduleId,revision,key,callerRunId}=op.payload ?? {}
|
|
233
|
+
if (!nonEmptyString(scheduleId) || !nonEmptyString(revision) || !nonEmptyString(key)) throw new Error('Task ID, revision and request key are required')
|
|
234
|
+
if (callerRunId !== undefined) {
|
|
235
|
+
const caller=await requireOwnerExecution(controlDir,callerRunId)
|
|
236
|
+
if (caller.replyOnly || caller.script || (caller.scheduled && caller.scheduled.pairedAt !== (await readOnlyOwner(controlDir))?.pairedAt)) throw new Error('Scheduling requires an active owner-authorized run')
|
|
237
|
+
}
|
|
238
|
+
const owner=await readOnlyOwner(controlDir)
|
|
239
|
+
if (!owner) throw new Error('Pair an owner before scheduling')
|
|
240
|
+
const run=await new Scheduler(controlDir).trigger(scheduleId,revision,key,owner,runs)
|
|
241
|
+
hooks.wake()
|
|
242
|
+
return run
|
|
243
|
+
}
|
|
211
244
|
case 'enqueue': {
|
|
212
245
|
const envelope = (op.payload ?? {}) as Record<string, unknown>
|
|
213
246
|
if (envelope.kind === 'owner') {
|
|
@@ -287,6 +320,10 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
|
|
|
287
320
|
if (!payload.change || typeof payload.change !== 'object') throw new Error('Patch change is required')
|
|
288
321
|
return runs.patch(payload.runId, payload.change as never)
|
|
289
322
|
}
|
|
323
|
+
case 'taskApplication': {
|
|
324
|
+
const p=op.payload as {runId:string;ticketHash:string}
|
|
325
|
+
return new Tasks(controlDir).authorizeApplicationLaunch(p.runId,p.ticketHash)
|
|
326
|
+
}
|
|
290
327
|
case 'authorize': {
|
|
291
328
|
const runId = (op.payload as { runId?: unknown } | undefined)?.runId
|
|
292
329
|
if (typeof runId !== 'string' || !runId) throw new Error('Run ID is required')
|
|
@@ -306,6 +343,7 @@ export const createLedgerHandler = (controlDir: string, hooks: LedgerHooks): Del
|
|
|
306
343
|
...(payload.messageId === undefined ? {} : { messageId: payload.messageId as number }),
|
|
307
344
|
})
|
|
308
345
|
}
|
|
346
|
+
case 'evidence': return runEvidence(controlDir, op.payload ?? {})
|
|
309
347
|
case 'deliveryStatus': return runs.deliveryStatus()
|
|
310
348
|
case 'approvalCheck': {
|
|
311
349
|
const actionId = (op.payload as { actionId?: unknown } | undefined)?.actionId
|
|
@@ -351,4 +389,3 @@ export const deliverySocketAlive = (socketPath: string, timeoutMs = 2000): Promi
|
|
|
351
389
|
// closed DeliverySocketOp union.
|
|
352
390
|
export const callDeliverySocket = (socketPath: string, op: DeliverySocketOp, timeoutMs = 130_000): Promise<unknown> =>
|
|
353
391
|
callSocket(socketPath, op, timeoutMs)
|
|
354
|
-
|
package/src/event-sources.ts
CHANGED
|
@@ -27,11 +27,11 @@ async function atomic(path: string, data: unknown) {
|
|
|
27
27
|
try { await fd.writeFile(JSON.stringify(data)); await fd.sync() } finally { await fd.close() }
|
|
28
28
|
await rename(tmp, path)
|
|
29
29
|
}
|
|
30
|
-
export function sourceCall(socketPath: string, command: string, args = {}): Promise<any> {
|
|
30
|
+
export function sourceCall(socketPath: string, command: string, args = {}, maxResponseBytes = 256000): Promise<any> {
|
|
31
31
|
return new Promise((resolve, reject) => {
|
|
32
32
|
const req = request({ socketPath, path: '/', method: 'POST', timeout: 3000 }, res => {
|
|
33
33
|
let body = ''
|
|
34
|
-
res.on('data', chunk => { body += chunk; if (Buffer.byteLength(body) >
|
|
34
|
+
res.on('data', chunk => { body += chunk; if (Buffer.byteLength(body) > maxResponseBytes) req.destroy(new Error('Event-source response too large')) })
|
|
35
35
|
res.on('error', () => reject(new Error('Event-source response interrupted')))
|
|
36
36
|
res.on('end', () => {
|
|
37
37
|
try { const result = JSON.parse(body); if (res.statusCode !== 200 || result.ok !== true) throw new Error(); resolve(result.data) }
|
package/src/executor.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { createInterface } from 'node:readline'
|
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
12
|
import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
|
|
13
13
|
import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
|
|
14
|
+
import { Scripts, type ScriptRunRef } from './scripts.js'
|
|
14
15
|
|
|
15
16
|
export type ExecutorOptions = {
|
|
16
17
|
repairEnabled?: boolean
|
|
@@ -38,8 +39,12 @@ export type ExecutorOptions = {
|
|
|
38
39
|
codexAutoCompactTokens?: number
|
|
39
40
|
codexProvider?: CodexProviderBinding
|
|
40
41
|
onSession?: (id: string) => Promise<void>
|
|
42
|
+
// Registered script occurrence: invoke it directly, never a native CLI.
|
|
43
|
+
script?: ScriptLaunch
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
export type ScriptLaunch = ScriptRunRef & { scheduleId: string; scheduleRevision: string; dueAt: string }
|
|
47
|
+
|
|
43
48
|
export type CodexProviderBinding = {
|
|
44
49
|
id: string
|
|
45
50
|
name: string
|
|
@@ -71,7 +76,7 @@ export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export const executorJobEnv = (
|
|
74
|
-
options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'
|
|
79
|
+
options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'> & { workspace?: string },
|
|
75
80
|
environment: NodeJS.ProcessEnv = process.env,
|
|
76
81
|
): NodeJS.ProcessEnv => {
|
|
77
82
|
const base = executorEnvironment(environment)
|
|
@@ -81,6 +86,7 @@ export const executorJobEnv = (
|
|
|
81
86
|
PATH: pathValue,
|
|
82
87
|
EZ_RUN_ID: options.runId,
|
|
83
88
|
EZ_CONTROL_DIR: options.controlDir,
|
|
89
|
+
...(options.workspace ? { EZ_AGENT_WORKSPACE: options.workspace } : {}),
|
|
84
90
|
EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
|
|
85
91
|
EZ_REPAIR_ENABLED: String(options.repairEnabled !== false),
|
|
86
92
|
...(options.toolsHome ? {BUILDX_CONFIG:path.join(options.toolsHome,'buildx')} : {}),
|
|
@@ -155,6 +161,8 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
|
|
|
155
161
|
args.push('--session-id', opts.sessionId)
|
|
156
162
|
}
|
|
157
163
|
args.push('--print', '--dangerously-skip-permissions')
|
|
164
|
+
for (const directory of [opts.controlDir, opts.sharedWorkspace, ...(opts.additionalWorkspaces ?? []), opts.toolsHome])
|
|
165
|
+
if (directory) args.push('--add-dir', directory)
|
|
158
166
|
if (opts.model) args.push('--model', opts.model)
|
|
159
167
|
if (opts.effort) args.push('--effort', opts.effort)
|
|
160
168
|
return args
|
|
@@ -285,6 +293,9 @@ export const opencodeDataHome = (controlDir: string): string | undefined => {
|
|
|
285
293
|
} catch { return undefined }
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
// Agent-bound Claude Code configuration directory (CLAUDE_CONFIG_DIR).
|
|
297
|
+
export const claudeConfigHome = (controlDir: string): string => path.join(controlDir, 'cli', 'claude')
|
|
298
|
+
|
|
288
299
|
// Pi resolves its provider/model catalog from an agent dir (settings.json,
|
|
289
300
|
// auth.json). Only a path is ever returned, never a secret.
|
|
290
301
|
export const piAgentDir = (controlDir: string): string | undefined => {
|
|
@@ -323,6 +334,7 @@ export const startExecutorJob = async (
|
|
|
323
334
|
texts: string[],
|
|
324
335
|
options: ExecutorOptions,
|
|
325
336
|
): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
|
|
337
|
+
if (options.script && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
|
|
326
338
|
options = executionDefaults(executorKey(options.cli), options)
|
|
327
339
|
// Routing is caller-owned: a restricted task run goes to the task runner
|
|
328
340
|
// unless the host transport must ship it across the boundary first.
|
|
@@ -387,6 +399,32 @@ export const startExecutorJob = async (
|
|
|
387
399
|
}
|
|
388
400
|
environment.CODEX_HOME = home
|
|
389
401
|
}
|
|
402
|
+
if (!host && !gui && key === 'claude') {
|
|
403
|
+
// Mirror the Codex binding: agent-bound settings, memory, plugins and
|
|
404
|
+
// sessions; never the user's personal ~/.claude configuration.
|
|
405
|
+
const home = claudeConfigHome(options.controlDir)
|
|
406
|
+
await mkdir(home, {recursive:true,mode:0o700})
|
|
407
|
+
environment.CLAUDE_CONFIG_DIR = home
|
|
408
|
+
// Host-capable agents share the installer login only. An empty secure
|
|
409
|
+
// storage dir makes Claude use its default credential store (macOS
|
|
410
|
+
// Keychain entry or ~/.claude/.credentials.json) in place, so token
|
|
411
|
+
// refreshes stay shared rather than diverging through a copy. Isolated
|
|
412
|
+
// agents keep their own login inside CLAUDE_CONFIG_DIR. The host executor
|
|
413
|
+
// runs unlabelled, so this matches the Codex auth-link check, not
|
|
414
|
+
// resolveIsolation().
|
|
415
|
+
if (process.env.EZ_ISOLATION !== 'isolated') environment.CLAUDE_SECURESTORAGE_CONFIG_DIR = ''
|
|
416
|
+
// Optional owner-provisioned long-lived token (`claude setup-token`). It
|
|
417
|
+
// never refreshes, so one file can be copied to every agent without the
|
|
418
|
+
// agents invalidating each other's OAuth session.
|
|
419
|
+
const token = await readFile(path.join(home, 'oauth-token'), 'utf8').then(value => value.trim(), (error: NodeJS.ErrnoException) => {
|
|
420
|
+
if (error.code === 'ENOENT') return undefined
|
|
421
|
+
throw error
|
|
422
|
+
})
|
|
423
|
+
if (token !== undefined) {
|
|
424
|
+
if (!/^[A-Za-z0-9._-]{20,512}$/.test(token)) throw new Error('Invalid Claude token binding')
|
|
425
|
+
environment.CLAUDE_CODE_OAUTH_TOKEN = token
|
|
426
|
+
}
|
|
427
|
+
}
|
|
390
428
|
if (!host && !gui && key === 'opencode') {
|
|
391
429
|
// Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
|
|
392
430
|
// when the owner provisioned it. Host runs keep the installer login;
|
|
@@ -459,6 +497,39 @@ export const startExecutorJob = async (
|
|
|
459
497
|
|
|
460
498
|
export const startGrokJob = startExecutorJob
|
|
461
499
|
|
|
500
|
+
// Spawn a verified registered script with the executor identity, environment
|
|
501
|
+
// allowlist and workspace confinement. No shell, model, prompt or native session.
|
|
502
|
+
export const startScriptJob = async (
|
|
503
|
+
options: ExecutorOptions & { script: ScriptLaunch },
|
|
504
|
+
): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
|
|
505
|
+
const { script } = options
|
|
506
|
+
for (const value of [options.runId, script.scheduleId, script.scheduleRevision]) if (!/^[a-zA-Z0-9_-]+$/.test(value)) throw new Error('Invalid script run binding')
|
|
507
|
+
if (!Number.isFinite(Date.parse(script.dueAt))) throw new Error('Invalid script due time')
|
|
508
|
+
const environment: NodeJS.ProcessEnv = {
|
|
509
|
+
...executorJobEnv(options),
|
|
510
|
+
EZ_SCHEDULE_ID: script.scheduleId, EZ_SCHEDULE_REVISION: script.scheduleRevision, EZ_DUE_AT: script.dueAt,
|
|
511
|
+
EZ_SCRIPT_ID: script.id, EZ_SCRIPT_REVISION: script.revision, EZ_SCRIPT_SHA256: script.sha256,
|
|
512
|
+
}
|
|
513
|
+
const verified = await new Scripts(options.controlDir).verifiedInvocation(script, options.workspace, environment.PATH)
|
|
514
|
+
let invocation = executorInvocation(verified.command, verified.args)
|
|
515
|
+
let profileDirectory: string | undefined
|
|
516
|
+
const denies = process.platform === 'darwin' ? workspaceSiblingDenies(options.workspace) : []
|
|
517
|
+
if (denies.length) {
|
|
518
|
+
profileDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
|
|
519
|
+
const profile = path.join(profileDirectory, 'workspace.sb')
|
|
520
|
+
await writeFile(profile, macosWorkspaceProfile(denies, [options.controlDir, options.binDir, options.toolsHome, options.sharedWorkspace,
|
|
521
|
+
...(options.additionalWorkspaces ?? [])].filter((value): value is string => Boolean(value))), { mode: 0o600 })
|
|
522
|
+
invocation = { command: 'sandbox-exec', args: ['-f', profile, invocation.command, ...invocation.args] }
|
|
523
|
+
}
|
|
524
|
+
const cleanup = async () => { if (profileDirectory) await rm(profileDirectory, { recursive: true, force: true }) }
|
|
525
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
526
|
+
cwd: options.workspace, env: environment, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', shell: false,
|
|
527
|
+
})
|
|
528
|
+
await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
|
|
529
|
+
.catch(async (error) => { await cleanup(); throw error })
|
|
530
|
+
return { child, cleanup, stdout: '' }
|
|
531
|
+
}
|
|
532
|
+
|
|
462
533
|
// Structured client events only. Model text is never interpreted or sent to chat.
|
|
463
534
|
export const nativeSessionId = (cli: string, line: string): string | undefined => {
|
|
464
535
|
try {
|
package/src/files.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, realpath, stat } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, readFile, writeFile, realpath, stat, mkdtemp, readdir, rm, unlink } from 'node:fs/promises'
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
2
4
|
import { randomUUID } from 'node:crypto'
|
|
3
5
|
import path from 'node:path'
|
|
4
6
|
|
|
5
|
-
export type DetectedFileType = 'pdf' | 'jpeg' | 'png' | 'webp' | 'text' | 'unknown'
|
|
7
|
+
export type DetectedFileType = 'pdf' | 'jpeg' | 'png' | 'webp' | 'text' | 'mp4' | 'unknown'
|
|
6
8
|
|
|
7
9
|
export const detectFileType = (buffer: Buffer): DetectedFileType => {
|
|
8
10
|
if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
|
|
@@ -31,6 +33,9 @@ export const detectFileType = (buffer: Buffer): DetectedFileType => {
|
|
|
31
33
|
) {
|
|
32
34
|
return 'webp'
|
|
33
35
|
}
|
|
36
|
+
// ISO-BMFF also carries HEIF/AVIF stills and audio-only M4A; only video brands are MP4.
|
|
37
|
+
if (buffer.length >= 12 && buffer.subarray(4,8).toString('ascii') === 'ftyp' &&
|
|
38
|
+
!/^(hei[cmsx]|hev[cmsx]|mif1|msf1|avi[fs]|M4[ABP] |F4A )$/.test(buffer.subarray(8,12).toString('latin1'))) return 'mp4'
|
|
34
39
|
try {
|
|
35
40
|
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
|
36
41
|
if (!buffer.includes(0)) return 'text'
|
|
@@ -108,7 +113,46 @@ export const stageChatAttachment = async (stagingRoot: string, name: string, byt
|
|
|
108
113
|
if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
|
|
109
114
|
(type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
|
|
110
115
|
const staged = await stageIncomingFile(stagingRoot, name, bytes)
|
|
111
|
-
const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
|
|
116
|
+
const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : staged.fileType === 'mp4' ? 'video' : 'document'
|
|
112
117
|
return { text: `[Attached ${kind} staged at ${staged.fullPath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
|
|
113
118
|
attachment: {path: staged.fullPath, type: staged.fileType}, fullPath: staged.fullPath }
|
|
114
119
|
}
|
|
120
|
+
|
|
121
|
+
// Shared inbound staging/validation with Telegram; restricted correspondence
|
|
122
|
+
// can read only this already-authorized input, never arbitrary workspace paths.
|
|
123
|
+
export async function readChatAttachment(stagingRoot: string, name: string, bytes: Buffer) {
|
|
124
|
+
const staged = await stageChatAttachment(stagingRoot, name, bytes, '')
|
|
125
|
+
// Untrusted media parsers never inherit relay secrets.
|
|
126
|
+
const execute = (command: string, args: string[], options: {timeout:number;maxBuffer:number}) =>
|
|
127
|
+
promisify(execFile)(command, args, {...options, env: {PATH: process.env.PATH ?? '/usr/bin:/bin'}})
|
|
128
|
+
try {
|
|
129
|
+
if (['jpeg','png','webp'].includes(staged.attachment.type)) return {name:sanitizeFileName(name),type:staged.attachment.type,bytes:bytes.length,image:{data:bytes.toString('base64'),mimeType:`image/${staged.attachment.type}`}}
|
|
130
|
+
if (staged.attachment.type === 'mp4') {
|
|
131
|
+
const directory = await mkdtemp(path.join(stagingRoot,'video-'))
|
|
132
|
+
try {
|
|
133
|
+
const probe = JSON.parse((await execute('ffprobe',['-v','error','-protocol_whitelist','file,pipe','-show_entries','format=duration:stream=codec_type','-of','json',staged.fullPath],{timeout:3000,maxBuffer:8000})).stdout)
|
|
134
|
+
const durationSeconds = Number(probe.format?.duration)
|
|
135
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0 || durationSeconds > 120 || !probe.streams?.some((stream:{codec_type:string})=>stream.codec_type==='video')) throw Error('Video must contain a visual track and be at most 120 seconds')
|
|
136
|
+
await execute('ffmpeg',['-v','error','-nostdin','-protocol_whitelist','file,pipe','-enable_drefs','0','-use_absolute_path','0','-i',staged.fullPath,'-an','-vf',`fps=fps=${8/durationSeconds}:start_time=0,scale=640:-2`,'-frames:v','8','-q:v','3',path.join(directory,'frame-%02d.jpg')],{timeout:20000,maxBuffer:8000})
|
|
137
|
+
const names=(await readdir(directory)).filter(name=>/^frame-\d{2}\.jpg$/.test(name)).sort()
|
|
138
|
+
const images=[];let total=0
|
|
139
|
+
for (const frame of names) {
|
|
140
|
+
const image=await readFile(path.join(directory,frame));total+=image.length
|
|
141
|
+
if (total>1024*1024 || detectFileType(image)!=='jpeg') throw Error('Video frames exceed the bounded image limit')
|
|
142
|
+
images.push({data:image.toString('base64'),mimeType:'image/jpeg'})
|
|
143
|
+
}
|
|
144
|
+
if (!images.length) throw Error('Video has no decodable frames')
|
|
145
|
+
return {name:sanitizeFileName(name),type:'mp4',bytes:bytes.length,durationSeconds,frames:images.map((_,index)=>({frame:index+1,seconds:index*durationSeconds/8})),images}
|
|
146
|
+
} finally { await rm(directory,{recursive:true,force:true}) }
|
|
147
|
+
}
|
|
148
|
+
const limit = 256000
|
|
149
|
+
const text = staged.attachment.type === 'text' ? new TextDecoder('utf-8', {fatal:true}).decode(bytes)
|
|
150
|
+
: (await execute('pdftotext', ['-enc','UTF-8','-layout',staged.fullPath,'-'], {timeout:20000,maxBuffer:limit + 1}).catch(error => {
|
|
151
|
+
if ((error as {code?:unknown}).code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') throw Error('Extracted document exceeds the 256000-byte text limit')
|
|
152
|
+
throw error
|
|
153
|
+
})).stdout
|
|
154
|
+
if (!text.trim()) throw Error('Document has no extractable text; scanned PDFs require OCR')
|
|
155
|
+
if (Buffer.byteLength(text) > limit) throw Error('Extracted document exceeds the 256000-byte text limit')
|
|
156
|
+
return { name: sanitizeFileName(name), type: staged.attachment.type, bytes: bytes.length, text }
|
|
157
|
+
} finally { await unlink(staged.fullPath) }
|
|
158
|
+
}
|
package/src/host-executor.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { readModels, validateSelection, validateOpencodeProviders, type ModelCho
|
|
|
13
13
|
import type { ChildProcess } from 'node:child_process'
|
|
14
14
|
import { packageVersion } from './version.js'
|
|
15
15
|
import { installedPluginVersions } from './software-status.js'
|
|
16
|
+
import { assertScheduledModel } from './model-policy.js'
|
|
16
17
|
import { processSnapshot } from './process-tree.js'
|
|
17
18
|
|
|
18
19
|
export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
|
|
@@ -51,7 +52,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
51
52
|
let catalogRefresh: Promise<void> | undefined
|
|
52
53
|
const locks: string[] = []
|
|
53
54
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
54
|
-
const activeWorkspaces = new Map<HostBinding, { lanes:
|
|
55
|
+
const activeWorkspaces = new Map<HostBinding, { lanes: Map<string, boolean>; release?: () => Promise<void> }>()
|
|
55
56
|
const additionalWorkspaces = new Map<HostBinding, string[]>()
|
|
56
57
|
const catalog = async (agent: HostBinding) => {
|
|
57
58
|
const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
|
|
@@ -160,18 +161,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
160
161
|
const base=path.join(directory,id)
|
|
161
162
|
const scheduled = Boolean(run?.scheduled)
|
|
162
163
|
let workspace = activeWorkspaces.get(agent)
|
|
163
|
-
if (workspace
|
|
164
|
+
if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
|
|
164
165
|
if (!workspace) {
|
|
165
166
|
const release = agent.toolsHome
|
|
166
167
|
? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
|
|
167
168
|
if (agent.toolsHome && !release) continue
|
|
168
|
-
workspace = { lanes: new
|
|
169
|
+
workspace = { lanes: new Map(), release }
|
|
169
170
|
activeWorkspaces.set(agent, workspace)
|
|
170
171
|
}
|
|
171
|
-
workspace.lanes.
|
|
172
|
-
// Keep plugin workspace invocations excluded until
|
|
172
|
+
workspace.lanes.set(id, scheduled)
|
|
173
|
+
// Keep plugin workspace invocations excluded until all native jobs finish.
|
|
173
174
|
const releaseWorkspace = async () => {
|
|
174
|
-
workspace.lanes.delete(
|
|
175
|
+
workspace.lanes.delete(id)
|
|
175
176
|
if (!workspace.lanes.size) {
|
|
176
177
|
activeWorkspaces.delete(agent)
|
|
177
178
|
await workspace.release?.()
|
|
@@ -194,26 +195,39 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
194
195
|
await new Tasks(agent.controlDir).authorize(run, false)
|
|
195
196
|
} else await authorizeRun(agent.controlDir, path.basename(base))
|
|
196
197
|
const opts=request.options as ExecutorOptions
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
198
|
+
// Script occurrences come from the authorized run record, never the request.
|
|
199
|
+
if (run?.script) {
|
|
200
|
+
if (!run.scheduled) throw new Error('Script runs require a scheduled occurrence')
|
|
201
|
+
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
|
|
202
|
+
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
203
|
+
script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
|
|
204
|
+
job=await launch(request.texts,options)
|
|
205
|
+
} else {
|
|
206
|
+
if (run?.scheduled) {
|
|
207
|
+
assertScheduledModel(run.execution?.preset.model)
|
|
208
|
+
if (opts.model !== run.execution!.preset.model) throw new Error('Scheduled run model does not match its saved task model')
|
|
209
|
+
}
|
|
210
|
+
const cli = opts.cli || installation.cli
|
|
211
|
+
resolveExecutor(cli)
|
|
212
|
+
const provider = cli === 'codex' && opts.provider
|
|
213
|
+
? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
|
|
214
|
+
: undefined
|
|
215
|
+
if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
|
|
216
|
+
const model = opts.model
|
|
217
|
+
if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
|
|
218
|
+
if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
|
|
219
|
+
if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
|
|
220
|
+
throw new Error('Selected Codex provider is required for this model')
|
|
221
|
+
// Admission and execution must use the same catalog snapshot. A
|
|
222
|
+
// transient native-client probe failure must not reject a model
|
|
223
|
+
// that the host just advertised to the relay and application.
|
|
224
|
+
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
|
|
225
|
+
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
226
|
+
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
227
|
+
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
228
|
+
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
229
|
+
job=await launch(request.texts,options)
|
|
230
|
+
}
|
|
217
231
|
active.set(base,job.child)
|
|
218
232
|
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
|
|
219
233
|
if(signal.aborted)terminateJob(job.child)
|