@jc_stack/ez-agents 0.1.0-beta.36 → 0.1.0-beta.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -13,9 +13,9 @@ EZ_AGENT_WORKSPACE=./agent
13
13
  # Optional. Default is 300.
14
14
  # Agent execution has no wall-clock timeout. Use /stop or schedule cancel.
15
15
 
16
- # CLI Executor adapter. Supported: agy (default), claude, grok, opencode
16
+ # CLI Executor adapter. Supported: codex (default), codex-gui, claude, grok, opencode, pi
17
17
  # Switch easily with: pnpm run setup <executor-name>
18
- EZ_EXECUTOR_CLI=agy
18
+ EZ_EXECUTOR_CLI=codex
19
19
 
20
20
  # Optional: Audio transcription (STT) and voice synthesis (TTS)
21
21
  # GEMINI_API_KEY=
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.38
4
+
5
+ - Remove the retired Antigravity CLI integration and its unused engine paths.
6
+ - Add scoped application delivery history readback so an authorized application
7
+ can retrieve its own messages and delivery receipts.
8
+ - Validate host execution against its advertised model catalog and keep host
9
+ heartbeats responsive during background catalog refresh.
10
+
11
+ ## 0.1.0-beta.37
12
+
13
+ - List installed OpenCode models with their provider variants as selectable
14
+ efforts in Choose AI, chat and scheduled tasks. The relay spawns the
15
+ installed `opencode` CLI with the whitelisted executor environment, so the
16
+ menu reflects that CLI's reported catalog; variants pass through verbatim as
17
+ `run --variant` on every platform. Restricted messaging tasks stay on the
18
+ audited Codex. The relay image now ships pinned OpenCode 1.18.32 for
19
+ isolated agents; host-capable agents reuse the host installation.
20
+ - Provision authenticated OpenCode providers per agent through
21
+ `control/cli/opencode/auth.json`: isolated runs resolve it via an
22
+ agent-bound data home. Paid models may appear in the catalog; execution
23
+ depends on valid provider authorization. Host runs keep the installer login
24
+ and the free tier stays the fallback.
25
+ - Scope selectable OpenCode catalog entries with `EZ_OPENCODE_PROVIDERS` (e.g.
26
+ Go only): unlisted providers, including the free tier, leave the menu.
27
+ Isolated relays read it from their environment; host transports take
28
+ `opencodeProviders` on the agent's host binding instead. An existing
29
+ model-less default still follows the native client's provider choice.
30
+ Existing deployments need an installer-owned bootstrap for the new Compose
31
+ environment field before their automatic main updater accepts this release.
32
+ - Allow unbound plugins to run through the isolated broker when a different
33
+ installed plugin declares a host network binding; bound plugins still fail
34
+ closed on an isolated deployment.
35
+ - Add a Pi native executor with the exact conversation session ID. It offers
36
+ its native default when no provider scope is set. Owner-curated model choices
37
+ require Pi's own provider credentials and are filtered by scope. The relay
38
+ image installs pinned Pi 0.87.1.
39
+
3
40
  ## 0.1.0-beta.36
4
41
 
5
42
  - Move relay run/outbox/inbox bookkeeping into relay memory behind the control
package/Dockerfile CHANGED
@@ -9,10 +9,21 @@ COPY . .
9
9
  FROM dependencies AS test
10
10
  RUN pnpm verify
11
11
  FROM dependencies AS runtime
12
- # Keep this pin aligned with TASK_CODEX_VERSION. Isolated agents run this CLI
13
- # in the relay; do not bind-mount the operator's ~/.codex.
12
+ # Isolated agents run this CLI in the relay; do not bind-mount the
13
+ # operator's ~/.codex. Pin the reviewed CLI for repeatable release images.
14
14
  ARG CODEX_CLI_VERSION=0.153.4
15
15
  RUN npm install -g @openai/codex@${CODEX_CLI_VERSION} && command -v codex
