@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.env.example +10 -1
  2. package/AGENTS.md +40 -9
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +31 -1
  5. package/Dockerfile +1 -0
  6. package/README.md +84 -12
  7. package/bin/ezenciel-agents-application +2 -0
  8. package/bin/ezenciel-agents-application.mjs +16 -0
  9. package/compose.yaml +8 -0
  10. package/docker/entrypoint.sh +20 -2
  11. package/docker/healthcheck.mjs +1 -1
  12. package/docker/run.ts +3 -3
  13. package/docker/smoke.mjs +41 -2
  14. package/docs/application-channel.md +366 -0
  15. package/docs/architecture/ai-selection.md +12 -15
  16. package/docs/docker-runtime.md +29 -0
  17. package/docs/host-service.md +5 -8
  18. package/docs/local-qa.md +1 -1
  19. package/docs/managed-applications.md +68 -0
  20. package/docs/plugin-catalog.md +1 -0
  21. package/docs/plugin-connection.md +76 -0
  22. package/docs/plugins.md +54 -5
  23. package/docs/repair.md +26 -25
  24. package/docs/responsive-channels.md +13 -55
  25. package/docs/scheduling.md +40 -36
  26. package/docs/setup.md +11 -21
  27. package/docs/standalone-cli.md +2 -2
  28. package/docs/upgrades.md +43 -18
  29. package/package.json +8 -4
  30. package/src/agent-guidance.ts +32 -3
  31. package/src/ai-cli.ts +5 -1
  32. package/src/ai.ts +6 -28
  33. package/src/application-channel.ts +308 -0
  34. package/src/application-cli.ts +41 -0
  35. package/src/application-client.mjs +87 -0
  36. package/src/application-origin.ts +15 -0
  37. package/src/codex-session.ts +7 -10
  38. package/src/config.ts +23 -5
  39. package/src/control-state.ts +274 -21
  40. package/src/conversation-menu.ts +89 -0
  41. package/src/delivery-context.d.mts +5 -0
  42. package/src/delivery-context.mjs +25 -0
  43. package/src/desktop-bridge.ts +11 -43
  44. package/src/event-sources.ts +2 -2
  45. package/src/execution-authority.ts +2 -0
  46. package/src/executor.ts +29 -58
  47. package/src/host-executor.ts +11 -9
  48. package/src/identity.ts +11 -3
  49. package/src/index.ts +191 -93
  50. package/src/menu.ts +76 -55
  51. package/src/message-history.ts +52 -0
  52. package/src/message-send.ts +1 -1
  53. package/src/message.ts +49 -7
  54. package/src/model-policy.ts +5 -15
  55. package/src/owner.ts +7 -1
  56. package/src/plugins/connection-artifacts.mjs +31 -0
  57. package/src/plugins/connection.mjs +124 -0
  58. package/src/plugins/manager.mjs +93 -23
  59. package/src/plugins/native-tasks.d.mts +4 -0
  60. package/src/plugins/native-tasks.mjs +66 -0
  61. package/src/plugins/workspace-lease.d.mts +3 -0
  62. package/src/plugins/workspace-lease.mjs +44 -0
  63. package/src/repair-policy.ts +0 -8
  64. package/src/reply-context.ts +3 -29
  65. package/src/runs.ts +67 -9
  66. package/src/schedule-cli.ts +33 -15
  67. package/src/scheduled-tasks.ts +20 -21
  68. package/src/scheduler.ts +55 -22
  69. package/src/task-executor.ts +4 -5
  70. package/src/task-workspace.ts +2 -11
  71. package/src/update-attention.ts +1 -1
  72. package/src/updates/binding.mjs +2 -6
  73. package/src/updates/control.mjs +4 -0
  74. package/src/updates/supervisor.mjs +10 -4
  75. package/src/web-launcher.ts +19 -0
  76. package/src/workspace.ts +3 -1
  77. package/templates/agent/AGENTS.md +13 -55
  78. package/templates/agent-guidance.md +90 -37
  79. package/templates/deployments.md +24 -0
  80. package/templates/failure-review.md +6 -0
  81. package/templates/maintainer-purpose.md +12 -6
  82. package/test/agent-guidance.test.ts +29 -39
  83. package/test/ai-cli.test.ts +9 -0
  84. package/test/ai.test.ts +66 -22
  85. package/test/application-channel.test.ts +283 -0
  86. package/test/application-client.test.mjs +84 -0
  87. package/test/application-controls.test.ts +224 -0
  88. package/test/application-only.test.ts +100 -0
  89. package/test/busy-reply-relay.test.ts +11 -7
  90. package/test/channel-delivery.test.ts +63 -0
  91. package/test/channel-owner.test.ts +161 -0
  92. package/test/client-defaults.test.ts +1 -1
  93. package/test/codex-session.test.ts +18 -10
  94. package/test/config.test.ts +16 -1
  95. package/test/connection-artifacts.test.mjs +32 -0
  96. package/test/conversation-menu.test.ts +67 -0
  97. package/test/conversations.test.ts +84 -0
  98. package/test/desktop-bridge.test.ts +17 -11
  99. package/test/engine-handoff.test.ts +73 -0
  100. package/test/event-sources.test.ts +5 -8
  101. package/test/executor.test.ts +68 -16
  102. package/test/failure.test.ts +64 -0
  103. package/test/host-executor.test.ts +58 -17
  104. package/test/install-config.test.ts +1 -1
  105. package/test/intake-relay.test.ts +169 -25
  106. package/test/message-history.test.ts +127 -0
  107. package/test/model-policy.test.ts +23 -48
  108. package/test/native-tasks.test.ts +36 -0
  109. package/test/plugin-connection.test.mjs +124 -0
  110. package/test/plugin-manager.test.mjs +70 -10
  111. package/test/repair-policy.test.ts +8 -12
  112. package/test/runs.test.ts +13 -0
  113. package/test/runtime-identity.test.mjs +18 -0
  114. package/test/schedule-cli.test.ts +34 -5
  115. package/test/scheduled-tasks.test.ts +79 -8
  116. package/test/scheduler.test.ts +30 -1
  117. package/test/task-native.test.ts +5 -2
  118. package/test/update-attention.test.ts +1 -2
  119. package/test/updates.test.mjs +44 -5
  120. package/test/workspace.test.ts +2 -3
  121. package/scripts/smoke-busy-reply.ts +0 -58
  122. package/src/reply-executor.ts +0 -55
  123. package/src/reply-mcp.ts +0 -23
  124. package/templates/agent/TOOLS.md +0 -105
  125. package/templates/chat-guidance.md +0 -23
  126. package/templates/standalone-tools.md +0 -20
  127. package/templates/updates.md +0 -45
  128. package/test/reply.test.ts +0 -159
