mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
* 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
80 lines
3.4 KiB
TypeScript
80 lines
3.4 KiB
TypeScript
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
|
|
}
|