16
+ # Isolated agents run the selected CLI in the relay; host-capable agents reuse
17
+ # the host installation instead. OpenCode carries no task-runner pin because
18
+ # restricted messaging tasks stay on the audited Codex above.
19
+ # Pin the reviewed CLI for repeatable release images.
20
+ ARG OPENCODE_CLI_VERSION=1.18.32
21
+ RUN npm install -g opencode-ai@${OPENCODE_CLI_VERSION} && command -v opencode && opencode --version
22
+ # Isolated agents select pi from the relay menu; the binary must resolve on
23
+ # the relay PATH just like codex/opencode above.
24
+ # Pin the reviewed CLI for repeatable release images.
25
+ ARG PI_CLI_VERSION=0.87.1
26
+ RUN npm install -g @earendil-works/pi-coding-agent@${PI_CLI_VERSION} && command -v pi
16
27
  RUN chmod +x docker/entrypoint.sh bin/ez bin/ezenciel-agents* && mkdir -p /state/control /state/home /workspace && chown node:node /state/control /state/home /workspace
17
28
  RUN node -e 'for (const [name, target] of Object.entries(require("./package.json").bin)) require("node:fs").symlinkSync("/app/" + target, "/usr/local/bin/" + name); require("node:fs").symlinkSync("/app/bin/ez", "/usr/local/bin/ez")'
18
29
  # Build identity for relay status (package version stays release-owned).
package/README.md CHANGED
@@ -291,7 +291,6 @@ Existing conversations without a title show their first saved message and date w
291
291
  available. Empty routing placeholders are omitted from history; + New conversation
292
292
  is the create action. Back updates the current menu without creating a conversation. Use /rename for a custom name. Older
293
293
  records without a saved model reuse a known choice for their original CLI.
294
- Antigravity's latest-only resume cannot switch back to an older conversation.
295
294
  The list covers sessions already tracked by this Ez agent; it does not import
296
295
  unrelated GUI/CLI history. Application-backed channels keep session controls in
297
296
  the connected application. Shared workspace files still persist across topics.
@@ -8,7 +8,10 @@ The container is built locally; no bundled third-party binary image is promised.
8
8
 
9
9
  Direct runtime dependencies: grammy (MIT), tsx (MIT). Development dependencies
10
10
  include TypeScript (Apache-2.0) and Node type definitions (MIT). The runtime
11
- image installs ffmpeg, Debian packages, and pinned `@openai/codex` (Apache-2.0):
12
- redistributing that image requires review of its exact package build, notices and
13
- corresponding-source obligations. Docker and a host-capable AI CLI remain
14
- separately installed products, not covered by this repository's MIT license.
11
+ image installs ffmpeg, Debian packages, and these reviewed CLI versions:
12
+ `@openai/codex` 0.153.4 (Apache-2.0), `opencode-ai` 1.18.32 (MIT), and
13
+ `@earendil-works/pi-coding-agent` 0.87.1 (MIT). Redistributing the image
14
+ requires review of its exact package build, notices and corresponding-source
15
+ obligations. Docker and
16
+ host-capable AI CLIs remain separately installed products, not covered by this
17
+ repository's MIT license.
package/compose.yaml CHANGED
@@ -28,6 +28,7 @@ services:
28
28
  EZ_CONTROL_DIR: ${EZ_CONTROL_DIR:?Set this agent control directory}
29
29
  EZ_EXECUTOR_CLI: ${EZ_EXECUTOR_CLI:?Set the host installation CLI}
30
30
  EZ_CODEX_SANDBOX: ${EZ_CODEX_SANDBOX:-}
31
+ EZ_OPENCODE_PROVIDERS: ${EZ_OPENCODE_PROVIDERS:-}
31
32
  EZ_CODEX_AUTO_COMPACT_TOKENS: ${EZ_CODEX_AUTO_COMPACT_TOKENS:-64000}
32
33
  EZ_EXECUTOR_TIMEOUT_SECONDS: ${EZ_EXECUTOR_TIMEOUT_SECONDS:-300}
33
34
  secrets: [relay_env]
@@ -131,6 +131,14 @@ not that a human read it. Failure adds a generic `error`; detailed logs remain
131
131
  local to core. Cancellation uses the existing child termination boundary and may
132
132
  return `running` until the process exits.
133
133
 
134
+ Inside an active native application run, `ezenciel-agents-message history
135
+ --limit 8` reads delivered text from this same inbox for the run's immutable
136
+ application binding and scope, including earlier runs in that scope. It cannot
137
+ read another binding, scope, or Telegram chat. The Telegram numeric
138
+ `--message-id` filter is unavailable for application runs. This reads the same
139
+ bounded relay delivery state as the application inbox; both disappear on relay
140
+ restart and add no persistent message record.
141
+
134
142
  `requestId` and `scope` accept 1–200 ASCII letters, digits, `_:.-`. Text is at most
