@jc_stack/ez-agents 0.1.0-beta.42 → 0.1.0-beta.44

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 (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/Dockerfile +3 -1
  3. package/bin/ez +3 -1
  4. package/compose.yaml +2 -0
  5. package/docker/entrypoint.sh +4 -1
  6. package/docker/smoke.mjs +92 -1
  7. package/docs/application-channel.md +6 -2
  8. package/docs/architecture/ai-selection.md +7 -4
  9. package/docs/architecture/authority-boundaries.md +15 -4
  10. package/docs/docker-runtime.md +25 -2
  11. package/docs/local-qa.md +5 -0
  12. package/docs/plugin-guide.md +10 -2
  13. package/docs/plugins.md +22 -2
  14. package/docs/selective-monitoring.md +26 -12
  15. package/docs/upgrades.md +5 -1
  16. package/package.json +1 -1
  17. package/src/agent-install.ts +5 -3
  18. package/src/ai-cli.ts +13 -4
  19. package/src/ai.ts +7 -3
  20. package/src/audio.ts +4 -2
  21. package/src/client-defaults.ts +7 -4
  22. package/src/control-state.ts +12 -8
  23. package/src/conversation-menu.ts +5 -2
  24. package/src/executor.ts +5 -5
  25. package/src/menu.ts +9 -7
  26. package/src/plugin-broker.mjs +2 -0
  27. package/src/plugins/manager.mjs +43 -9
  28. package/src/plugins/native-tasks.d.mts +1 -0
  29. package/src/plugins/native-tasks.mjs +13 -0
  30. package/src/task-cli.ts +1 -1
  31. package/src/task-executor.ts +7 -4
  32. package/src/tasks.ts +54 -16
  33. package/src/updates/control.mjs +5 -3
  34. package/src/updates/runtime.mjs +27 -7
  35. package/test/agent-install.test.ts +6 -0
  36. package/test/ai-cli.test.ts +6 -1
  37. package/test/ai.test.ts +69 -14
  38. package/test/application-controls.test.ts +2 -1
  39. package/test/conversation-menu.test.ts +2 -0
  40. package/test/conversations.test.ts +26 -0
  41. package/test/plugin-broker.test.mjs +13 -1
  42. package/test/plugin-manager.test.mjs +58 -1
  43. package/test/scheduler-host.test.ts +1 -0
  44. package/test/task-native.test.ts +2 -4
  45. package/test/tasks.test.ts +107 -3
  46. package/test/updates.test.mjs +55 -3
  47. package/test/upgrade-pause.test.ts +10 -2
package/src/ai.ts CHANGED
@@ -5,7 +5,7 @@ import { execFile } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
6
  import { homedir } from 'node:os'
7
7
  import { join, delimiter } from 'node:path'
8
- import { executorEnvironment, executorKey, resolveExecutor } from './executor.js'
8
+ import { executorEnvironment, executorInvocation, executorKey, resolveExecutor } from './executor.js'
9
9
  import { desktopCodexPath } from './desktop-bridge.js'
10
10
 
11
11
  export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
@@ -27,6 +27,8 @@ export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
27
27
  return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
28
28
  }