package/src/ai.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CODEX_DEFAULT_MODEL, DEFAULT_EFFORT, CODEX_CHAT_MODEL, CHAT_EFFORT, assertEffort, allowedEffort } from './model-policy.js'
1
+ import { assertEffort, allowedEffort } from './model-policy.js'
2
2
  import { access, readFile } from 'node:fs/promises'
3
3
  import { constants } from 'node:fs'
4
4
  import { homedir } from 'node:os'
@@ -21,41 +21,19 @@ export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
21
21
  const c = v as ExecutionChoice | undefined
22
22
  return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
23
23
  }
24
- export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || (
25
- ['codex', 'codex-gui'].includes(p.cli) && p.model === 'gpt-5.6-luna' ? DEFAULT_EFFORT : 'default effort'
26
- )}`
24
+ export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
27
25
  // The seed delegates model selection to the native client. Project its resolved
28
26
  // settings for status without pinning future conversations to that snapshot.
29
27
  export const statusPreset = (preset: AiPreset, discovered: AiPreset[]): AiPreset =>
30
- preset.cli === 'codex' && !preset.model && !preset.effort
28
+ ['codex','codex-gui'].includes(preset.cli) && !preset.model && !preset.effort
31
29
  ? discovered.find((candidate) => candidate.cli === preset.cli) ?? preset
32
30
  : preset
33
31
  export const initialPreset = (cli: string): AiPreset => {
34
32
  const key = executorKey(cli)
35
- return {
36
- id: 'initial', name: `${resolveExecutor(key).name} · current setup`, cli: key,
37
- ...(key === 'codex' || key === 'codex-gui'
38
- ? { model: CODEX_DEFAULT_MODEL, effort: DEFAULT_EFFORT } : {}),
39
- ...(key === 'opencode'
40
- ? { model: process.env.OPENCODE_MODEL || 'opencode/nemotron-3.5-lightning-free' } : {}),
41
- }
42
- }
43
-
44
- // Keep persisted state readable by older releases. Luna/max is an execution
45
- // default; the launcher resolves an omitted Luna effort back to max.
46
- export const persistedPreset = (preset: AiPreset): AiPreset => {
47
- if (!['codex', 'codex-gui'].includes(preset.cli) || preset.model !== 'gpt-5.6-luna' || preset.effort !== 'max') return preset
48
- const { effort: _effort, ...rollbackReadable } = preset
49
- return rollbackReadable
50
- }
51
-
52
- // Conversation defaults are independent of durable work and explicit saved choices.
53
- export const chatPreset = (cli: string): AiPreset => {
54
- const preset = initialPreset(cli)
55
- return ['codex', 'codex-gui'].includes(preset.cli)
56
- ? { ...preset, id: 'chat-default', name: 'Responsive chat', model: CODEX_CHAT_MODEL, effort: CHAT_EFFORT }
57
- : preset
33
+ return {id:'initial', name:`${resolveExecutor(key).name} · current setup`, cli:key,...(key==='opencode' && process.env.OPENCODE_MODEL ? {model:process.env.OPENCODE_MODEL} : {})}
58
34
  }
35
+ export const persistedPreset = (preset: AiPreset): AiPreset => preset
36
+ export const chatPreset = (cli: string): AiPreset => ({...initialPreset(cli),id:'chat-default',name:'Current engine'})
59
37
 
60
38
  export const installed = async (cli: string): Promise<boolean> => {
61
39
  if (cli === 'codex-gui') return Boolean(await desktopCodexPath())
@@ -0,0 +1,308 @@
1
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
2
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
3
+ import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { ControlStore, sessionTitle, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
6
+ import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
7
+ import { ownsRun } from './identity.js'
8
+ import { applicationId, validApplicationOrigin } from './application-origin.js'
9
+ import { isPreset, type AiPreset, type ModelChoice } from './ai.js'
10
+ import { assertEffort } from './model-policy.js'
11
+
12
+ const hash = (value: string) => createHash('sha256').update(value).digest('hex')
13
+ export const applicationScope = (bindingId: string, scope: string) => hash(JSON.stringify([bindingId, scope]))
14
+ type Binding = { id: string; bindingId: string; tokenHash: string; owner: Owner; shareTelegram?: boolean }
15
+
16
+ export function validateApplicationRegistration(id: string, token: string | null): void {
17
+ if (!applicationId(id) || (token !== null && !/^[A-Za-z0-9_-]{43,200}$/.test(token))) throw new Error('Use a simple application ID and a random token of at least 256 bits encoded as base64url')
18
+ }
19
+
20
+ export class ApplicationBindings {
21
+ constructor(private controlDir: string) {}
22
+ async list(): Promise<Binding[]> {
23
+ try {
24
+ const bindings = JSON.parse(await readFile(join(this.controlDir, 'application-bindings.json'), 'utf8'))
25
+ if (!Array.isArray(bindings) || bindings.some(binding => !applicationId(binding?.id) || !/^[a-f0-9-]{36}$/.test(binding.bindingId) || !/^[a-f0-9]{64}$/.test(binding.tokenHash) || !validOwner(binding.owner) || (binding.shareTelegram !== undefined && typeof binding.shareTelegram !== 'boolean')) || ['tokenHash','id','bindingId'].some(key => new Set(bindings.map(b => b[key])).size !== bindings.length)) throw new Error('Invalid application binding state')
26
+ return bindings
27
+ } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
28
+ }
29
+ async register(id: string, token: string | null, owner: Owner, shareTelegram = false, rotate = false): Promise<Binding | undefined> {
30
+ validateApplicationRegistration(id, token)
31
+ await mkdir(this.controlDir, { recursive: true, mode: 0o700 })
32
+ const file = join(this.controlDir, 'application-bindings.json')
33
+ const lock = await open(`${file}.lock`, 'wx', 0o600)
34
+ try {
35
+ const bindings = await this.list()
36
+ const existing = bindings.find(binding => binding.id === id)
37
+ if (!sameOwner(owner, (await new ControlStore(this.controlDir, 900000).status()).owner)) throw new Error('Application authority revoked')
38
+ if (rotate && (!existing || !token || !sameOwner(existing.owner, owner))) throw new Error('Rotation requires a current binding and new token')
39
+ if (token && bindings.some(item => item.id !== id && item.tokenHash === hash(token))) throw new Error('Application credential already registered')
40
+ if (token !== null && existing && !rotate) throw new Error('Application already registered; rotate its token or revoke before replacing its authority')
41
+ const binding = token === null ? undefined : rotate ? {...existing!, tokenHash: hash(token)} : { id, bindingId: randomUUID(), tokenHash: hash(token), owner, ...(shareTelegram ? { shareTelegram: true } : {}) }
42
+ const next = [...bindings.filter(item => item.id !== id), ...(binding ? [binding] : [])]
43
+ const temporary = `${file}.${randomUUID()}.tmp`
44
+ await writeFile(temporary, JSON.stringify(next), { mode: 0o600 })
45
+ await rename(temporary, file)
46
+ return binding
47
+ } finally { await lock.close(); await unlink(`${file}.lock`) }
48
+ }
49
+ async authenticate(token: string): Promise<Binding> {
50
+ if (!/^[A-Za-z0-9_-]{43,200}$/.test(token)) throw new Error('Unauthorized application')
51
+ const fingerprint = Buffer.from(hash(token), 'hex')
52
+ const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
53
+ const binding = (await this.list()).find(item => timingSafeEqual(fingerprint, Buffer.from(item.tokenHash, 'hex')) && sameOwner(item.owner, owner))
54
+ if (!binding) throw new Error('Unauthorized application')
55
+ return binding
56
+ }
57
+ async authorize(run: RunRecord): Promise<void> {
58
+ const origin = run.application ?? run.delivery
59
+ if (!origin || !validApplicationOrigin({...origin, requestId: run.application?.requestId ?? run.id})) throw new Error('Invalid application origin')
60
+ const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
61
+ const binding = (await this.list()).find(item => item.bindingId === origin.bindingId)
62
+ if (!binding || !sameOwner(binding.owner, owner) || !ownsRun(owner, run)) throw new Error('Application authority revoked')
63
+ }
64
+ }
65
+
66
+ export class ApplicationChannel {
67
+ private server?: Server
68
+ private admissions = Promise.resolve()
69
+ readonly bindings: ApplicationBindings
70
+ constructor(private options: {
71
+ controlDir: string; initial: AiPreset
72
+ wake: () => void
73
+ cancel: (id: string) => Promise<void>
74
+ aiControls?: {
75
+ catalog: () => Promise<ModelChoice[]>
76
+ select: (preset: AiPreset, expectedSession: string | null, guard?: ControlGuard) => Promise<unknown>
77
+ validate: (preset: AiPreset) => Promise<void>
78
+ saveSelection: (model: ModelChoice, effort?: string, guard?: ControlGuard) => Promise<AiPreset>
79
+ }
80
+ }) { this.bindings = new ApplicationBindings(options.controlDir) }
81
+ private get runs() { return new RunStore(this.options.controlDir) }
82
+ private async sharedBinding(bindingId: string, shared = true) {
83
+ const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
84
+ const state = await new ControlStore(this.options.controlDir, 900000).status()
85
+ if (!binding || (shared && !binding.shareTelegram) || !sameOwner(binding.owner, state.owner)) throw new Error('Application authority does not permit controls')
86
+ return binding.owner
87
+ }
88
+ async controls(bindingId: string) {
89
+ await this.sharedBinding(bindingId)
90
+ if (!this.options.aiControls) throw new Error('Application controls unavailable')
91
+ const models = await this.options.aiControls.catalog()
92
+ await this.sharedBinding(bindingId)
93
+ const control = new ControlStore(this.options.controlDir, 900000)
94
+ const state = await control.status()
95
+ const ai = state.ai ?? {presets:[this.options.initial],defaultId:this.options.initial.id,selectedId:this.options.initial.id,recentIds:[]}
96
+ const sessions = (await control.listSessions()).filter(item => !item.applicationScope || item.telegramShared)
97
+ await this.sharedBinding(bindingId)
98
+ return { ai, models, activeSessionId: state.activeSession?.sessionId ?? null,
99
+ sessions: sessions.map(item => ({id:item.sessionId, title:sessionTitle(item), cli:item.cli, archived:!!item.archived})) }
100
+ }
101
+ async changeControls(bindingId: string, input: unknown) {
102
+ const owner = await this.sharedBinding(bindingId)
103
+ const controls = this.options.aiControls
104
+ if (!controls) throw new Error('Application controls unavailable')
105
+ const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; model?: unknown; effort?: unknown}
106
+ if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
107
+ !(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
108
+ const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
109
+ if (Object.keys(value).some(key => !['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
110
+ const control = new ControlStore(this.options.controlDir, 900000)
111
+ const expected = value.expectedSession as string | null
112
+ const guard: ControlGuard = {owner, expectedSession:expected, authorize:async()=>{
113
+ // Runs inside the control lock. Binding storage has its own independent
114
+ // lock; do not call sharedBinding here because it also reads ControlStore.
115
+ const live = (await this.bindings.list()).find(item=>item.bindingId===bindingId)
116
+ if (!live?.shareTelegram || !sameOwner(owner, live.owner)) throw new Error('Application authority revoked')
117
+ }}
118
+ if (value.action === 'new') await control.resetSession(expected, guard)
119
+ if (value.action === 'switch') {
120
+ if (typeof value.sessionId !== 'string') throw new Error('Invalid application conversation')
121
+ // switchSession enforces the same visibility/engine restrictions as /chats.
122
+ await control.switchSession(value.sessionId, expected, guard)
123
+ }
124
+ if (value.action === 'select') {
125
+ const preset = (await control.status()).ai?.presets.find(item => item.id === value.presetId)
126
+ if (!preset) throw new Error('Invalid application preset')
127
+ await this.sharedBinding(bindingId)
128
+ await controls.select(preset, expected, guard)
129
+ }
130
+ if (value.action === 'model') {
131
+ const model = (await controls.catalog()).find(item => item.cli === value.cli && item.model === value.model)
132
+ if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
133
+ await this.sharedBinding(bindingId)
134
+ const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
135
+ await this.sharedBinding(bindingId)
136
+ await controls.select(preset, expected, guard)
137
+ }
138
+ return this.controls(bindingId)
139
+ }
140
+ async scopeControls(bindingId: string, scope: string) {
141
+ if (!applicationId(scope)) throw new Error('Invalid application scope')
142
+ await this.sharedBinding(bindingId, false)
143
+ if (!this.options.aiControls) throw new Error('Application controls unavailable')
144
+ const models = await this.options.aiControls.catalog()
145
+ const control = new ControlStore(this.options.controlDir, 900000)
146
+ const session = await control.applicationSession(applicationScope(bindingId, scope))
147
+ if (session?.telegramShared) throw new Error('Application scope is shared; use shared controls')
148
+ const state = await control.status()
149
+ const ai = state.ai ?? {presets:[this.options.initial],defaultId:this.options.initial.id,selectedId:this.options.initial.id}
150
+ await this.sharedBinding(bindingId, false)
151
+ return {ai:{...ai, selectedId:session?.preset?.id ?? ai.selectedId,
152
+ presets:session?.preset ? [...ai.presets.filter(item=>item.id!==session.preset!.id),session.preset] : ai.presets},
153
+ models, activeSessionId:session?.sessionId ?? null, sessions:session ? [{id:session.sessionId,title:sessionTitle(session),cli:session.cli,archived:false}] : []}
154
+ }
155
+ async changeScopeControls(bindingId: string, scope: string, input: unknown) {
156
+ if (!applicationId(scope)) throw new Error('Invalid application scope')
157
+ const owner = await this.sharedBinding(bindingId, false)
158
+ const controls = this.options.aiControls
159
+ if (!controls) throw new Error('Application controls unavailable')
160
+ const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; model?:unknown; effort?:unknown}
161
+ if (!value || !['new','select','model'].includes(String(value.action)) ||
162
+ !(value.expectedSession===null || (typeof value.expectedSession==='string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
163
+ const fields = {new:[],select:['presetId'],model:['cli','model','effort']}[value.action as 'new'|'select'|'model']!
164
+ if (Object.keys(value).some(key=>!['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
165
+ const hashedScope = applicationScope(bindingId,scope)
166
+ const guard:ControlGuard = {owner,applicationScope:hashedScope,expectedSession:value.expectedSession as string|null,
167
+ authorize:async()=>{
168
+ const binding = (await this.bindings.list()).find(item=>item.bindingId===bindingId)
169
+ if (!binding || !sameOwner(binding.owner,owner)) throw new Error('Application authority revoked')
170
+ }}
171
+ const control = new ControlStore(this.options.controlDir,900000)
172
+ let preset:AiPreset|undefined
173
+ if (value.action==='select') {
174
+ const session = await control.applicationSession(hashedScope)
175
+ preset = session?.preset && session.preset.id===value.presetId ? session.preset : (await control.status()).ai?.presets.find(item=>item.id===value.presetId)
176
+ if (!preset) throw new Error('Invalid application preset')
177
+ await controls.validate(preset)
178
+ }
179
+ if (value.action==='model') {
180
+ const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.model===value.model)
181
+ if (!model || model.cli==='agy' || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
182
+ preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
183
+ }
184
+ await control.changeApplicationSession(hashedScope,guard,preset)
185
+ return this.scopeControls(bindingId,scope)
186
+ }
187
+ async submit(bindingId: string, input: unknown): Promise<RunRecord> {
188
+ const work = this.admissions.then(async () => {
189
+ // Channel-neutral name for the existing shared owner conversation option.
190
+ if (input && typeof input === 'object' && 'followOwner' in input) {
191
+ const {followOwner, ...rest} = input as Record<string, unknown>
192
+ if ('followTelegram' in rest) throw new Error('Invalid application request: choose one conversation option')
193
+ input = {...rest, followTelegram: followOwner}
194
+ }
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')
197
+ 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
+ if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
199
+ if (value.followTelegram !== undefined && typeof value.followTelegram !== 'boolean') throw new Error('Invalid application Telegram following')
200
+ if (value.followTelegram && (value.activateTelegram || value.ai !== undefined || value.expectedNativeSessionId !== undefined)) throw new Error('Invalid application request: following Telegram uses its current conversation and AI')
201
+ let requestedPreset: AiPreset | undefined
202
+ if (value.ai !== undefined) {
203
+ const candidate = { ...value.ai, id: 'application', name: 'Application selection' }
204
+ if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
205
+ assertEffort(candidate.effort, candidate.model, candidate.cli)
206
+ requestedPreset = candidate
207
+ }
208
+ const application = { bindingId, requestId: value.requestId, scope: value.scope, ...(value.followTelegram ? { followTelegram: true } : {}), ...(value.context === undefined ? {} : { context: value.context }) }
209
+ if (!validApplicationOrigin(application)) throw new Error('Invalid application context')
210
+ const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
211
+ if (!binding) throw new Error('Application authority revoked')
212
+ if (value.followTelegram && !binding.shareTelegram) throw new Error('Application authority does not permit following Telegram')
213
+ const id = `r_app_${hash(JSON.stringify([bindingId, value.requestId]))}`
214
+ const existing = await this.runs.get(id)
215
+ if (existing) {
216
+ await this.bindings.authorize(existing)
217
+ 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')
219
+ return existing // Retried context never replaces already admitted capabilities.
220
+ }
221
+ const control = new ControlStore(this.options.controlDir, 900000)
222
+ if (!sameOwner(binding.owner, (await control.status()).owner)) throw new Error('Application authority revoked')
223
+ 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
+ const run = await this.runs.create({ id, ownerId: ownerId(binding.owner), ownerEpoch: ownerEpoch(binding.owner), texts: [value.text], execution, application })
225
+ this.options.wake()
226
+ return run
227
+ })
228
+ this.admissions = work.then(() => {}, () => {})
229
+ return work
230
+ }
231
+ async snapshot(bindingId: string, id: string) {
232
+ const run = await this.runs.get(id)
233
+ if (!run || (run.application ?? run.delivery)?.bindingId !== bindingId) throw new Error('Unknown application run')
234
+ await this.bindings.authorize(run)
235
+ const state = await new ControlStore(this.options.controlDir, 900000).status()
236
+ 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' } : {}) }
238
+ }
239
+ async deliver(run: RunRecord, item: OutboxItem): Promise<void> {
240
+ await this.bindings.authorize(run)
241
+ if (item.type && item.type !== 'message') throw new Error('Application channel supports text messages only')
242
+ await this.runs.markOutboxSent(item.id)
243
+ }
244
+ private async route(request: IncomingMessage, response: ServerResponse) {
245
+ const send = (status: number, body: unknown) => { response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); response.end(JSON.stringify(body)) }
246
+ let binding: Binding
247
+ try { binding = await this.bindings.authenticate(request.headers.authorization?.match(/^Bearer (.+)$/)?.[1] ?? '') }
248
+ catch { send(401, { error: 'Unauthorized application' }); return }
249
+ let admissionId: string | undefined
250
+ try {
251
+ const url = new URL(request.url ?? '/', 'http://localhost'), path = url.pathname
252
+ if (request.method === 'GET' && path === '/v1/registration') {
253
+ send(200, {ownerId: ownerId(binding.owner), bindingId: binding.bindingId, channel: binding.id}); return
254
+ }
255
+ if (request.method === 'GET' && path === '/v1/runs') {
256
+ const all = (await this.runs.list()).filter(run => (run.application ?? run.delivery)?.bindingId === binding.bindingId)
257
+ const before = url.searchParams.get('before')
258
+ const end = before ? all.findIndex(run => run.id === before) : all.length
259
+ if (end < 0) throw new Error('Invalid application inbox cursor')
260
+ const records = all.slice(Math.max(0,end-100),end)
261
+ send(200, {runs: await Promise.all(records.map(run => this.snapshot(binding.bindingId, run.id))), nextCursor:end>100?records[0].id:null}); return
262
+ }
263
+ if (path==='/v1/scope-control' && ['GET','POST'].includes(request.method ?? '')) {
264
+ const scope = url.searchParams.get('scope')
265
+ if (!applicationId(scope) || [...url.searchParams.keys()].length!==1) throw new Error('Invalid application scope')
266
+ if (request.method==='GET') {send(200,await this.scopeControls(binding.bindingId,scope!));return}
267
+ const chunks:Buffer[]=[];let size=0
268
+ for await (const chunk of request) {size+=chunk.length;if(size>65536)throw new Error('Invalid application control size');chunks.push(chunk)}
269
+ send(200,await this.changeScopeControls(binding.bindingId,scope!,JSON.parse(Buffer.concat(chunks).toString('utf8'))));return
270
+ }
271
+ if (path === '/v1/control' && ['GET','POST'].includes(request.method ?? '')) {
272
+ if (!binding.shareTelegram) { send(403, {error:'Application authority does not permit shared controls'}); return }
273
+ if (request.method === 'GET') { send(200, await this.controls(binding.bindingId)); return }
274
+ const chunks: Buffer[] = []; let size = 0
275
+ for await (const chunk of request) { size += chunk.length; if (size > 65536) throw new Error('Invalid application control size'); chunks.push(chunk) }
276
+ send(200, await this.changeControls(binding.bindingId, JSON.parse(Buffer.concat(chunks).toString('utf8')))); return
277
+ }
278
+ if (request.method === 'POST' && path === '/v1/runs') {
279
+ const chunks: Buffer[] = []; let size = 0
280
+ for await (const chunk of request) { size += chunk.length; if (size > 65536) throw new Error('Application request too large'); chunks.push(chunk) }
281
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
282
+ if (applicationId(input?.requestId)) admissionId = `r_app_${hash(JSON.stringify([binding.bindingId, input.requestId]))}`
283
+ const run = await this.submit(binding.bindingId, input)
284
+ send(202, await this.snapshot(binding.bindingId, run.id)); return
285
+ }
286
+ const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
287
+ if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
288
+ await this.snapshot(binding.bindingId, match[1])
289
+ if (match[2]) await this.options.cancel(match[1])
290
+ send(200, await this.snapshot(binding.bindingId, match[1])); return
291
+ }
292
+ send(404, { error: 'Unknown application endpoint' })
293
+ } catch (error) {
294
+ const message = error instanceof Error ? error.message : 'Invalid application request'
295
+ let admission: { admitted: boolean; runId?: string } | undefined
296
+ if (admissionId) {
297
+ try { const existing = await this.runs.get(admissionId); admission = { admitted: !!existing, ...(existing ? { runId: existing.id } : {}) } } catch { /* Unreadable state is not proof of non-admission. */ }
298
+ }
299
+ send(message.includes('conflicts') ? 409 : message === 'Unknown application run' ? 404 : 400, { ...admission, error: /^(Invalid application|Application request|Application authority|Unknown application)/.test(message) ? message : 'Invalid application request' })
300
+ }
301
+ }
302
+ async listen(port: number, host = '127.0.0.1') {
303
+ this.server = createServer((request, response) => { void this.route(request, response) })
304
+ await new Promise<void>((resolve, reject) => { this.server!.once('error', reject); this.server!.listen(port, host, resolve) })
305
+ return this.server.address()
306
+ }
307
+ async stop() { await new Promise<void>((resolve, reject) => this.server ? this.server.close(error => error ? reject(error) : resolve()) : resolve()) }
308
+ }
@@ -0,0 +1,41 @@
1
+ import { parseArgs } from 'node:util'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { loadControlConfig } from './config.js'
4
+ import { ControlStore, sameOwner, ownerId } from './control-state.js'
5
+ import { ApplicationBindings, applicationScope, validateApplicationRegistration } from './application-channel.js'
6
+ import { initialPreset } from './ai.js'
7
+ import { applicationId } from './application-origin.js'
8
+
9
+ async function main() {
10
+ const { values } = parseArgs({ options: {
11
+ owner: {type:'string'}, id: {type:'string'}, 'token-file': {type:'string'}, revoke: {type:'boolean'}, list: {type:'boolean'}, help: {type:'boolean'},
12
+ 'owner-id': {type:'string'}, rotate: {type:'boolean'}, 'share-owner': {type:'boolean'}, 'share-active': {type:'boolean'},
13
+ 'import-scope': {type:'string'}, 'native-session': {type:'string'}, cli: {type:'string'}, 'share-telegram': {type:'boolean'},
14
+ } })
15
+ if (values.help) { console.log('ezenciel-agents-application --id CHANNEL --token-file PRIVATE_FILE [--owner-id VERIFIED_OWNER_ID] [--share-owner] [--rotate] | --id CHANNEL --revoke | --list | --id CHANNEL --import-scope SCOPE --native-session ID --cli CLI'); return }
16
+ if (process.env.EZ_RUN_ID) throw new Error('Application authority is configured by the installing administrator outside agent turns')
17
+ const config = loadControlConfig(), control = new ControlStore(config.controlDir, config.pairingTtlMs)
18
+ if (values.owner && values['owner-id']) throw new Error('Choose one owner registration form')
19
+ if (values['owner-id'] && (!values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner-id requires channel registration')
20
+ if (values.owner && (process.env.EZ_TELEGRAM_ENABLED !== 'false' || !values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner bootstrap requires explicit application-only registration')
21
+ const registrationToken = values['token-file'] ? (await readFile(values['token-file'], 'utf8')).trim() : null
22
+ if (values.owner || values['owner-id']) validateApplicationRegistration(values.id!, registrationToken)
23
+ const owner = values['owner-id'] ? await control.registerOwner(values['owner-id']) : values.owner ? await control.bootstrapApplicationOwner(Number(values.owner)) : (await control.status()).owner
24
+ if (!owner) throw new Error('Register an owner with --owner-id or pair a Telegram owner')
25
+ const bindings = new ApplicationBindings(config.controlDir)
26
+ if (values.list) { console.log(JSON.stringify((await bindings.list()).map(({id,bindingId}) => ({id,bindingId})))); return }
27
+ if (!values.id || !applicationId(values.id)) throw new Error('Application ID required')
28
+ if (values['import-scope']) {
29
+ const binding = (await bindings.list()).find(item => item.id === values.id)
30
+ if (!binding || !sameOwner(binding.owner, owner) || !applicationId(values['import-scope']) || !values['native-session'] || !/^[a-zA-Z0-9_-]{1,160}$/.test(values['native-session']) || !values.cli) throw new Error('Current application binding, scope, native session and CLI required')
31
+ if (values['share-owner'] && !binding.shareTelegram) throw new Error('This channel is not registered for shared owner chat')
32
+ const choice = await control.captureApplicationChoice(initialPreset(values.cli), applicationScope(binding.bindingId, values['import-scope']), values['share-owner'] === true)
33
+ if (choice.preset.cli !== values.cli) throw new Error('Application scope already uses a different engine')
34
+ await control.saveNativeSession(choice.sessionId, values['native-session'])
35
+ console.log(JSON.stringify({ok:true,scope:values['import-scope'],sessionId:choice.sessionId})); return
36
+ }
37
+ if (!!values['token-file'] === !!values.revoke) throw new Error('Provide --token-file or --revoke')
38
+ const binding = await bindings.register(values.id, values.revoke ? null : registrationToken, owner, values['share-owner'] || values['share-active'] || values['share-telegram'], values.rotate)
39
+ console.log(JSON.stringify({ok:true,ownerId:ownerId(owner),id:values.id,bindingId:binding?.bindingId,revoked:Boolean(values.revoke)}))
40
+ }
41
+ main().catch(error => { console.error(error.message); process.exitCode = 1 })
@@ -0,0 +1,87 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { isAbsolute } from 'node:path'
3
+ import { setTimeout as delay } from 'node:timers/promises'
4
+
5
+ // Backend-only: identity must already have been resolved by the application.
6
+ // Each entry names a separately isolated ordinary Ez deployment, not a session.
7
+ export async function applicationBinding(file, principalId) {
8
+ if (typeof principalId !== 'string' || !principalId) throw new Error('Application principal is required')
9
+ const registry = JSON.parse(await readFile(file, 'utf8'))
10
+ if (registry?.version !== 1 || !Array.isArray(registry.bindings)) throw new Error('Invalid application binding registry')
11
+ const principals = new Set(), endpoints = new Set()
12
+ for (const entry of registry.bindings) {
13
+ if (typeof entry.principalId !== 'string' || !entry.principalId || principals.has(entry.principalId) ||
14
+ typeof entry.tokenFile !== 'string' || !isAbsolute(entry.tokenFile) ||
15
+ (entry.revoked !== undefined && typeof entry.revoked !== 'boolean')) throw new Error('Invalid application binding registry')
16
+ const endpoint = applicationEndpoint(entry.url).origin
17
+ if (endpoints.has(endpoint)) throw new Error('Independent principals require separate Ez endpoints')
18
+ principals.add(entry.principalId); endpoints.add(endpoint)
19
+ }
20
+ const entry = registry.bindings.find(item => item.principalId === principalId && !item.revoked)
21
+ if (!entry) throw new Error('No authorized Ez binding for this principal')
22
+ const token = (await readFile(entry.tokenFile, 'utf8')).trim()
23
+ if (!token) throw new Error('Ez application credential is missing')
24
+ return { url: entry.url, token }
25
+ }
26
+
27
+ function applicationEndpoint(value) {
28
+ const endpoint = new URL(value)
29
+ if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password ||
30
+ endpoint.pathname !== '/' || endpoint.search || endpoint.hash) throw new Error('Invalid Ez application endpoint')
31
+ return endpoint
32
+ }
33
+
34
+ export async function applicationCall(path, body, { url, token, fetchImpl = fetch, signal } = {}) {
35
+ if (!token) throw new Error('Ez application credential is missing')
36
+ const scopeParams = path.startsWith('/v1/scope-control?') ? new URLSearchParams(path.slice('/v1/scope-control?'.length)) : null
37
+ const scoped = scopeParams && [...scopeParams.keys()].length===1 && /^[a-zA-Z0-9_:.\-]{1,200}$/.test(scopeParams.get('scope') ?? '')
38
+ if (!scoped && !/^\/v1\/(?:registration|control|runs(?:\?before=r_(?:app|schedule)_[a-f0-9]{64}|\/r_(?:app|schedule)_[a-f0-9]{64}(?:\/cancel)?)?)$/.test(path)) throw new Error('Invalid Ez application operation')
39
+ let response
40
+ const retrySafe = body === undefined || (!scoped && path !== '/v1/control')
41
+ try {
42
+ response = await fetchImpl(new URL(path, applicationEndpoint(url)), {
43
+ method: body === undefined ? 'GET' : 'POST',
44
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
45
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
46
+ redirect: 'error', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
47
+ })
48
+ } catch (cause) {
49
+ throw Object.assign(new Error('Ez application transport unavailable', { cause }), { retryable: retrySafe && !signal?.aborted })
50
+ }
51
+ if (!response.ok) {
52
+ const body = await response.json().catch(() => null)
53
+ throw Object.assign(new Error(`Ez application returned HTTP ${response.status}`), {
54
+ retryable: retrySafe && (response.status >= 500 || [408, 429].includes(response.status)), status: response.status,
55
+ ...(typeof body?.admitted === 'boolean' ? { admitted: body.admitted } : {}),
56
+ ...(typeof body?.runId === 'string' ? { runId: body.runId } : {}),
57
+ })
58
+ }
59
+ try { return await response.json() }
60
+ catch (cause) { throw Object.assign(new Error('Ez application response unavailable or invalid', { cause }), { retryable: retrySafe }) }
61
+ }
62
+
63
+ // Reconnect with the same requestId after transport failure. This client never
64
+ // creates another job, rotates its authority, or retries a domain mutation.
65
+ export async function runApplication(input, { pollMs = 1000, onAdmitted, signal, reconnect = false, ...connection } = {}) {
66
+ const call = async (path, body) => {
67
+ for (;;) {
68
+ try { return await applicationCall(path, body, { ...connection, signal }) }
69
+ catch (error) {
70
+ if (!reconnect || !error.retryable || signal?.aborted) throw error
71
+ await delay(pollMs, undefined, { signal })
72
+ }
73
+ }
74
+ }
75
+ let run = await call('/v1/runs', input)
76
+ await onAdmitted?.(run.id)
77
+ while (run.status === 'queued' || run.status === 'running') {
78
+ await delay(pollMs, undefined, { signal })
79
+ run = await call(`/v1/runs/${encodeURIComponent(run.id)}`)
80
+ }
81
+ if (run.status !== 'completed') throw Object.assign(new Error(run.error || `Ez application run ${run.status}`), {
82
+ terminal: ['failed', 'cancelled'].includes(run.status),
83
+ })
84
+ const reply = run.messages?.filter(message => typeof message.text === 'string' && message.text.trim()).at(-1)?.text
85
+ if (!reply) throw Object.assign(new Error('Ez application completed without a reply'), { terminal: true })
86
+ return { ...run, reply }
87
+ }
@@ -0,0 +1,15 @@
1
+ export type ApplicationOrigin = {
2
+ bindingId: string
3
+ scope: string
4
+ requestId: string
5
+ followTelegram?: boolean
6
+ context?: Record<string, unknown>
7
+ }
8
+ export const applicationId = (value: unknown): value is string =>
9
+ typeof value === 'string' && /^[a-zA-Z0-9_:.\-]{1,200}$/.test(value)
10
+ export const validApplicationOrigin = (value: unknown): value is ApplicationOrigin => {
11
+ const origin = value as ApplicationOrigin | undefined
12
+ return !!origin && /^[a-f0-9-]{36}$/.test(origin.bindingId) && applicationId(origin.scope) && applicationId(origin.requestId) &&
13
+ (origin.followTelegram === undefined || typeof origin.followTelegram === 'boolean') &&
14
+ (origin.context === undefined || (!!origin.context && typeof origin.context === 'object' && !Array.isArray(origin.context) && JSON.stringify(origin.context).length <= 48000))
15
+ }
@@ -5,16 +5,17 @@ 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;goal:boolean}
8
+ type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?: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;
12
12
  // this transport never generates a continuation prompt or an Ez goal record.
13
13
  export async function runCodexSession(options:Options, io:{launch?:()=>ChildProcess;emit?:(line:string)=>void}={}):Promise<number> {
14
14
  options = executionDefaults('codex', options)
15
+ if (options.codexSandbox !== undefined && options.codexSandbox !== 'external') throw new Error('Invalid Codex sandbox selection')
15
16
  const child=io.launch?.() ?? spawn('codex',['app-server','--stdio','--disable','memories','--enable','skip_host_skill_discovery'],{cwd:options.workspace,env:process.env,stdio:['pipe','pipe','pipe']})
16
17
  const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
17
- let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=options.goal
18
+ let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false
18
19
  let resolveDone!:(code:number)=>void
19
20
  const done=new Promise<number>(resolve=>{resolveDone=resolve})
20
21
  const pending=new Map<number,{resolve:(value:any)=>void;reject:(error:Error)=>void;timer:ReturnType<typeof setTimeout>}>()
@@ -70,19 +71,15 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
70
71
  await request('initialize',{clientInfo:{name:'ezenciel-agents',version:'1'},capabilities:{experimentalApi:true}})
71
72
  send({method:'initialized',params:{}})
72
73
  const result=await request('thread/start',{
73
- cwd:options.workspace,approvalPolicy:'never',sandbox:'workspace-write',model:options.model,
74
- config:{'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[])],
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
76
  'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})},
76
77
  })
77
78
  threadId=result.thread?.id
78
79
  if(!threadId)throw new Error('Codex did not return a native thread ID')
79
80
  emit(JSON.stringify({type:'thread.started',thread_id:threadId}))
80
- if(options.goal){
81
- // This is the native request used by the interactive /goal command.
82
- // Setting it active starts work in Codex; do not also send turn/start.
83
- const result=await request('thread/goal/set',{threadId,objective:options.prompt,status:'active'})
84
- if(result.goal)settled(result.goal)
85
- }else await request('turn/start',{threadId,input:[{type:'text',text:options.prompt}],model:options.model,effort:options.effort})
81
+ await request('turn/start',{threadId,input:[{type:'text',text:options.prompt}],model:options.model,effort:options.effort,
82
+ ...(options.codexSandbox === 'external' ? {sandboxPolicy:{type:'externalSandbox',networkAccess:'enabled'}} : {})})
86
83
  return await done
87
84
  }catch(error){fail(error);return 1}
88
85
  finally{