135
143
  16,000 characters. Context is an optional JSON object (48 KiB); the complete HTTP
136
144
  body is bounded to 64 KiB. Unknown input fields are rejected. Use a stable job ID:
@@ -155,8 +163,7 @@ application must not infer or auto-submit consent.
155
163
 
156
164
  Each application binding plus scope gets an existing core session. Its first run
157
165
  captures the selected engine/preset; later runs resume that same native session.
158
- Application sessions are excluded from the Telegram session selector. Engines
159
- that only resume an implicit latest session (`agy`) cannot serve isolated scopes.
166
+ Application sessions are excluded from the Telegram session selector.
160
167
 
161
168
  Before cutting over an existing application, stop its old runner, copy its native
162
169
  transcripts into the ordinary engine's private state, and import each authoritative
@@ -34,8 +34,16 @@ References used for the adapter contract:
34
34
  - [OpenCode native run events](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/cli/cmd/run.ts)
35
35
 
36
36
  The catalog is local metadata, not an authentication or billing health check.
37
- Grok/Codex support explicit listed model/effort choices. Other installed clients
38
- offer their own default only in this slice. Refresh by opening the native client;
37
+ Grok/Codex/OpenCode support explicit listed model/effort choices. Other installed clients
38
+ offer their own default only in this slice. OpenCode lists the installed `opencode models`
39
+ catalog with the whitelisted executor environment, so only models the relay can actually
40
+ run are offered; provider variants become selectable efforts (`--variant`).
41
+ Authenticated providers appear only when the agent-bound
42
+ `control/cli/opencode/auth.json` binding exists (host runs keep the installer
43
+ login); otherwise the free tier lists. `EZ_OPENCODE_PROVIDERS` optionally
44
+ restricts the catalog to named providers (e.g. `opencode-go`); a set
45
+ allowlist that matches nothing offers no OpenCode choice rather than falling
46
+ back outside it. Refresh by opening the native client;
39
47
  the relay does not install models, manage subscriptions or guess aliases.
40
48
 
41
49
  Setup initialization and relay startup seed one default choice per installed client.
@@ -239,6 +239,25 @@ workspace (a tenant farm), while allowing explicitly shared and additional
239
239
  workspace grants and leaving `$HOME` readable for the host CLI.
240
240
  Codex then uses `danger-full-access` so it does not apply a nested Seatbelt.
241
241
 
242
+ ## OpenCode auth
243
+
244
+ Isolated agents run the image OpenCode with `XDG_DATA_HOME` set to the
245
+ agent-bound `control/cli` directory only when the owner provisioned
246
+ `control/cli/opencode/auth.json` there (mode `600`, copied once from the host
247
+ `opencode auth login`). Authenticated providers then list and run; otherwise
248
+ only the free tier is offered and paid turns fail closed with the provider
249
+ error. Only the directory path crosses into the executor environment, never a
250
+ secret. Host-capable agents reuse the host login and ignore the binding.
251
+
252
+ `EZ_OPENCODE_PROVIDERS` optionally restricts the catalog to named providers,
253
+ e.g. `opencode-go` for Go credentials only. Isolated relays read it from
254
+ their environment (`docker.env` through the relay `environment`); the host
255
+ transport is spawned with a scrubbed environment, so host-capable agents set
256
+ `opencodeProviders` on the agent's `host-executor.json` binding instead
257
+ (validated the same way, unknown fields ignored by older releases). Unset or
258
+ absent means unfiltered; a set scope that matches nothing offers no OpenCode
259
+ choice rather than falling back outside it.
260
+
242
261
  ## Chat latency
243
262
 
244
263
  Interactive Codex CLI turns resume the existing native session and use native
package/docs/upgrades.md CHANGED
@@ -245,7 +245,7 @@ The package-owned locator is the managed `<!-- ez tools -->` block: the bound
245
245
  generated on read. Plugin registry init and update binding refresh that locator.
246
246
  Malformed markers and symlinks fail visibly rather than overwriting personal work.
247
247
 