29
29
  export const presetLabel = (p: AiPreset) => `${p.cli}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
30
+ // OpenCode encodes its provider in the native provider/model identifier.
31
+ export const presetProvider = (p: AiPreset) => p.cli === 'opencode' ? p.model?.split('/')[0] : p.provider
30
32
  // The seed delegates model selection to the native client. Project its resolved
31
33
  // settings for status without pinning future conversations to that snapshot.
32
34
  export const statusPreset = (preset: AiPreset, discovered: AiPreset[]): AiPreset =>
@@ -53,8 +55,10 @@ export const installed = async (cli: string): Promise<boolean> => {
53
55
  // The optional runner injects `opencode models` output in tests; production
54
56
  // spawns the installed CLI with the whitelisted executor environment.
55
57
  export const readOpencodeModels = async (run?: (args: string[]) => Promise<string>, dataHome?: string): Promise<ModelChoice[]> => {
56
- const exec = run ?? (async (args: string[]) =>
57
- (await promisify(execFile)('opencode', args, { env: { ...executorEnvironment(), ...(dataHome ? { XDG_DATA_HOME: dataHome } : {}) }, timeout: 8000, maxBuffer: 4 * 1024 * 1024 })).stdout)
58
+ const exec = run ?? (async (args: string[]) => {
59
+ const invocation = executorInvocation('opencode', args)
60
+ return (await promisify(execFile)(invocation.command, invocation.args, { env: { ...executorEnvironment(), ...(dataHome ? { XDG_DATA_HOME: dataHome } : {}) }, timeout: 8000, maxBuffer: 4 * 1024 * 1024 })).stdout
61
+ })
58
62
  const record = (value: unknown): Record<string, unknown> =>
59
63
  value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
60
64
  try {
package/src/audio.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { readRequest } from './read-request.js'
3
+ import { executorEnvironment, executorInvocation } from './executor.js'
3
4
 
4
5
  export type AudioOptions = {
5
6
  geminiApiKey?: string
@@ -33,7 +34,7 @@ export const pcmToWav = (pcm: Buffer, sampleRate = 24000, channels = 1): Buffer
33
34
 
34
35
  export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Promise<Buffer> => {
35
36
  return new Promise((resolve, reject) => {
36
- const child = spawn(
37
+ const invocation = executorInvocation(
37
38
  'ffmpeg',
38
39
  [
39
40
  '-f',
@@ -52,8 +53,9 @@ export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Pr
52
53
  'ogg',
53
54
  '-',
54
55
  ],
55
- { stdio: ['pipe', 'pipe', 'pipe'] },
56
56
  )
57
+ const child = spawn(invocation.command, invocation.args,
58
+ { env: executorEnvironment(), stdio: ['pipe', 'pipe', 'pipe'] })
57
59
 
58
60
  const output: Buffer[] = []
59
61
  let error = ''
@@ -6,14 +6,16 @@ import { homedir } from 'node:os'
6
6
  import { join } from 'node:path'
7
7
  import { createHash } from 'node:crypto'
8
8
  import { installed, type AiPreset } from './ai.js'
9
- import { executorEnvironment } from './executor.js'
9
+ import { executorEnvironment, executorInvocation } from './executor.js'
10
10
 
11
11
  const record = (v: unknown): Record<string, unknown> =>
12
12
  v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
13
13
  const value = (v: unknown): string | undefined =>
14
14
  typeof v === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(v) ? v : undefined
15
- const command = async (cli: string, args: string[], cwd: string): Promise<string> =>
16
- (await promisify(execFile)(cli, args, { cwd, env: executorEnvironment(), timeout: 8000, maxBuffer: 2 * 1024 * 1024 })).stdout
15
+ const command = async (cli: string, args: string[], cwd: string): Promise<string> => {
16
+ const invocation = executorInvocation(cli, args)
17
+ return (await promisify(execFile)(invocation.command, invocation.args, { cwd, env: executorEnvironment(), timeout: 8000, maxBuffer: 2 * 1024 * 1024 })).stdout
18
+ }
17
19
 
18
20
  export const resolvedCodexDefaults = (configValue: unknown, catalogValue: unknown): Record<string, unknown> => {
19
21
  const config = record(configValue)
@@ -32,7 +34,8 @@ export const resolvedCodexDefaults = (configValue: unknown, catalogValue: unknow
32
34
 
33
35
  // Native config/read resolves Codex's layers; do not reimplement TOML or start a turn.
34
36
  export const codexDefaults = (cwd: string, codexHome?: string, nativeFallback = false): Promise<Record<string, unknown>> => new Promise((resolve) => {
35
- const child = spawn('codex', ['app-server'], { cwd,
37
+ const invocation = executorInvocation('codex', ['app-server'])
38
+ const child = spawn(invocation.command, invocation.args, { cwd,
36
39
  env: { ...executorEnvironment(), ...(codexHome ? { CODEX_HOME: codexHome } : {}) }, stdio: ['pipe', 'pipe', 'ignore'] })
37
40
  const lines = createInterface({ input: child.stdout })
38
41
  let done = false
@@ -2,7 +2,7 @@ import { assertEffort } from './model-policy.js'
2
2
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
3
3
  import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
- import { isPreset, persistedPreset, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
5
+ import { isPreset, persistedPreset, presetProvider, type AiPreset, type ExecutionChoice, type ModelChoice } from './ai.js'
6
6
 
7
7
  export type Owner = {
8
8
  id?: string
@@ -615,7 +615,7 @@ export class ControlStore {
615
615
  // Hot path (every inbound message and every source tick): when the pinned
616
616
  // session already matches the selected preset, return it with zero
617
617
  // control/ writes and without taking the lock. Any divergence (missing
618
- // session, CLI/provider/model change, untitled fresh session) falls
618
+ // session, choice change, untitled fresh session) falls
619
619
  // through to the serializing slow path. A concurrent control edit inside
620
620
  // the microsecond unlocked window fails closed downstream (the execution
621
621
  // session lookup rejects a stale pin; the batch can be retried).
@@ -625,7 +625,7 @@ export class ControlStore {
625
625
  const session = settled.activeSession
626
626
  if (settledPreset && session && (session.cli || session.hasStarted) &&
627
627
  !(session.cli === settledPreset.cli && session.hasStarted && session.preset &&
628
- (session.preset.provider !== settledPreset.provider || session.preset.model !== settledPreset.model)) &&
628
+ presetProvider(session.preset) !== presetProvider(settledPreset)) &&
629
629
  (session.cli !== settledPreset.cli || JSON.stringify(session.preset) === JSON.stringify(settledPreset)) &&
630
630
  Boolean(session.title || session.hasStarted || !title?.trim()))
631
631
  return { sessionId: session.sessionId, preset: settledPreset }
@@ -637,10 +637,10 @@ export class ControlStore {
637
637
  if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
638
638
  if (state.activeSession.cli === preset.cli) {
639
639
  const previous = state.activeSession.preset
640
- // Same CLI, different provider/model: web/Telegram must not resume the old native thread.
641
- // Undefined is the native client default and is a distinct selection.
640
+ // A provider change starts a separate native context. Model and effort
641
+ // changes are native turn settings within the existing conversation.
642
642
  if (state.activeSession.hasStarted && previous &&
643
- (previous.provider !== preset.provider || previous.model !== preset.model)) {
643
+ presetProvider(previous) !== presetProvider(preset)) {
644
644
  ;(state.sessions ??= []).push(state.activeSession)
645
645
  state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset }
646
646
  } else {
@@ -772,13 +772,17 @@ export class ControlStore {
772
772
  assertEffort(preset.effort, preset.model, preset.cli)
773
773
  if ((state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Menu expired. Open Choose AI again.')
774
774
  const current = ai.presets.find((p) => p.id === ai.selectedId)!
775
- if (state.activeSession && (current.cli !== preset.cli || !state.activeSession.cli) && !fresh) return false
775
+ if (state.activeSession && (current.cli !== preset.cli || presetProvider(current) !== presetProvider(preset) || !state.activeSession.cli) && !fresh) return false
776
776
  if (fresh || !state.activeSession) {
777
777
  rememberPreset(state)
778
778
  if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
779
- state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
779
+ state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset: persistedPreset(preset) }
780
780
  }
781
781
  ai.selectedId = id
782
+ // Persist the conversation's choice now, even if the owner switches chats
783
+ // or restarts before sending another message. Queued choices are snapshots.
784
+ if (state.activeSession.cli === preset.cli && (!state.activeSession.preset || presetProvider(state.activeSession.preset) === presetProvider(preset)))
785
+ state.activeSession.preset = persistedPreset(preset)
782
786
  ai.recentIds = [id, ...(ai.recentIds ?? []).filter((recentId) => recentId !== id)].slice(0, 3)
783
787
  await this.writeState(state)
784
788
  return true
@@ -1,5 +1,6 @@
1
1
  import { InlineKeyboard, type Context } from 'grammy'
2
2
  import { ControlStore, sessionTitle } from './control-state.js'
3
+ import { presetLabel } from './ai.js'
3
4
 
4
5
  // IDs identify existing relay bindings only; the engine still owns all context.
5
6
  // Titles come from owner renames; no run text or delivery history is read here.
@@ -61,13 +62,15 @@ export const createConversationMenu = (control: ControlStore) => {
61
62
  } else {
62
63
  const keyboard = new InlineKeyboard().text('Archive this conversation', `chat:archive:${id}`).row()
63
64
  .text('Back to conversations', 'chat:list:0:0')
64
- try { await control.switchSession(id) }
65
+ let selected
66
+ try { selected = await control.switchSession(id) }
65
67
  catch (error) {
66
68
  // Even an older session that cannot resume can still be archived.
67
69
  await render(ctx, `${sessionTitle(session)}\n${error instanceof Error ? error.message : 'Unable to continue.'}`, keyboard)
68
70
  return true
69
71
  }
70
- await render(ctx, `Current conversation: ${sessionTitle(session)}\nSend a message to continue. To rename it, use /rename followed by a name.`, keyboard)
72
+ keyboard.inline_keyboard.unshift([{ text: 'Choose AI', callback_data: 'menu:ai' }])
73
+ await render(ctx, `Current conversation: ${sessionTitle(selected)}\nAI: ${selected.preset ? presetLabel(selected.preset) : 'Not saved; open Choose AI.'}\nSend a message to continue. To rename it, use /rename followed by a name.`, keyboard)
71
74
  }
72
75
  } catch (error) {
73
76
  await ctx.reply(error instanceof Error ? error.message : 'Conversation selection failed.')
package/src/executor.ts CHANGED
@@ -2,7 +2,7 @@ import { executionDefaults } from './model-policy.js'
2
2
  import { startTaskExecutor } from './task-executor.js'
3
3
  import { deliverySocketPath } from './delivery-socket.js'
4
4
  import { mkdtemp, rm, writeFile, mkdir, symlink, readFile } from 'node:fs/promises'
5
- import { accessSync, constants as fsConstants } from 'node:fs'
5
+ import { accessSync, openSync, closeSync, constants as fsConstants } from 'node:fs'
6
6
  import { tmpdir, homedir } from 'node:os'
7
7
  import path from 'node:path'
8
8
  import { spawn, type ChildProcess } from 'node:child_process'
@@ -210,7 +210,7 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
210
210
  // Loaded by path, never by prompt text, so the model cannot miss it.
211
211
  try {
212
212
  const skill = path.join(opts.controlDir, 'cli', 'pi', 'skills', 'ez-delivery', 'SKILL.md')
213
- accessSync(skill, fsConstants.R_OK)
213
+ closeSync(openSync(skill, 'r'))
214
214
  args.push('--skill', path.dirname(skill))
215
215
  } catch { /* runs without the skill exactly as before */ }
216
216
  // Bind Pi to this conversation, not the most recently used session in
@@ -280,7 +280,7 @@ export const resolveExecutor = (name?: string): CliAdapter => EXECUTOR_REGISTRY[
280
280
  // secret.
281
281
  export const opencodeDataHome = (controlDir: string): string | undefined => {
282
282
  try {
283
- accessSync(path.join(controlDir, 'cli', 'opencode', 'auth.json'), fsConstants.R_OK)
283
+ closeSync(openSync(path.join(controlDir, 'cli', 'opencode', 'auth.json'), 'r'))
284
284
  return path.join(controlDir, 'cli')
285
285
  } catch { return undefined }
286
286
  }
@@ -289,7 +289,7 @@ export const opencodeDataHome = (controlDir: string): string | undefined => {
289
289
  // auth.json). Only a path is ever returned, never a secret.
290
290
  export const piAgentDir = (controlDir: string): string | undefined => {
291
291
  try {
292
- accessSync(path.join(controlDir, 'cli', 'pi', 'agent', 'auth.json'), fsConstants.R_OK)
292
+ closeSync(openSync(path.join(controlDir, 'cli', 'pi', 'agent', 'auth.json'), 'r'))
293
293
  return path.join(controlDir, 'cli', 'pi', 'agent')
294
294
  } catch { return undefined }
295
295
  }
@@ -315,7 +315,7 @@ export const opencodeInvocation = (
315
315
  export const executorInvocation = (command: string, args: string[]) => {
316
316
  const uid = process.geteuid?.()
317
317
  return process.platform === 'linux' && uid !== undefined && process.getuid?.() !== uid
318
- ? { command: 'setpriv', args: [`--ruid=${uid}`, `--euid=${uid}`, '--', command, ...args] }
318
+ ? { command: 'setpriv', args: [`--ruid=${uid}`, `--euid=${uid}`, '--no-new-privs', '--', command, ...args] }
319
319
  : { command, args }
320
320
  }
321
321
 
package/src/menu.ts CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path'
4
4
  import { randomBytes } from 'node:crypto'
5
5
  import { InlineKeyboard, type Context } from 'grammy'
6
6
  import { ControlStore, type ControlGuard } from './control-state.js'
7
- import { chatPreset, installed, persistedPreset, presetLabel, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
7
+ import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
8
8
  import { discoverDefaults } from './client-defaults.js'
9
9
 
10
10
  export const mainCommands = [
@@ -64,7 +64,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
64
64
  const state = (await control.status()).ai
65
65
  if (!state) throw new Error('AI settings not initialized')
66
66
  const current = state.presets.find((p) => p.id === state.selectedId)!
67
- const fresh = current.cli !== preset.cli || current.provider !== preset.provider || Boolean(session && !session.cli)
67
+ const fresh = current.cli !== preset.cli || presetProvider(current) !== presetProvider(preset) || Boolean(session && !session.cli)
68
68
  if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
69
69
  return { preset, fresh }
70
70
  }
@@ -72,12 +72,13 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
72
72
  const session = await control.getActiveSession()
73
73
  const { fresh } = await select(preset, session?.sessionId ?? null)
74
74
  await ctx.reply(`${preset.name}\n${presetLabel(preset)}\n${fresh
75
- ? 'CLI changed: fresh conversation. Files kept; queued work unchanged.'
76
- : 'Selected for this conversation. Queued work unchanged.'}`)
75
+ ? 'Client or provider changed: fresh conversation. Files kept; queued work unchanged.'
76
+ : 'Saved for this conversation. History kept; queued work unchanged.'}`)
77
77
  }
