feat(agents): add first-class ZCode harness (#22464)

* feat(agents): add first-class ZCode harness

Add ZCode (Z.ai's `zcode` CLI) as a supervised Orca agent: managed lifecycle
hooks on local, SSH and Windows hosts; status, question and approval reporting;
synthetic status titles; session resume; orchestration worker launch options;
and desktop + mobile agent-picker registration.

Written against the newly open-sourced `zai-org/ZCode` (agent CLI 0.16.9), not
against a remembered screen:

- ZCode's hook runner writes a Claude-compatible stdin alias set, so it routes
  through the existing Claude-compatible vendor path while keeping its own
  identity in the sidebar.
- `PermissionRequest` fires only once the approval card is on screen and racing
  the user's answer, so it is proof the pane is blocked, not an auto-approval.
- ZCode's clarification tool is literally `AskUserQuestion` with Claude's
  questions/options shape, so Orca's question card renders it unchanged.
- ZCode's `hooks.enabled` defaults to false, which is why configured hooks were
  reported as never firing; the installer sets it.
- ZCode renames its own process to `zcode-cli`, so the expected foreground
  process cannot be the launch command or dispatch refuses the pane.
- ZCode emits no OSC title in any state and repaints its ASCII banner forever,
  so readiness comes from Orca's synthetic hook title and launch drafts wait on
  the composer box rather than on a quiet render window.

Three files crossed their max-lines limit, so each is split along a real seam:
command-line entrypoint parsing out of agent process recognition, skill
classification out of skill root discovery, and registry coverage out of the
remote hook installer tests.

Refs #10564

* fix(zcode): drop the session-option catalog and pin the orchestration contract

ZCode's CLI exposes no `--model` flag at all, and the session-option launch path
refuses to apply any option until a model id is chosen. A catalog therefore could
not deliver `--mode` per worker, and would have accepted `--model` only to drop
it silently. Take opencode's position instead: no catalog, so `worker-start
--model` is refused with a clear message and ZCode launches with the model from
its own config. `--mode` stays reachable through agent args, which is also how
the yolo default is applied.

Add a contract test covering the parts that make ZCode a usable worker:
dispatchable foreground process, stdin prompt delivery, the prompt staying out
of the launch command, and the composer-gated draft paste.

* refactor(zcode): reuse shared helpers and cut the harness down

No behaviour change; every ZCode test still passes.

- Use installer-utils' own `hookDefinitionHasManagedCommand` instead of
  re-walking a hook definition by hand, which also drops a local string reader.
- Share one `readZCodeEventMap` instead of keeping the same narrowing in both
  hook-settings and hook-config-json.
- Collapse five identical error returns into one `zcodeHookError` builder, and
  return early from the status branches instead of assigning through `let`.
- Split the event-to-status decision out of `normalizeZCodeEvent` into a pure
  `readZCodeTurn`, so the normalizer reads as decide-then-build and stops
  computing the tool name for events that never look at it.
- Take a script file name in `readManagedZCodeHookEvents` like its siblings,
  which removes a `Parameters<typeof …>` indirection at the call site.
- Drop the unused `ZCodeHookEvent` export and inline a single-use path helper.
- Correct a stale comment: ZCode's loader is a strict `JSON.parse`, so the
  in-place edit preserves key order and indentation, not comments.

* fix(zcode): address review — keep unmanaged event keys, correct comment, de-dupe README

- `removeZCodeManagedHooks` deleted any event key whose list ended up empty, so an
  unrelated `"Notification": []` the user wrote was removed as collateral whenever a
  managed hook elsewhere made the write happen. Only touch an event Orca actually
  owned something in; covered by a new regression test.
- The `isNewTurnEvent` comment claimed UserPromptSubmit was ZCode's only turn
  boundary while the expression below it also returned true for SessionStart. Say
  what the code does: SessionStart lands the idle boundary, UserPromptSubmit is the
  turn boundary (the Codex/Claude shape).
- ZCode appeared twice in the README's single agent-badge block; keep the
  local-icon entry the link checker validates and drop the favicon duplicate.

* docs(zcode): call out that the desktop bundle's CLI cannot open a session

From live testing on #22464: pointing `zcode` at the desktop app's bundled
`glm/zcode.cjs` installs Orca's hooks fine but then fails with
`Cannot find package '@zcode/tui'`, so the pane never opens a session. The
symptom reads as a broken harness when the CLI simply has no TUI. Say which
build to use and how to check before reporting a problem.

Reported-by: JWu527
This commit is contained in:
Neil
2026-09-25 02:17:51 -07:00
committed by GitHub
parent f5d2ce5de7
commit 90801e2deb
69 changed files with 1595 additions and 256 deletions
+79
View File
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs'
import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { isPlainObject } from '../agent-hooks/installer-utils'
import { isZCodeHooksEnabled, readZCodeEventMap, type ZCodeConfig } from './hook-settings'
export type ZCodeConfigSource = {
text: string | null
config: ZCodeConfig
}
export function parseZCodeConfigText(text: string, diagnosticName: string): ZCodeConfig | null {
const errors: ParseError[] = []
const parsed = parseJsonc(text, errors)
if (errors.length > 0) {
console.warn(
`Could not parse ${diagnosticName}: ${errors.map((e) => `offset ${e.offset} length ${e.length}`).join(', ')}`
)
return null
}
if (parsed === undefined) {
return {}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: isPlainObject just proved this is a plain object; ZCodeConfig only adds optional keys over its index signature.
return isPlainObject(parsed) ? (parsed as ZCodeConfig) : null
}
/** Original file text alongside its parsed form, so a write can edit the text in place. */
export function readZCodeConfigSource(configPath: string): ZCodeConfigSource | null {
let text: string
try {
text = readFileSync(configPath, 'utf-8')
} catch (error) {
// Why: only a definitive "no such file" is a fresh install; an EACCES/EIO must not
// be mistaken for one and overwrite the user's config with a stub.
return isDefinitiveAbsence(error) ? { text: null, config: {} } : null
}
const config = parseZCodeConfigText(text, 'ZCode config.json')
return config === null ? null : { text, config }
}
const JSON_EDIT_FORMATTING = { formattingOptions: { insertSpaces: true, tabSize: 2 } } as const
/** Set one path in the JSON text; `undefined` removes the key. */
function editJsonPath(text: string, path: readonly string[], value: unknown): string {
return applyEdits(text, modify(text, [...path], value, JSON_EDIT_FORMATTING))
}
/**
* Serialize by editing the original text one hook event at a time, so the user's key order
* and indentation survive. A parse -> JSON.stringify round trip would reformat the whole
* file. (ZCode's loader is a strict `JSON.parse`, so there are no comments to preserve.)
*/
export function serializeZCodeConfig(originalText: string | null, nextConfig: ZCodeConfig): string {
if (originalText === null) {
return `${JSON.stringify(nextConfig, null, 2)}\n`
}
const previous = parseZCodeConfigText(originalText, 'ZCode config.json') ?? {}
const previousEvents = readZCodeEventMap(previous)
const nextEvents = readZCodeEventMap(nextConfig)
let text = originalText
const nextEnabled = isZCodeHooksEnabled(nextConfig)
if (isZCodeHooksEnabled(previous) !== nextEnabled) {
text = editJsonPath(text, ['hooks', 'enabled'], nextEnabled)
}
// Why: touch only the events that actually changed, so the user's key order and
// indentation around their own untouched hook entries stay put.
for (const eventName of new Set([...Object.keys(previousEvents), ...Object.keys(nextEvents)])) {
const nextValue = nextEvents[eventName]
if (JSON.stringify(previousEvents[eventName]) === JSON.stringify(nextValue)) {
continue
}
// `undefined` removes the key, which is how remove() drops an emptied event.
text = editJsonPath(text, ['hooks', 'events', eventName], nextValue)
}
return text
}
+173
View File
@@ -0,0 +1,173 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeOs from 'node:os'
const hoisted = vi.hoisted(() => ({ home: '' }))
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof NodeOs>()
return { ...actual, homedir: () => hoisted.home }
})
vi.mock('electron', () => ({ app: { getPath: () => hoisted.home } }))
import { zcodeHookService } from './hook-service'
import { getZCodeConfigPath, ZCODE_HOOK_EVENTS } from './hook-settings'
type ManagedHookEntry = { type: string; command: string; timeout?: number }
type ZCodeConfigFile = {
hooks?: { enabled?: boolean; events?: Record<string, { hooks: ManagedHookEntry[] }[]> }
[key: string]: unknown
}
// Why no assertion: `JSON.parse` is already `any`, so the annotation narrows without a cast.
function readConfig(): ZCodeConfigFile {
return JSON.parse(readFileSync(getZCodeConfigPath(), 'utf-8'))
}
beforeEach(() => {
hoisted.home = mkdtempSync(join(tmpdir(), 'orca-zcode-'))
})
afterEach(() => {
rmSync(hoisted.home, { recursive: true, force: true })
vi.restoreAllMocks()
})
describe('ZCodeHookService', () => {
it('reports not_installed before any install', () => {
expect(zcodeHookService.getStatus()).toMatchObject({
agent: 'zcode',
state: 'not_installed',
managedHooksPresent: false
})
})
it('creates config.json with every lifecycle event and enables hooks', () => {
const status = zcodeHookService.install()
expect(status).toMatchObject({ agent: 'zcode', state: 'installed', managedHooksPresent: true })
const config = readConfig()
// Why: ZCode's DefaultRuntimeConfig ships `hooks.enabled: false`, so registering the
// events without this flag is exactly the "hooks never fire" report in zai-org/feedback#32.
expect(config.hooks?.enabled).toBe(true)
expect(Object.keys(config.hooks?.events ?? {}).sort()).toEqual([...ZCODE_HOOK_EVENTS].sort())
for (const event of ZCODE_HOOK_EVENTS) {
expect(config.hooks?.events?.[event]?.[0]?.hooks?.[0]?.command).toContain('zcode-hook')
}
})
it('is idempotent — a second install adds no duplicate entries', () => {
zcodeHookService.install()
const first = readFileSync(getZCodeConfigPath(), 'utf-8')
zcodeHookService.install()
expect(readFileSync(getZCodeConfigPath(), 'utf-8')).toBe(first)
const config = readConfig()
expect(config.hooks?.events?.Stop).toHaveLength(1)
})
it("preserves the user's own hooks, key order, and unrelated config", () => {
const configPath = getZCodeConfigPath()
mkdirSync(join(hoisted.home, '.zcode', 'cli'), { recursive: true })
// Why plain JSON and not JSONC: ZCode's loader is a strict `JSON.parse`
// (`packages/adapters/src/config/file-config.adapter.ts`), so a comment would make
// ZCode drop the whole file. The in-place edit still matters — it keeps the user's
// key order and indentation instead of reserializing their config.
writeFileSync(
configPath,
`{
"ui": { "theme": "dark" },
"telemetry": { "enabled": false },
"hooks": {
"enabled": true,
"events": {
"Stop": [{ "hooks": [{ "type": "command", "command": "my-own-hook.sh" }] }]
}
}
}
`
)
zcodeHookService.install()
const text = readFileSync(configPath, 'utf-8')
expect(text).toContain('my-own-hook.sh')
expect(text).toContain('"theme": "dark"')
// Key order is untouched: `ui` still precedes `telemetry`, which still precedes `hooks`.
expect(text.indexOf('"ui"')).toBeLessThan(text.indexOf('"telemetry"'))
expect(text.indexOf('"telemetry"')).toBeLessThan(text.indexOf('"hooks"'))
const config = readConfig()
expect(config.hooks?.events?.Stop).toHaveLength(2)
// The managed entry is appended, so the user's own hook still runs first.
expect(config.hooks?.events?.Stop?.[0]?.hooks?.[0]?.command).toBe('my-own-hook.sh')
})
it('leaves a config ZCode itself can parse (strict JSON, no comments)', () => {
zcodeHookService.install()
expect(() => JSON.parse(readFileSync(getZCodeConfigPath(), 'utf-8'))).not.toThrow()
})
it('removes only Orca-managed entries and leaves the user their hooks', () => {
const configPath = getZCodeConfigPath()
mkdirSync(join(hoisted.home, '.zcode', 'cli'), { recursive: true })
writeFileSync(
configPath,
JSON.stringify({
hooks: {
enabled: true,
events: { Stop: [{ hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }
}
})
)
zcodeHookService.install()
zcodeHookService.remove()
const config = readConfig()
expect(config.hooks?.events?.Stop).toEqual([
{ hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }
])
// Why: the user may run their own hooks; remove() must not switch the runtime off.
expect(config.hooks?.enabled).toBe(true)
expect(zcodeHookService.getStatus().managedHooksPresent).toBe(false)
})
it('leaves an unrelated empty event key alone while removing its own', () => {
const configPath = getZCodeConfigPath()
mkdirSync(join(hoisted.home, '.zcode', 'cli'), { recursive: true })
// Why: `Notification` is not an event Orca manages, and an empty list is a legitimate
// thing for a user to have written. Removing Orca's hooks must not take it with them.
writeFileSync(
configPath,
JSON.stringify({ hooks: { enabled: true, events: { Notification: [] } } })
)
zcodeHookService.install()
zcodeHookService.remove()
const config = readConfig()
expect(config.hooks?.events?.Notification).toEqual([])
for (const event of ZCODE_HOOK_EVENTS) {
expect(config.hooks?.events?.[event]).toBeUndefined()
}
})
it('reports partial when the managed events are present but hooks are disabled', () => {
zcodeHookService.install()
const configPath = getZCodeConfigPath()
const config: ZCodeConfigFile = JSON.parse(readFileSync(configPath, 'utf-8'))
if (config.hooks) {
config.hooks.enabled = false
}
writeFileSync(configPath, JSON.stringify(config, null, 2))
expect(zcodeHookService.getStatus()).toMatchObject({
state: 'partial',
managedHooksPresent: true,
detail: expect.stringContaining('hooks.enabled')
})
})
it('writes an executable managed hook script that posts to the ZCode endpoint', () => {
zcodeHookService.install()
const scriptPath = join(
hoisted.home,
'.orca',
'agent-hooks',
process.platform === 'win32' ? 'zcode-hook.cmd' : 'zcode-hook.sh'
)
expect(existsSync(scriptPath)).toBe(true)
expect(readFileSync(scriptPath, 'utf-8')).toContain('/hook/zcode')
})
})
+191
View File
@@ -0,0 +1,191 @@
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildWindowsAgentHookCurlPostCommand,
writeHooksJson,
writeManagedScript
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readTextFileRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import {
applyZCodeManagedHooks,
getZCodeConfigPath,
getZCodeManagedCommand,
getZCodeManagedScriptFileName,
getZCodeManagedScriptPath,
getZCodePosixManagedScriptFileName,
getZCodeRemoteConfigPath,
getZCodeRemoteManagedCommand,
isZCodeHooksEnabled,
readManagedZCodeHookEvents,
removeZCodeManagedHooks,
ZCODE_HOOK_EVENTS,
type ZCodeConfig
} from './hook-settings'
import {
parseZCodeConfigText,
readZCodeConfigSource,
serializeZCodeConfig
} from './hook-config-json'
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
// Why: endpoint file holds the live port/token; a PTY that outlives an Orca restart carries stale env, so `call` it to refresh (else PTY env).
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
buildWindowsAgentHookCurlPostCommand('zcode'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('zcode'),
// Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env).
// Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race can't leak shell parse errors into agent transcripts (fail-open).
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
...buildPosixAgentHookPostCommand('zcode').map((line, index, lines) =>
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
),
'exit 0',
''
].join('\n')
}
function zcodeHookError(configPath: string, detail: string): AgentHookInstallStatus {
return { agent: 'zcode', state: 'error', configPath, managedHooksPresent: false, detail }
}
function buildStatus(
config: ZCodeConfig,
configPath: string,
scriptFileName: string
): AgentHookInstallStatus {
const base = { agent: 'zcode' as const, configPath }
const present = readManagedZCodeHookEvents(config, scriptFileName)
const missing = ZCODE_HOOK_EVENTS.filter((event) => !present.has(event))
// Why: ZCode ships `hooks.enabled: false` by default, so registered events alone prove
// nothing — an install that left the flag off would never deliver a single event.
const hooksEnabled = isZCodeHooksEnabled(config)
if (missing.length === 0 && hooksEnabled) {
return { ...base, state: 'installed', managedHooksPresent: true, detail: null }
}
if (present.size === 0) {
return { ...base, state: 'not_installed', managedHooksPresent: false, detail: null }
}
const reasons = [
missing.length > 0 ? `events: ${missing.join(', ')}` : '',
hooksEnabled ? '' : '`hooks.enabled` is false, so ZCode runs no hooks'
].filter(Boolean)
return { ...base, state: 'partial', managedHooksPresent: true, detail: reasons.join('; ') }
}
export class ZCodeHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getZCodeManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getZCodeConfigPath()
const source = readZCodeConfigSource(configPath)
if (!source) {
return zcodeHookError(configPath, 'Could not read ZCode config.json')
}
return buildStatus(source.config, configPath, getZCodeManagedScriptFileName())
}
install(): AgentHookInstallStatus {
const configPath = getZCodeConfigPath()
const scriptPath = getZCodeManagedScriptPath()
const source = readZCodeConfigSource(configPath)
if (!source) {
return zcodeHookError(configPath, 'Could not read ZCode config.json')
}
const scriptFileName = getZCodeManagedScriptFileName()
const command = getZCodeManagedCommand(scriptPath)
const nextConfig = applyZCodeManagedHooks(source.config, command, scriptFileName)
// Why: write the script first so config.json never points at a missing file.
writeManagedScript(scriptPath, getManagedScript())
writeHooksJson(configPath, nextConfig, {
serialized: serializeZCodeConfig(source.text, nextConfig)
})
return this.getStatus()
}
// Install the ZCode hook on an SSH execution host, where the shell contract is POSIX.
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remoteConfigPath = getZCodeRemoteConfigPath(remoteHome)
// Why: remote-Windows is out of scope; process.platform describes the local box, not the host.
const remoteScriptFileName = getZCodePosixManagedScriptFileName()
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}`
try {
const body = await readTextFileRemote(sftp, remoteConfigPath)
const config = body === null ? {} : parseZCodeConfigText(body, 'remote ZCode config.json')
if (!config) {
return zcodeHookError(remoteConfigPath, 'Could not parse remote ZCode config.json')
}
const command = getZCodeRemoteManagedCommand(remoteScriptPath)
const nextConfig = applyZCodeManagedHooks(config, command, remoteScriptFileName)
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig, {
serialized: serializeZCodeConfig(body, nextConfig)
})
return {
agent: 'zcode',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return zcodeHookError(remoteConfigPath, err instanceof Error ? err.message : String(err))
}
}
remove(): AgentHookInstallStatus {
const configPath = getZCodeConfigPath()
const source = readZCodeConfigSource(configPath)
if (!source) {
return zcodeHookError(configPath, 'Could not read ZCode config.json')
}
const { config: nextConfig, changed } = removeZCodeManagedHooks(
source.config,
getZCodeManagedScriptFileName()
)
if (changed) {
writeHooksJson(configPath, nextConfig, {
serialized: serializeZCodeConfig(source.text, nextConfig)
})
}
return this.getStatus()
}
}
export const zcodeHookService = new ZCodeHookService()
+195
View File
@@ -0,0 +1,195 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
isPlainObject,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsCmdHookCommand,
type HookDefinition
} from '../agent-hooks/installer-utils'
const ZCODE_SCRIPT_BASE = 'zcode-hook'
/**
* Every lifecycle event ZCode's hook runner can fire (`HookEventName` in
* `packages/contracts/src/hooks/index.ts`). Matchers are omitted on purpose:
* ZCode's `matchesAnyHookMatcher` treats an absent matcher as "every tool",
* and Claude's `"*"` is not a valid ZCode matcher.
*/
export const ZCODE_HOOK_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PermissionRequest',
'PostToolUse',
'PostToolUseFailure',
'Stop'
] as const
/**
* ZCode's hook block, nested one level deeper than Claude's (`hooks.events.<Event>`).
*
* `events` is deliberately `unknown`-valued: it comes straight off a user-editable JSON
* file, so each entry is narrowed by `readEventDefinitions` at the point of use rather
* than asserted to be well-formed here.
*/
export type ZCodeHooksRuntimeConfig = {
enabled?: boolean
events?: Record<string, unknown>
[key: string]: unknown
}
export type ZCodeConfig = {
hooks?: ZCodeHooksRuntimeConfig
[key: string]: unknown
}
export function getZCodeConfigPath(): string {
// Why: ZCode resolves `~/.zcode/cli` from `homedir()` on every platform
// (`packages/adapters/src/config/file-config.adapter.ts`) — no APPDATA/XDG branch.
return join(homedir(), '.zcode', 'cli', 'config.json')
}
export function getZCodeRemoteConfigPath(remoteHome: string): string {
return `${remoteHome.replace(/\/$/, '')}/.zcode/cli/config.json`
}
export function getZCodeManagedScriptFileName(): string {
return process.platform === 'win32' ? `${ZCODE_SCRIPT_BASE}.cmd` : `${ZCODE_SCRIPT_BASE}.sh`
}
export function getZCodePosixManagedScriptFileName(): string {
return `${ZCODE_SCRIPT_BASE}.sh`
}
export function getZCodeManagedScriptPath(): string {
return getSharedManagedScriptPath(getZCodeManagedScriptFileName())
}
export function getZCodeManagedCommand(scriptPath: string): string {
if (process.platform === 'win32') {
// Why: ZCode spawns a `type: "command"` hook through its own shell resolver, so keep the
// bare directly-spawnable .cmd on the safe path and fall back to the encoded form otherwise.
return wrapWindowsCmdHookCommand(scriptPath)
}
return wrapPosixHookCommand(scriptPath)
}
export function getZCodeRemoteManagedCommand(scriptPath: string): string {
return wrapPosixHookCommand(scriptPath)
}
function getZCodeManagedCommandMatcher(
scriptFileName = getZCodeManagedScriptFileName()
): (command: string | undefined) => boolean {
return createManagedCommandMatcher(scriptFileName)
}
/** The `hooks.events` block as a plain lookup, or empty when absent or malformed. */
export function readZCodeEventMap(config: ZCodeConfig): Record<string, unknown> {
const events = config.hooks?.events
return isPlainObject(events) ? events : {}
}
/** The definitions registered for one event, dropping anything not shaped like a list. */
function readEventDefinitions(
events: Record<string, unknown>,
eventName: string
): HookDefinition[] {
const definitions = events[eventName]
if (!Array.isArray(definitions)) {
return []
}
// Why: a hand-edited config can hold nulls or scalars here; keep only object entries so
// the callers below never have to re-check, and never throw on user content.
return definitions.filter((definition): definition is HookDefinition => isPlainObject(definition))
}
export function applyZCodeManagedHooks(
config: ZCodeConfig,
command: string,
scriptFileName = getZCodeManagedScriptFileName()
): ZCodeConfig {
const nextEvents = { ...readZCodeEventMap(config) }
const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName)
for (const eventName of ZCODE_HOOK_EVENTS) {
const current = readEventDefinitions(nextEvents, eventName)
const cleaned = removeManagedCommands(current, isManagedCommand)
nextEvents[eventName] = [...cleaned, { hooks: [buildManagedCommandHook(command)] }]
}
return {
...config,
hooks: {
...config.hooks,
// Why: ZCode's DefaultRuntimeConfig ships `hooks.enabled: false`, so a hook block alone
// fires nothing — this flag is what the "ZCode hooks never run" reports were missing.
enabled: true,
events: nextEvents
}
}
}
export function removeZCodeManagedHooks(
config: ZCodeConfig,
scriptFileName = getZCodeManagedScriptFileName()
): { config: ZCodeConfig; changed: boolean } {
const events = readZCodeEventMap(config)
const nextEvents = { ...events }
const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName)
let changed = false
for (const [eventName, value] of Object.entries(nextEvents)) {
// Why: leave a non-array value exactly as the user wrote it — emptying it below would
// delete a key Orca never owned.
if (!Array.isArray(value)) {
continue
}
const definitions = readEventDefinitions(nextEvents, eventName)
const cleaned = removeManagedCommands(definitions, isManagedCommand)
// Why: only touch an event Orca actually owned something in. Without this, an unrelated
// empty entry the user wrote (`"Notification": []`) was deleted as collateral whenever a
// managed hook elsewhere made the write happen.
if (JSON.stringify(cleaned) === JSON.stringify(definitions)) {
continue
}
changed = true
if (cleaned.length === 0) {
delete nextEvents[eventName]
} else {
nextEvents[eventName] = cleaned
}
}
if (!changed) {
return { config, changed: false }
}
// Why: leave `hooks.enabled` alone on remove — the user may run their own hooks, and
// flipping it back to false would silently disable those too.
return { config: { ...config, hooks: { ...config.hooks, events: nextEvents } }, changed: true }
}
/** Events whose managed command is currently registered in the user's config. */
export function readManagedZCodeHookEvents(
config: ZCodeConfig,
scriptFileName = getZCodeManagedScriptFileName()
): Set<string> {
const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName)
const events = readZCodeEventMap(config)
return new Set(
ZCODE_HOOK_EVENTS.filter((eventName) =>
readEventDefinitions(events, eventName).some((definition) =>
hookDefinitionHasManagedCommand(definition, isManagedCommand)
)
)
)
}
export function isZCodeHooksEnabled(config: ZCodeConfig): boolean {
return config.hooks?.enabled === true
}