248
- Codex, agy, Grok and OpenCode discover workspace instructions natively. Claude
248
+ Codex, Grok and OpenCode discover workspace instructions natively. Claude
249
249
  receives the native `--append-system-prompt-file` binding to `AGENTS.md`, alongside
250
250
  its own normal instructions. Fresh and resumed owner input is literal; ez does
251
251
  not surround it with policies, tool recipes, repair instructions or history.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.36",
3
+ "version": "0.1.0-beta.38",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
package/src/ai-cli.ts CHANGED
@@ -12,7 +12,7 @@ if(values.help){
12
12
  }
13
13
  if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
14
14
  const catalog=await readFile(join(process.env.EZ_CONTROL_DIR,'host-executor','models.json'),'utf8')
15
- .then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR!,'cli','codex')))
15
+ .then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR!,'cli','codex'),undefined,undefined,undefined,process.env.EZ_CONTROL_DIR))
16
16
  if(positionals[0]==='list')console.log(JSON.stringify(catalog))
17
17
  else if(positionals[0]==='select'){
18
18
  const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.provider,values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',provider:values.provider,model:values.model,effort:values.effort}
package/src/ai.ts CHANGED
@@ -1,20 +1,25 @@
1
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
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
4
6
  import { homedir } from 'node:os'
5
7
  import { join, delimiter } from 'node:path'
6
- import { executorKey, resolveExecutor } from './executor.js'
8
+ import { executorEnvironment, executorKey, resolveExecutor } from './executor.js'
7
9
  import { desktopCodexPath } from './desktop-bridge.js'
8
10
 
9
11
  export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
10
12
  export type ExecutionChoice = { sessionId: string; preset: AiPreset }
11
13
  export type ModelChoice = { cli: string; provider?: string; model?: string; name: string; efforts: string[] }
12
14
  const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
15
+ const knownClis = ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'pi']
16
+ const nativeEffort = (cli: string, effort: string) =>
17
+ cli === 'pi' ? ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort) : true
13
18
  export const isPreset = (p: unknown): p is AiPreset => {
14
19
  if (!p || typeof p !== 'object') return false
15
20
  const v = p as AiPreset
16
21
  return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
17
- ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy'].includes(v.cli) &&
22
+ knownClis.includes(v.cli) &&
18
23
  (v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
19
24
  }
20
25
  export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
@@ -45,7 +50,81 @@ export const installed = async (cli: string): Promise<boolean> => {
45
50
 
46
51
  // Read only metadata from native client catalogs. Never import prompts, credentials,
47
52
  // provider configuration, or model instructions into relay context.
48
- export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex')): Promise<ModelChoice[]> => {
53
+ // The optional runner injects `opencode models` output in tests; production
54
+ // spawns the installed CLI with the whitelisted executor environment.
55
+ 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 record = (value: unknown): Record<string, unknown> =>
59
+ value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
60
+ try {
61
+ const stdout = await exec(['models', '--verbose'])
62
+ // Verbose output alternates a `provider/model` identifier line with its
63
+ // pretty-printed JSON metadata (name, variants). Split on identifier lines
64
+ // so variant keys can be projected as selectable efforts.
65
+ const segments = stdout.split(/^([A-Za-z0-9_.\-]+\/[A-Za-z0-9_./:~\-]+)$/m)
66
+ const entries: ModelChoice[] = []
67
+ for (let index = 1; index + 1 < segments.length; index += 2) {
68
+ const id = segments[index].trim()
69
+ if (!safe(id)) continue
70
+ let meta: Record<string, unknown>
71
+ try { meta = record(JSON.parse(segments[index + 1])) } catch { continue }
72
+ const friendly = typeof meta.name === 'string' ? meta.name.trim() : ''
73
+ const efforts = Object.keys(record(meta.variants))
74
+ .filter((effort) => safe(effort) && allowedEffort(effort, id, 'opencode'))
75
+ entries.push({ cli: 'opencode', model: id,
76
+ name: (friendly ? `${friendly} · ${id}` : id).slice(0, 80), efforts })
77
+ }
78
+ if (entries.length) return entries
79
+ } catch { /* fall through to the plain list, then the client default */ }
80
+ try {
81
+ const stdout = await exec(['models'])
82
+ const entries: ModelChoice[] = []
83
+ for (const line of stdout.split(/\r?\n/)) {
84
+ const id = line.trim()
85
+ if (!id || !safe(id)) continue
86
+ entries.push({ cli: 'opencode', model: id, name: id.slice(0, 80), efforts: [] })
87
+ }
88
+ if (entries.length) return entries
89
+ } catch { /* unavailable metadata stays client default */ }
90
+ return []
91
+ }
92
+
93
+ // User-curated entries live in the agent's own setup (control/ai-models.json),
94
+ // never in the repo. They pin models the native catalogs may not currently
95
+ // serve (e.g. paid tiers) and merge ahead of discovered entries, so selection
96
+ // validation accepts them.
97
+ export const readCuratedModels = async (controlDir?: string): Promise<ModelChoice[]> => {
98
+ if (!controlDir) return []
99
+ const curated: ModelChoice[] = []
100
+ const seen = new Set<string>()
101
+ try {
102
+ const raw = JSON.parse(await readFile(join(controlDir, 'ai-models.json'), 'utf8'))
103
+ if (!Array.isArray(raw)) return []
104
+ for (const candidate of raw) {
105
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue
106
+ const entry = candidate as Record<string, unknown>
107
+ if (typeof entry.cli !== 'string' || !knownClis.includes(entry.cli)) continue
108
+ if (entry.provider !== undefined && !safe(entry.provider)) continue
109
+ if (entry.model !== undefined && !safe(entry.model)) continue
110
+ const key = `${entry.cli}‖${entry.provider ?? ''}‖${entry.model ?? ''}`
111
+ if (seen.has(key)) continue
112
+ seen.add(key)
113
+ curated.push({
114
+ cli: entry.cli,
115
+ ...(entry.provider !== undefined ? { provider: entry.provider as string } : {}),
116
+ ...(entry.model !== undefined ? { model: entry.model as string } : {}),
117
+ name: (typeof entry.name === 'string' && entry.name ? entry.name : String(entry.model ?? entry.cli)).slice(0, 80),
118
+ efforts: (Array.isArray(entry.efforts) ? entry.efforts : [])
119
+ .filter((effort): effort is string => safe(effort))
120
+ .filter((effort) => allowedEffort(effort, typeof entry.model === 'string' ? entry.model : undefined, entry.cli as string) && nativeEffort(entry.cli as string, effort)),
121
+ })
122
+ }
123
+ } catch { return [] }
124
+ return curated
125
+ }
126
+
127
+ export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex'), opencodeRunner?: (args: string[]) => Promise<string>, opencodeDataHome?: string, opencodeAllowlist?: string[], curationDir?: string): Promise<ModelChoice[]> => {
49
128
  const models: ModelChoice[] = []
50
129
  const record = (value: unknown): Record<string, unknown> =>
51
130
  value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
@@ -79,15 +158,61 @@ export const readModels = async (home = homedir(), available = installed, codexH
79
158
  models.push(...(desktop.length ? desktop : [{ cli: 'codex-gui', name: 'codex-gui · desktop', efforts: [] }]))
80
159
  }
81
160
  // Other adapters expose the authenticated client's default, not a guessed catalog.
82
- for (const cli of ['claude', 'opencode', 'agy'])
83
- if (await available(cli)) models.push({ cli, name: `${cli} · client default`, efforts: [] })
84
- return models
161
+ if (await available('claude')) models.push({ cli: 'claude', name: 'claude · client default', efforts: [] })
162
+ const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
163
+ if (await available('opencode')) models.push(...await opencodeCatalogModels(opencodeRunner, opencodeDataHome, allow))
164
+ // Pi has separate provider configuration. OpenCode's catalog cannot
165
+ // establish which models Pi can execute.
166
+ if (!allow && await available('pi')) models.push({ cli: 'pi', name: 'Pi · client default', efforts: [] })
167
+ const curated = await readCuratedModels(curationDir)
168
+ if (!curated.length) return models
169
+ const scoped: ModelChoice[] = []
170
+ for (const entry of curated) {
171
+ if (!(await available(entry.cli))) continue
172
+ if (['opencode', 'pi'].includes(entry.cli) && allow &&
173
+ (entry.model === undefined || !allow.includes(entry.model.split('/')[0]))) continue
174
+ scoped.push(entry)
175
+ }
176
+ const key = (m: { cli: string; provider?: string; model?: string }) => `${m.cli}‖${m.provider ?? ''}‖${m.model ?? ''}`
177
+ const curatedKeys = new Set(scoped.map(key))
178
+ return [...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))]
179
+ }
180
+ export const opencodeCatalogModels = async (
181
+ opencodeRunner?: (args: string[]) => Promise<string>,
182
+ opencodeDataHome?: string,
183
+ opencodeAllowlist?: string[],
184
+ ): Promise<ModelChoice[]> => {
185
+ const discovered = await readOpencodeModels(opencodeRunner, opencodeDataHome)
186
+ const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
187
+ const scoped = allow ? discovered.filter((m) => m.model && allow.includes(m.model.split('/')[0])) : discovered
188
+ if (scoped.length) return scoped
189
+ // A set allowlist that matches nothing offers no choice rather than falling
190
+ // back outside the allowed providers.
191
+ if (allow) return []
192
+ return [{ cli: 'opencode', name: 'opencode · client default', efforts: [] as string[] }]
193
+ }
194
+
195
+ // Optional deployment-scoped restriction of the OpenCode catalog to named
196
+ // providers (e.g. EZ_OPENCODE_PROVIDERS=opencode-go). Unset means unfiltered.
197
+ // Reads process env directly like OPENCODE_MODEL; malformed values fail fast
198
+ // so /ai reports the misconfiguration instead of a silently wrong list.
199
+ const providerId = (p: unknown): p is string => typeof p === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(p)
200
+ export const validateOpencodeProviders = (value: unknown): string[] | undefined => {
201
+ if (value === undefined) return undefined
202
+ if (!Array.isArray(value) || !value.length || value.length > 16 || new Set(value).size !== value.length || value.some((p) => !providerId(p)))
203
+ throw new Error('opencode provider allowlist must be one to sixteen unique provider IDs')
204
+ return value as string[]
205
+ }
206
+ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env): string[] | undefined => {
207
+ const raw = env.EZ_OPENCODE_PROVIDERS?.trim()
208
+ if (!raw) return undefined
209
+ return validateOpencodeProviders([...new Set(raw.split(',').map((p) => p.trim()).filter(Boolean))])
85
210
  }