78
78
  const list = async (ctx: Context) => {
79
79
  const models = await catalog()
80
80
  const state = await control.aiState(initial)
81
+ const selected = state.presets.find((preset) => preset.id === state.selectedId)!
81
82
  const keyboard = new InlineKeyboard()
82
83
  if (models.length) {
83
84
  const recent = (state.recentIds ?? [])
@@ -99,8 +100,8 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
99
100
  await list(next)
100
101
  })
101
102
  await ctx.reply(models.length
102
- ? 'Choose AI\nUse a recent choice or select an installed client, then choose its model and reasoning level.'
103
- : 'Choose AI\nNo client catalog available. Showing the current client setup only.', { reply_markup: keyboard })
103
+ ? `Choose AI\nCurrent: ${presetLabel(selected)}\nSaved per conversation. Select a client, model and reasoning level; changing client or provider starts a new conversation.`
104
+ : `Choose AI\nCurrent: ${presetLabel(selected)}\nNo client catalog available. Showing the current client setup only.`, { reply_markup: keyboard })
104
105
  }
105
106
  const available = async (ctx: Context, cli: string, page = 0, listed?: ModelChoice[]) => {
106
107
  const models = (listed ?? await catalog()).filter((model) => model.cli === cli)
@@ -129,7 +130,8 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
129
130
  const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.provider === stored.provider && preset.model === stored.model && preset.effort === stored.effort)
