@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 +2 -2
- package/CHANGELOG.md +37 -0
- package/Dockerfile +13 -2
- package/README.md +0 -1
- package/THIRD_PARTY_NOTICES.md +7 -4
- package/compose.yaml +1 -0
- package/docs/application-channel.md +9 -2
- package/docs/architecture/ai-selection.md +10 -2
- package/docs/docker-runtime.md +19 -0
- package/docs/upgrades.md +1 -1
- package/package.json +1 -1
- package/src/ai-cli.ts +1 -1
- package/src/ai.ts +132 -7
- package/src/application-channel.ts +1 -1
- package/src/config.ts +1 -1
- package/src/control-state.ts +1 -4
- package/src/executor.ts +62 -22
- package/src/host-executor.ts +50 -18
- package/src/index.ts +3 -2
- package/src/menu.ts +2 -2
- package/src/message-history.ts +19 -1
- package/src/message.ts +2 -2
- package/src/plugins/manager.mjs +3 -4
- package/src/setup.ts +3 -2
- package/test/ai.test.ts +185 -1
- package/test/config.test.ts +2 -2
- package/test/conversations.test.ts +2 -8
- package/test/engine-handoff.test.ts +3 -4
- package/test/executor.test.ts +112 -12
- package/test/host-executor.test.ts +168 -12
- package/test/message-history.test.ts +47 -0
- package/test/model-policy.test.ts +2 -2
- package/test/plugin-manager.test.mjs +17 -0
- package/test/setup.test.ts +7 -7
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:
|
|
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=
|
|
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
|
-
#
|
|
13
|
-
#
|
|
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.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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.
|
|
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.
|
package/docs/docker-runtime.md
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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 ||
|
|
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() || '
|
|
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(),
|
package/src/control-state.ts
CHANGED
|
@@ -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)
|
|
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 ?? '
|
|
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, [
|