86
211
 
87
212
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
88
213
  assertEffort(p.effort, p.model, p.cli)
89
214
  if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
90
- if (!p.provider && !p.model && !p.effort && p.cli !== 'agy') return
215
+ if (!p.provider && !p.model && !p.effort) return
91
216
  const model = catalog.find((m) => m.cli === p.cli && m.provider === p.provider && m.model === p.model)
92
217
  if (!model || (p.effort && !model.efforts.includes(p.effort)))
93
218
  throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
@@ -206,7 +206,7 @@ export class ApplicationChannel {
206
206
  }
207
207
  if (value.action==='model') {
208
208
  const model = (await controls.catalog()).find(item=>item.cli===value.cli && item.provider===value.provider && item.model===value.model)
209
- if (!model || model.cli==='agy' || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
209
+ if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
210
210
  preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
211
211
  }
212
212
  await control.changeApplicationSession(hashedScope,guard,preset)
package/src/config.ts CHANGED
@@ -78,7 +78,7 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
78
78
  isolation,
79
79
  codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
80
80
  codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
81
- executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
81
+ executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'codex',
82
82
  applicationPort: env.EZ_APPLICATION_PORT ? positiveInteger(env.EZ_APPLICATION_PORT, 'EZ_APPLICATION_PORT') : undefined,
83
83
  applicationHost: env.EZ_APPLICATION_HOST?.trim() || '127.0.0.1',
84
84
  channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
@@ -483,8 +483,6 @@ export class ControlStore {
483
483
  if (state.activeSession?.sessionId === sessionId) return state.activeSession
484
484
  const session = state.sessions?.find(s => s.sessionId === sessionId)
485
485
  if (!session || session.archived || (session.applicationScope && !session.telegramShared)) throw new Error('Conversation unavailable. Open /chats again.')
486
- if (session.cli === 'agy')
487
- throw new Error('Antigravity only resumes its latest conversation; selecting an older session is not supported.')
488
486
  const ai = state.ai
489
487
  // Older sessions did not record their model. Reuse a known preset for the
490
488
  // same CLI; never resume an engine ID through a different client.
@@ -684,7 +682,6 @@ export class ControlStore {
684
682
  return { sessionId: previous.sessionId, preset: previous.preset }
685
683
  }
686
684
  const preset = requested ?? state.ai.presets.find(item => item.id === state.ai!.selectedId)!
687
- if (preset.cli === 'agy') throw new Error('Application scopes require an engine with explicit session selection')
688
685
  const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset, applicationScope: scope }
689
686
  state.sessions.push(session)
690
687
  activate(session)
@@ -707,7 +704,7 @@ export class ControlStore {
707
704
  if (previous && (previous.telegramShared || previous === state.activeSession))
708
705
  throw new Error('Application scope is shared; use shared controls')
709
706
  const nextPreset = preset ?? state.ai?.presets.find(item => item.id === state.ai!.defaultId)
710
- if (!nextPreset || !isPreset(nextPreset) || nextPreset.cli === 'agy') throw new Error('Invalid application AI selection')
707
+ if (!nextPreset || !isPreset(nextPreset)) throw new Error('Invalid application AI selection')
711
708
  if (preset && previous?.cli === preset.cli) {
712
709
  previous.preset = persistedPreset(preset)
713
710
  await this.writeState(state)
package/src/executor.ts CHANGED
@@ -103,7 +103,6 @@ export const resolveHostCommand = (command: string, pathValue = process.env.PATH
103
103
  }
104
104
  throw new Error(`Native CLI ${command} is not executable on the host PATH`)
105
105
  }
106
-
107
106
  export type CliAdapter = {
108
107
  name: string
109
108
  command: string
@@ -144,17 +143,6 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
144
143
  return args
145
144
  },
146
145
  },
147
- agy: {
148
- name: 'antigravity',
149
- command: 'agy',
150
- description: 'Google Antigravity CLI (default)',
151
- buildArgs: (opts, _promptFile, promptText) => {
152
- const args: string[] = []
153
- if (opts.isResume) args.push('-c')
154
- args.push('--dangerously-skip-permissions', `--print=${promptText}`)
155
- return args
156
- },
157
- },
158
146
  claude: {
159
147
  name: 'claude',
160
148
  command: 'claude',
@@ -210,6 +198,30 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
210
198
  return args
211
199
  },
212
200
  },