130
131
  const preset = existing ?? candidate
131
132
  await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
132
- await control.savePreset(preset, guard)
133
+ // Private scopes persist their selection on the session, not the owner menu.
134
+ if (!guard?.applicationScope) await control.savePreset(preset, guard)
133
135
  return preset
134
136
  }
135
137
  const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
@@ -59,6 +59,7 @@ const validateManagerArgs = async (value, workspace) => {
59
59
  if (!args.length) throw Error('Missing broker management command');
60
60
  const [group, action, name, extra, ...tail] = args;
61
61
  if (group === 'tools' && action === 'list' && (args.length === 2 || args.length === 3 && name === '--details')) return args;
62
+ if (group === 'tools' && action === 'skill' && IDENTIFIER.test(name) && args.length === 4) return args;
62
63
  if (group === 'tools' && action === 'exposure' && args.length === 2) return args;
63
64
  if (group === 'plugins' && action === 'available' && args.length === 2) return args;
64
65
  if (group === 'plugins' && action === 'list' && args.length === 2) return args;
@@ -90,6 +91,7 @@ const validateManagerArgs = async (value, workspace) => {
90
91
  (args.length === 3 || ['stable', 'beta', 'manual'].includes(extra))) return args;
91
92
  if (action === 'prepare' && args.length === 5 && IDENTIFIER.test(name) && extra === '--file' &&
92
93
  typeof tail[0] === 'string' && path.isAbsolute(tail[0])) {
94
+ if (name === 'main') throw Error('Main runtime candidates require operator preparation outside the agent');
93
95
  let resolved;
94
96
  try { resolved = await fs.realpath(tail[0]); } catch { throw Error('Upgrade candidate must be an existing file inside the agent workspace'); }
95
97
  if (!childOf(resolved, workspace)) throw Error('Upgrade candidate must stay inside the agent workspace');
@@ -1,5 +1,6 @@
1
1
  import { sharedService, attachShared } from './shared.mjs';
2
2
  import { exposure, commandExposure } from './exposure.mjs';
3
+ import { installedNativeHelp } from './native-tasks.mjs';
3
4
  import * as fs from 'node:fs/promises';
4
5
  import path from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
@@ -17,7 +18,16 @@ const emit = value => console.log(JSON.stringify(value));
17
18
  const keys = (object, allowed) => { if(!object || typeof object !== 'object' || Array.isArray(object) || Object.keys(object).some(k=>!allowed.includes(k))) throw Error('Invalid or unknown descriptor fields'); };
18
19
  const strings = value => { if(!Array.isArray(value) || value.some(x=>typeof x!=='string' || x.includes('\0'))) throw Error('Expected literal string arguments'); return value; };
19
20
  const containerPath = value => { if(typeof value!=='string' || !value.startsWith('/') || value.includes('..') || /[\0\n\r:$]/.test(value) || value.startsWith('/var/run') || value.startsWith('/proc') || value.startsWith('/sys')) throw Error('Invalid container path'); return value; };
20
- const privateDir = async dir => fs.mkdir(dir,{recursive:true,mode:0o700});
21
+ export async function privateDir(dir) {
22
+ const created=await fs.mkdir(dir,{recursive:true,mode:0o700});
23
+ if(!created)return;
24
+ // A root broker must leave new private directories with the installer's
25
+ // ownership, just as atomic files are. Do not change existing directories.
26
+ let current=created;await stewardOwned(current);
27
+ for(const part of path.relative(created,dir).split(path.sep).filter(Boolean)) {
28
+ current=path.join(current,part);await stewardOwned(current);
29
+ }
30
+ }
21
31
  export async function atomic(file, value) {
22
32
  const tmp = `${file}.${randomUUID()}.tmp`;
23
33
  await fs.writeFile(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600,flag:'wx'});
@@ -280,11 +290,12 @@ export async function hostNetworkBindings(config, record, home) {
280
290
  throw Error('Invalid host network bindings');
281
291
  const route = configured?.[record.manifest?.id];
282
292
  if (route === undefined) return [];
283
- if (process.env.EZ_DOCKER_COMPOSE === 'standalone') {
284
- throw Error('Isolated broker cannot use host network bindings');
285
- }
286
- if (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
287
- realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir))
293
+ const isolated = process.env.EZ_DOCKER_COMPOSE === 'standalone';
294
+ if (isolated && host.isolation !== 'isolated') throw Error('Isolated broker requires an isolated host binding');
295
+ // Like folder grants, network grants use the exact mounted identities. The
296
+ // broker does not receive the host deployment's convenience symlinks.
297
+ if (!isolated && (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
298
+ realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
288
299
  throw Error('Invalid host network binding deployment');
289
300
  keys(route, ['revisions', 'bindings']);
290
301
  const trusted = await snapshot(record.source);
@@ -519,7 +530,14 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
519
530
  await fs.access(target,fs.constants.X_OK);
520
531
  await fs.symlink(target,path.join(bin,name));
521
532
  }
522
- for(const file of await fs.readdir(agent.binDir)) {if(file==='ez') throw Error('Existing ez binding collision');if(Object.hasOwn(manifest.bin,file))continue;await fs.symlink(path.join(agent.binDir,file),path.join(bin,file));}
533
+ for(const file of await fs.readdir(agent.binDir)) {
534
+ if(file==='ez') {
535
+ if(await fs.realpath(path.join(agent.binDir,file))!==await fs.realpath(new URL('../../bin/ez',import.meta.url)))throw Error('Existing ez binding collision');
536
+ continue;
537
+ }
538
+ if(Object.hasOwn(manifest.bin,file))continue;
539
+ await fs.symlink(path.join(agent.binDir,file),path.join(bin,file));
540
+ }
523
541
  agent.binDir=bin;agent.toolsHome=home;await atomic(hostConfig,host);
524
542
  }
525
543
  });