201
+ pi: {
202
+ name: 'pi',
203
+ command: 'pi',
204
+ description: 'Pi coding agent',
205
+ buildArgs: (opts, _promptFile, promptText) => {
206
+ if (!opts.controlDir) throw new Error('Pi runs require a bound control directory for sessions')
207
+ if (opts.effort && !['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(opts.effort)) throw new Error('Invalid Pi thinking level')
208
+ const args = ['-p', '--mode', 'json', '--session-dir', path.join(opts.controlDir, 'cli', 'pi', 'sessions')]
209
+ // Owner-provisioned delivery skill (control/cli/pi/skills/ez-delivery).
210
+ // Loaded by path, never by prompt text, so the model cannot miss it.
211
+ try {
212
+ const skill = path.join(opts.controlDir, 'cli', 'pi', 'skills', 'ez-delivery', 'SKILL.md')
213
+ accessSync(skill, fsConstants.R_OK)
214
+ args.push('--skill', path.dirname(skill))
215
+ } catch { /* runs without the skill exactly as before */ }
216
+ // Bind Pi to this conversation, not the most recently used session in
217
+ // the shared directory. --session-id creates or resumes that exact ID.
218
+ if (opts.sessionId) args.push('--session-id', opts.sessionId)
219
+ if (opts.model) args.push('--model', opts.model)
220
+ if (opts.effort) args.push('--thinking', opts.effort)
221
+ args.push('--', promptText)
222
+ return args
223
+ },
224
+ },
213
225
  'codex-gui': {
214
226
  name: 'codex-gui',
215
227
  command: 'codex',
@@ -219,13 +231,12 @@ export const EXECUTOR_REGISTRY: Record<string, CliAdapter> = {
219
231
  }
220
232
 
221
233
  export const EXECUTOR_ALIASES: Record<string, string> = {
222
- antigravity: 'agy',
223
234
  'claude-code': 'claude',
224
235
  oc: 'opencode',
225
236
  }
226
237
 
227
238
  export const executorKey = (name?: string): string => {
228
- const normalized = (name ?? 'agy').trim().toLowerCase()
239
+ const normalized = (name ?? 'codex').trim().toLowerCase()
229
240
  const resolvedKey = EXECUTOR_ALIASES[normalized] ?? normalized
230
241
  if (!EXECUTOR_REGISTRY[resolvedKey]) {
231
242
  const supported = [...Object.keys(EXECUTOR_REGISTRY), ...Object.keys(EXECUTOR_ALIASES)].join(', ')
@@ -262,6 +273,27 @@ export const validateCodexProviderEnvironmentKey = (value: unknown): string => {
262
273
 
263
274
  export const resolveExecutor = (name?: string): CliAdapter => EXECUTOR_REGISTRY[executorKey(name)]
264
275
 
276
+ // Agent-bound OpenCode credentials live at cli/opencode/auth.json, mirroring
277
+ // the Codex binding. Returns the XDG data home (cli/) only when that binding
278
+ // exists; otherwise the host login (host transport) or the unauthenticated
279
+ // free tier (isolated relay) applies. Only a path is ever returned, never a
280
+ // secret.
281
+ export const opencodeDataHome = (controlDir: string): string | undefined => {
282
+ try {
283
+ accessSync(path.join(controlDir, 'cli', 'opencode', 'auth.json'), fsConstants.R_OK)
284
+ return path.join(controlDir, 'cli')
285
+ } catch { return undefined }
286
+ }
287
+
288
+ // Pi resolves its provider/model catalog from an agent dir (settings.json,
289
+ // auth.json). Only a path is ever returned, never a secret.
290
+ export const piAgentDir = (controlDir: string): string | undefined => {
291
+ try {
292
+ accessSync(path.join(controlDir, 'cli', 'pi', 'agent', 'auth.json'), fsConstants.R_OK)
293
+ return path.join(controlDir, 'cli', 'pi', 'agent')
294
+ } catch { return undefined }
295
+ }
296
+
265
297
  export const grokInvocation = (
266
298
  options: Pick<ExecutorOptions, 'workspace' | 'isResume'>,
267
299
  promptFile: string,
@@ -270,14 +302,6 @@ export const grokInvocation = (
270
302
  args: EXECUTOR_REGISTRY.grok.buildArgs(options, promptFile, ''),
271
303
  })
272
304
 
273
- export const antigravityInvocation = (
274
- prompt: string,
275
- options: Pick<ExecutorOptions, 'isResume'> = {},
276
- ): { command: string; args: string[] } => ({
277
- command: EXECUTOR_REGISTRY.agy.command,
278
- args: EXECUTOR_REGISTRY.agy.buildArgs({ workspace: '', ...options }, '', prompt),
279
- })
280
-
281
305
  export const opencodeInvocation = (
282
306
  prompt: string,
283
307
  options: Pick<ExecutorOptions, 'workspace' | 'sessionId' | 'isResume'> = { workspace: '' },
@@ -363,6 +387,22 @@ export const startExecutorJob = async (
363
387
  }
364
388
  environment.CODEX_HOME = home
365
389
  }
390
+ if (!host && !gui && key === 'opencode') {
391
+ // Point OpenCode at the agent-bound data home (cli/opencode/auth.json)
392
+ // when the owner provisioned it. Host runs keep the installer login;
393
+ // without a binding the CLI falls back to its free tier. Fail closed at
394
+ // runtime with the provider error, never with a guessed credential.
395
+ const dataHome = opencodeDataHome(options.controlDir)
396
+ if (dataHome) environment.XDG_DATA_HOME = dataHome
397
+ }
398
+ if (!host && !gui && key === 'pi') {
399
+ // Point Pi at the agent-bound config dir (cli/pi/agent/auth.json) when
400
+ // the owner provisioned it. Without a binding Pi falls back to the relay
401
+ // home config. Fail closed at runtime with the provider error, never with
402
+ // a guessed credential.
403
+ const agentDir = piAgentDir(options.controlDir)
404
+ if (agentDir) environment.PI_CODING_AGENT_DIR = agentDir
405
+ }
366
406
  if (denies.length) {
367
407
  const profile = path.join(outputDirectory, 'workspace.sb')
368
408
  await writeFile(profile, macosWorkspaceProfile(denies, [