@@ -540,7 +558,6 @@ export async function install(home,config,name,source,revision) {
540
558
  const stage=path.join(base,`.stage-${randomUUID()}`);await privateDir(stage);
541
559
  try {
542
560
  for(const [relative,file] of p.files) {const dest=path.join(stage,relative);await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});await fs.writeFile(dest,file.data,{mode:file.mode,flag:'wx'});}
543
- await fs.chmod(stage,0o755);
544
561
  // A previously interrupted install may leave a snapshot. Never silently replace it.
545
562
  try { await fs.rename(stage,target); } catch(error) {
546
563
  if(!['EEXIST','ENOTEMPTY'].includes(error.code)) throw error;
@@ -583,17 +600,34 @@ export async function main(args) {
583
600
  return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1),{publish:port,serve:true});
584
601
  }
585
602
  if(group==='tools'&&action==='connect')return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1));
586
- if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
603
+ if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|skill <plugin> <declared-path>|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],native:await installedNativeHelp(path.join(home,'bin')),foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
587
604
  if(group==='plugins'&&(!action||args.includes('--help'))) return emit({commands:['available','list','inspect <id>','install <id>','start <id>','stop <id>','status <id>','logs <id>','uninstall <id>','catalog-add <id> --source PATH --revision HASH','export <id> <artifact> --output PATH','folder-bind <id> --service NAME --source PATH --target PATH [--writable]','folder-unbind <id> --service NAME --target PATH','folders <id>','shared-enable <id> <service>','shared-disable <id> <service>','shared-status <id> <service>'],uninstall:'Stops and removes containers/network and unregisters aliases; retains all volumes and secrets. No data deletion flag.',scope:home});
588
605
  if(group==='plugins'||group==='tools') {
589
606
  args=rest;args=args.filter(a=>a!=='--json');
590
607
  if(action==='available'&&group==='plugins') return emit(config.catalog);
591
608
  const r=await registry(home);
609
+ if(group==='tools' && action==='skill') {
610
+ if(args.length!==2)throw Error('Use tools skill <plugin> <declared-path>');
611
+ const [name,skill]=args,record=r.plugins[id(name)];
612
+ if(!record || !record.manifest.skills.includes(skill))throw Error('Unknown installed plugin skill');
613
+ if(path.isAbsolute(skill) || skill.split('/').some(part=>!part || part==='.' || part==='..') || /[\0\r\n]/.test(skill))throw Error('Unsafe skill path');
614
+ const source=await fs.realpath(record.source);
615
+ if(source!==record.source || !childOf(source,home))throw Error('Skill source must stay inside installed packages');
616
+ let file=source;
617
+ for(const part of skill.split('/')) {
618
+ file=path.join(file,part);
619
+ if((await fs.lstat(file)).isSymbolicLink())throw Error('Skill symlinks are not supported');
620
+ }
621
+ const stat=await fs.stat(file);
622
+ if(!stat.isFile() || stat.size>64*1024)throw Error('Skill must be a regular file of at most 64 KiB');
623
+ return emit({plugin:name,revision:record.revision,path:skill,content:await fs.readFile(file,'utf8')});
624
+ }
592
625
  if(action==='list') {
593
626
  if(group==='tools' && args.length===1 && args[0]==='--details')return emit(Object.fromEntries(Object.entries(r.plugins).map(([name,p])=>[name,{
594
627
  description:typeof p.manifest.description==='string'?p.manifest.description.replace(/\s+/g,' ').trim().slice(0,200):'',
595
628
  commands:Object.keys(p.manifest.commands).map(alias=>`ez ${alias} --help`),
596
629
  skills:p.manifest.skills.map(skill=>path.join(p.source,skill)),
630
+ skillReads:p.manifest.skills.map(skill=>['ez','tools','skill',name,skill]),
597
631
  }])));
598
632
  if(args.length)throw Error('Use tools list [--details] or plugins list');
599
633
  return emit(group==='tools'?r.commands:r.plugins);
@@ -1,4 +1,5 @@
1
1
  export function nativeTaskBinding(home:string, environment?:NodeJS.ProcessEnv):Promise<{cwd:string;env:NodeJS.ProcessEnv}>;
2
2
  import type {DeliveryContext} from '../delivery-context.mjs';
3
3
  export function nativeCommands():{command:string;description:string;limitations?:string[]}[];
4
+ export function installedNativeHelp(binDirectory:string):Promise<{description:string;argv:string[]}[]>;
4
5
  export function nativeTasks(home:string,args:string[],options?:{signal?:AbortSignal;command?:string;deliveryContext?:DeliveryContext}):Promise<{code:number;stdout:string;stderr:string}>;
@@ -9,6 +9,19 @@ export const nativeCommands=()=>[
9
9
  {command:'message',description:"Send text, voice or a workspace document to the paired owner's Telegram chat and wait for its Telegram delivery receipt. Read --help.",limitations:['History requires a native run; inline text only.']},
10
10
  ];
11
11
 
12
+ export async function installedNativeHelp(binDirectory) {
13
+ const commands=[];
14
+ for(const {command,description} of nativeCommands()) {
15
+ const executable=path.join(binDirectory,`ezenciel-agents-${command}`);
16
+ try {await fs.access(executable,fs.constants.X_OK);} catch(error) {
17
+ if(['ENOENT','EACCES'].includes(error.code))continue;
18
+ throw error;
19
+ }
20
+ commands.push({description,argv:[executable,'--help']});
21
+ }
22
+ return commands;
23
+ }
24
+
12
25
  export async function nativeTaskBinding(home,environment=process.env) {
13
26
  home=await fs.realpath(home);
14
27
  const config=JSON.parse(await fs.readFile(path.join(home,'config.json'),'utf8'));
package/src/task-cli.ts CHANGED
@@ -6,7 +6,7 @@ const { values, positionals } = parseArgs({ allowPositionals: true, options: {
6
6
  'context-file': { type: 'string' }, 'capability-file': { type: 'string' }, hours: { type: 'string' }, id: { type: 'string' }, help: { type: 'boolean' },
7
7
  'incoming-only': { type: 'boolean' }, 'until-revoked': { type: 'boolean' }, 'any-conversation': { type: 'boolean' },
8
8
  } })
9
- if (values.help) { console.log('ezenciel-agents-task propose --source NAME (--contact EXACT_ID | --any-conversation) --purpose TEXT --context-file FILE [--capability-file FILE] --hours 24 [--incoming-only [--until-revoked]] | list | revoke --id TASK_ID'); console.log(await readFile(new URL('../docs/selective-monitoring.md', import.meta.url), 'utf8')) }
9
+ if (values.help) { console.log('ezenciel-agents-task start|propose --source NAME (--contact EXACT_ID | --any-conversation) --purpose TEXT --context-file FILE [--capability-file FILE] --hours 24 [--incoming-only [--until-revoked]] | list | revoke --id TASK_ID'); console.log(await readFile(new URL('../docs/selective-monitoring.md', import.meta.url), 'utf8')) }
10
10
  else {
11
11
  if (!process.env.EZ_CONTROL_DIR || !process.env.EZ_RUN_ID) throw new Error('Run from the current owner turn')
12
12
  const anyConversation = values['any-conversation'] === true
@@ -7,7 +7,7 @@ import { spawn, execFile } from 'node:child_process'
7
7
  import { promisify } from 'node:util'
8
8
  import { readRun } from './delivery-socket.js'
9
9
  import { Tasks } from './tasks.js'
10
- import { executorEnvironment, terminateJob, type ExecutorOptions } from './executor.js'
10
+ import { executorEnvironment, executorInvocation, terminateJob, type ExecutorOptions } from './executor.js'
11
11
 
12
12
  // This adapter is deliberately version-pinned: a new native tool default needs
13
13
  // a fresh tool-inventory audit before external correspondence can use it.
@@ -45,13 +45,15 @@ export async function startTaskExecutor(options: ExecutorOptions) {
45
45
  const toolNames = [...(task.anyConversation ? ['context','send'] : ['context','send','note','report','complete']),...capabilityNames]
46
46
  if (capabilityNames.length && !options.toolsHome) throw new Error('Channel capabilities require an installed tool registry')
47
47
  const environment = executorEnvironment()
48
- const version = await promisify(execFile)('codex', ['--version'], { env: environment })
48
+ const versionInvocation = executorInvocation('codex', ['--version'])
49
+ const version = await promisify(execFile)(versionInvocation.command, versionInvocation.args, { env: environment })
49
50
  if (version.stdout.trim() !== `codex-cli ${TASK_CODEX_VERSION}`) throw new Error(`Restricted tasks require audited Codex ${TASK_CODEX_VERSION}`)
50
51
  const temporary = await mkdtemp(join(tmpdir(), 'ez-task-'))
51
52
  try {
52
53
  const directory = join(temporary, 'workspace'), home = join(temporary, 'home')
53
54
  await mkdir(directory, { mode: 0o700 }); await mkdir(home, { mode: 0o700 })
54
- const catalog = await promisify(execFile)('codex', ['debug', 'models', '--bundled'], { env: environment, maxBuffer: 4 * 1024 * 1024 })
55
+ const catalogInvocation = executorInvocation('codex', ['debug', 'models', '--bundled'])
56
+ const catalog = await promisify(execFile)(catalogInvocation.command, catalogInvocation.args, { env: environment, maxBuffer: 4 * 1024 * 1024 })
55
57
  await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
56
58
  const auth = process.env.EZ_ISOLATION === 'isolated'
57
59
  ? join(options.controlDir, 'cli', 'codex', 'auth.json')
@@ -60,7 +62,8 @@ export async function startTaskExecutor(options: ExecutorOptions) {
60
62
  const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
61
63
  fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? [])]
62
64
  const prompt = JSON.stringify({event: run.external ? 'correspondence_received' : 'task_activated', taskId: run.taskId})
63
- const child = spawn('codex', taskArguments(directory, broker, prompt, toolNames, options), {
65
+ const invocation = executorInvocation('codex', taskArguments(directory, broker, prompt, toolNames, options))
66
+ const child = spawn(invocation.command, invocation.args, {
64
67
  cwd: directory, env: { ...environment, HOME: home, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32',
65
68
  })
66
69
  await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
package/src/tasks.ts CHANGED
@@ -22,6 +22,7 @@ export type Task = {
22
22
  sourceId: string; bindingId: string; accountId: string; conversationId: string
23
23
  purpose: string; context: string; createdAt: number; expiresAt: number
24
24
  state: 'pending' | 'active' | 'revoked' | 'completed'
25
+ grant?: string; initialRunDispatched?: true
25
26
  notes: string[]; operations: Record<string, { text: string; attachmentId?:string; state: 'uncertain' | 'accepted'; receipt?: unknown }>
26
27
  capabilityOperations?: Record<string, { capabilityId: string; inputHash: string; lease: string; attachment?:TaskAttachment; state: 'authorized' | 'completed' }>
27
28
  publicBudget?: PublicBudget
@@ -59,6 +60,8 @@ export class Tasks {
59
60
  !bounded(task.accountId, 200) || !bounded(task.conversationId, 200) || !bounded(task.purpose, 1000) ||
60
61
  !bounded(task.context, 6000) || !Number.isFinite(task.createdAt) || !Number.isFinite(task.expiresAt) ||
61
62
  !['pending', 'active', 'revoked', 'completed'].includes(task.state) || !Array.isArray(task.notes) ||
63
+ (task.grant !== undefined && (typeof task.grant !== 'string' || !/^[a-f0-9]{64}$/.test(task.grant))) ||
64
+ (task.initialRunDispatched !== undefined && task.initialRunDispatched !== true) ||
62
65
  !task.operations || typeof task.operations !== 'object' || (task.capabilityOperations !== undefined && (!task.capabilityOperations || typeof task.capabilityOperations !== 'object')) ||
63
66
  (task.anyConversation && !validPublicBudget(task.publicBudget)) || !task.owner) throw new Error('Invalid task record')
64
67
  return task
@@ -91,11 +94,7 @@ export class Tasks {
91
94
  !ownsRun(task.owner, run))
92
95
  throw new Error('Task is inactive or expired')
93
96
  const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
94
- const approval = await new ApprovalStore(this.controlDir).getDecision(task.id)
95
- const approvedByOwner = approval?.version === 2
96
- ? approval.decidedOwnerId === ownerId(task.owner) && approval.decidedOwnerEpoch === ownerEpoch(task.owner)
97
- : !!owner && ownsRun(owner,{telegramUserId:approval?.decidedBy!,chatId:task.owner.telegramChatId})
98
- if (!sameOwner(task.owner, owner) || approval?.decision !== 'approved' || !approvedByOwner || approval.runId !== task.runId || approval.prompt !== this.prompt(task))
97
+ if (!sameOwner(task.owner, owner) || (task.grant ? task.grant !== this.scopeHash(task) : !await this.approved(task)))
99
98
  throw new Error('Task approval is no longer valid')
100
99
  if (checkProvider) await this.source(task)
101
100
  if (run.external && checkProvider) {
@@ -107,6 +106,34 @@ export class Tasks {
107
106
  }
108
107
  return task
109
108
  }
109
+ // Persist only the bounded grant, not transient approval/outbox state. Bind every
110
+ // immutable authority field so later scope changes cannot reuse an old grant.
111
+ private scopeHash(task: Task) {
112
+ return createHash('sha256').update(JSON.stringify([
113
+ task.id, task.runId, task.owner, task.sourceId, task.bindingId, task.accountId,
114
+ task.conversationId, task.purpose, task.context, task.createdAt, task.expiresAt,
115
+ task.waitForIncoming, task.untilRevoked, task.anyConversation, task.capabilities,
116
+ ])).digest('hex')
117
+ }
118
+ private async approved(task: Task) {
119
+ const approval = await new ApprovalStore(this.controlDir).getDecision(task.id)
120
+ const approvedByOwner = approval?.version === 2
121
+ ? approval.decidedOwnerId === ownerId(task.owner) && approval.decidedOwnerEpoch === ownerEpoch(task.owner)
122
+ : ownsRun(task.owner,{telegramUserId:approval?.decidedBy!,chatId:task.owner.telegramChatId})
123
+ return approval?.decision === 'approved' && approvedByOwner && approval.runId === task.runId && approval.prompt === this.prompt(task)
124
+ }
125
+ private async dispatchInitial(task: Task) {
126
+ if (task.waitForIncoming || task.initialRunDispatched) return
127
+ // Runs are intentionally memory-only. Do not replay an opener after restart,
128
+ // when its delivery may be unknown; subsequent inbound events still resume.
129
+ task.initialRunDispatched = true
130
+ await this.save(task)
131
+ await new RunStore(this.controlDir).create({
132
+ id: `event_${createHash('sha256').update(task.id).digest('hex')}`, taskId: task.id,
133
+ ownerId: ownerId(task.owner), ownerEpoch: ownerEpoch(task.owner),
134
+ chatId: task.owner.telegramChatId, telegramUserId: task.owner.telegramUserId, texts: [],
135
+ })
136
+ }
110
137
  async match(sourceId: string, bindingId: string, events: SourceEvent[]) {
111
138
  if (!events.length || new Set(events.map(event => event.conversationId)).size !== 1) return
112
139
  const matches = (await this.list()).filter(t => t.state === 'active' && t.expiresAt > Date.now() &&
@@ -137,19 +164,23 @@ export class Tasks {
137
164
  const task = await this.get(id)
138
165
  if (!task) return false
139
166
  if (task.state === 'revoked' && task.unwatchPending) { await this.unwatch(task); return true }
140
- const approval = await new ApprovalStore(this.controlDir).getDecision(id)
141
- if (!approval || approval.runId !== task.runId || approval.prompt !== this.prompt(task)) throw new Error('Task approval mismatch')
142
- if (task.state === 'pending' && approval.decision !== 'pending') {
143
- task.state = approval.decision === 'approved' ? 'active' : 'revoked'
144
- if (task.state === 'active') {
145
- const source = await this.source(task)
146
- await sourceCall(source.socketPath, 'task-watch', { accountId: task.accountId, conversationId: task.conversationId, expiresAt: task.expiresAt })
167
+ if (!['pending', 'active'].includes(task.state) || task.expiresAt <= Date.now()) return false
168
+ if (!task.grant) {
169
+ const approval = await new ApprovalStore(this.controlDir).getDecision(id)
170
+ if (!approval || approval.runId !== task.runId || approval.prompt !== this.prompt(task)) throw new Error('Task approval mismatch')
171
+ if (approval.decision === 'pending') return true
172
+ if (approval.decision === 'denied') {
173
+ task.state = 'revoked'; await this.save(task); return true
147
174
  }
175
+ if (!await this.approved(task)) throw new Error('Task approval is no longer valid')
176
+ const source = await this.source(task)
177
+ await sourceCall(source.socketPath, 'task-watch', { accountId: task.accountId, conversationId: task.conversationId, expiresAt: task.expiresAt })
178
+ if (task.state === 'active' && !task.waitForIncoming) task.initialRunDispatched = true
179
+ task.state = 'active'; task.grant = this.scopeHash(task)
148
180
  await this.save(task)
149
181
  }
150
- if (task.state === 'active' && !task.waitForIncoming && task.expiresAt > Date.now()) await new RunStore(this.controlDir).create({
151
- id: `event_${createHash('sha256').update(task.id).digest('hex')}`, taskId: task.id, chatId: task.owner.telegramChatId, telegramUserId: task.owner.telegramUserId, texts: [],
152
- })
182
+ if (task.grant !== this.scopeHash(task)) throw new Error('Task grant scope changed')
183
+ await this.dispatchInitial(task)
153
184
  return true
154
185
  })
155
186
  }
@@ -182,7 +213,7 @@ export class Tasks {
182
213
  if(task.unwatchPending) await this.unwatch(task)
183
214
  return { id: task.id, state: task.state }
184
215
  }
185
- if (command !== 'propose') throw new Error('Unknown owner task command')
216
+ if (command !== 'start' && command !== 'propose') throw new Error('Unknown owner task command')
186
217
  if (args.waitForIncoming !== undefined && typeof args.waitForIncoming !== 'boolean') throw new Error('Invalid incoming-only option')
187
218
  if (args.untilRevoked !== undefined && (typeof args.untilRevoked !== 'boolean' || (args.untilRevoked && args.waitForIncoming !== true))) throw new Error('Persistent permission requires incoming-only mode')
188
219
  if (args.anyConversation !== undefined && typeof args.anyConversation !== 'boolean') throw new Error('Invalid any-conversation option')
@@ -207,6 +238,13 @@ export class Tasks {
207
238
  const task: Task = { version: args.anyConversation ? 4 : args.untilRevoked ? 3 : args.waitForIncoming ? 2 : 1, ...(args.untilRevoked ? {untilRevoked:true as const} : {}), ...(args.waitForIncoming ? { waitForIncoming: true as const } : {}), ...(args.anyConversation ? {anyConversation:true as const,publicBudget:{windowStartedAt:Date.now(),runs:0,responses:0,totalRuns:0,totalResponses:0}} : {}), ...(capabilities ? {capabilities} : {}), id: `task_${randomUUID().replaceAll('-', '')}`, runId, owner, sourceId: source.id,
208
239
  bindingId: source.bindingId, accountId: head.accountId, conversationId: args.conversationId, purpose: args.purpose,
209
240
  context: args.context, createdAt: Date.now(), expiresAt: args.untilRevoked ? 8640000000000000 : Date.now() + args.hours * 3600000, state: 'pending', notes: [], operations: {} }
241
+ if (command === 'start') {
242
+ await sourceCall(source.socketPath, 'task-watch', { accountId: task.accountId, conversationId: task.conversationId, expiresAt: task.expiresAt })
243
+ task.state = 'active'; task.grant = this.scopeHash(task)
244
+ await this.save(task)
245
+ await this.dispatchInitial(task)
246
+ return { id: task.id, state: task.state }
247
+ }
210
248
  if (this.prompt(task).length > 3500) throw new Error('Proposal is too long for owner review; shorten the shared context')
211
249
  await this.save(task)
212
250
  await new ApprovalStore(this.controlDir).requestApproval(task.id, this.prompt(task), runId)