diff --git a/EVENTS.md b/EVENTS.md index 40d5435..45a6ff3 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -10,6 +10,8 @@ When running `aictrl run --format json`, the CLI emits newline-delimited JSON (N } ``` +The schema is versioned via `session_start.schemaVersion`. This document describes **schema version `"1"`**. Consumers should pin to this version and treat unknown fields as forward-compatible additions. + ## Lifecycle Events ### `session_start` @@ -19,11 +21,19 @@ Emitted once when the session begins. ```json { "type": "session_start", + "schemaVersion": "1", "model": "anthropic/claude-sonnet-4-20250514", - "agent": "default" + "agent": "default", + "permissions": [ + { "permission": "bash", "pattern": "*", "action": "allow" }, + { "permission": "write", "pattern": "*", "action": "ask" } + ] } ``` +- `schemaVersion` (string, **required**) — pinned contract version. Currently `"1"`. +- `permissions` (array, **required**) — the fully resolved permission ruleset for the session (merge of agent + session rules). In headless mode, any permission not matching an `allow` rule is auto-rejected. + ### `session_complete` Emitted once when the session ends (success or failure). @@ -38,6 +48,27 @@ Emitted once when the session ends (success or failure). `error` is `null` on success, or a string describing the failure. +> **Deprecated in v1.** Prefer the structured `session_error` event for new consumers. `session_complete.error` remains populated for back-compat and will be removed in a future schema version. +> +> Note: `session_error` is only emitted when the session terminates abnormally (provider/auth/rate-limit/timeout/OOM). Non-fatal errors that accumulate during an otherwise-successful run surface as individual `error` events and may also appear concatenated in `session_complete.error` without a preceding `session_error`. Alerting logic should key on `session_error`, not on `session_complete.error`. + +### `session_error` + +Emitted immediately before `session_complete` when the session terminates abnormally. + +```json +{ + "type": "session_error", + "reason": "rate_limit", + "code": "429", + "message": "Rate limit exceeded" +} +``` + +- `reason` (string, **required**) — one of `rate_limit`, `auth`, `timeout`, `oom`, `provider`, `unknown`. +- `code` (string, optional) — provider HTTP status code or error code when available. +- `message` (string, **required**) — human-readable error message. + ## Message Events ### `message_complete` @@ -73,6 +104,8 @@ Emitted when a text block from the assistant is complete. } ``` +- `sequenceNum` (number, **required**) — monotonic per-session counter shared across `text`, `reasoning`, and `tool_use` events. Use it to render a correctly-ordered trace without relying on timestamp ties. Subagent sessions have their own independent counters keyed on `part.sessionID`. Note: in JSON mode only `tool_use` events are emitted for subagent sessions, so subagent counters increment only on tool_use. + ### `reasoning` Emitted when extended thinking content is complete (requires `--thinking` flag). @@ -88,6 +121,9 @@ Emitted when extended thinking content is complete (requires `--thinking` flag). } ``` +- `sequenceNum` (number, **required**) — monotonic per-session counter shared across `text`, `reasoning`, and `tool_use` events. Use it to render a correctly-ordered trace without relying on timestamp ties. Subagent sessions have their own independent counters keyed on `part.sessionID`. Note: in JSON mode only `tool_use` events are emitted for subagent sessions, so subagent counters increment only on tool_use. +- One event is emitted per complete reasoning block. `part.text` has no size cap; consumers must accept arbitrarily large strings. + ## Tool Events ### `tool_use` @@ -110,6 +146,8 @@ Emitted when a tool call completes (success or error). This includes tools execu } ``` +- `sequenceNum` (number, **required**) — monotonic per-session counter shared across `text`, `reasoning`, and `tool_use` events. Use it to render a correctly-ordered trace without relying on timestamp ties. Subagent sessions have their own independent counters keyed on `part.sessionID`. Note: in JSON mode only `tool_use` events are emitted for subagent sessions, so subagent counters increment only on tool_use. + `state.status` is `"completed"` or `"error"`. On error, `state.error` contains the error message. For tools executed inside a subagent, `part.sessionID` will differ from the top-level `sessionID` — compare the two to identify subagent tool calls. @@ -215,7 +253,32 @@ Emitted when a permission request is auto-rejected (headless mode has no user to ```json { "type": "permission_rejected", + "callID": "call_01xyz...", + "tool": "bash", + "permission": "bash", + "patterns": ["rm -rf /"], + "input": { "command": "rm -rf /" } +} +``` + +- `tool` (string, **required**) — the tool that requested the permission. Falls back to the `permission` string when the request did not originate from a tool execution. +- `permission` (string, **required**) — permission category (e.g. `bash`, `write`). +- `patterns` (string[], **required**) — matched patterns. +- `input` (any, **required**) — the arguments the tool tried to invoke. `null` when unavailable (e.g. subagent task permission checks). +- `callID` (string, optional) — tool call id; present when the permission originated from a tool execute closure. +- `sessionID` — present on the base envelope; identifies the session that made the request (may be a subagent's session id). + +### `permission_granted` + +Emitted when a permission request resolves to `allow` (either by matching an `allow` rule or a cached `always` approval). Same shape as `permission_rejected`. + +```json +{ + "type": "permission_granted", + "callID": "call_01xyz...", + "tool": "bash", "permission": "bash", - "patterns": ["rm -rf /"] + "patterns": ["ls"], + "input": { "command": "ls" } } ``` diff --git a/packages/cli/src/cli/cmd/events.ts b/packages/cli/src/cli/cmd/events.ts new file mode 100644 index 0000000..f777ee2 --- /dev/null +++ b/packages/cli/src/cli/cmd/events.ts @@ -0,0 +1,22 @@ +import type { Argv } from "yargs" +import { cmd } from "./cmd" +import { SCHEMA_VERSION } from "./run.errors" +import EVENTS_MD from "../../../../../EVENTS.md" with { type: "text" } + +export const EventsCommand = cmd({ + command: "events", + describe: "print the NDJSON event schema (EVENTS.md) bundled with this CLI", + builder: (yargs: Argv) => + yargs.option("schema-version", { + type: "boolean", + describe: "print only the schema version string", + default: false, + }), + async handler(args) { + if (args["schema-version"]) { + process.stdout.write(SCHEMA_VERSION + "\n") + return + } + process.stdout.write(EVENTS_MD) + }, +}) diff --git a/packages/cli/src/cli/cmd/run.errors.ts b/packages/cli/src/cli/cmd/run.errors.ts new file mode 100644 index 0000000..adb031e --- /dev/null +++ b/packages/cli/src/cli/cmd/run.errors.ts @@ -0,0 +1,51 @@ +export const SCHEMA_VERSION = "1" + +export type SessionErrorReason = "rate_limit" | "auth" | "timeout" | "oom" | "provider" | "unknown" + +export type ClassifiedSessionError = { + reason: SessionErrorReason + code?: string + message: string +} + +export function classifySessionError(err: unknown): ClassifiedSessionError { + const message = extractMessage(err) + const status = extractStatus(err) + const name = extractName(err) + + if (status === 429) return { reason: "rate_limit", code: "429", message } + if (status === 401 || status === 403) return { reason: "auth", code: String(status), message } + if (name === "AbortError" || /timeout/i.test(message)) { + return { reason: "timeout", code: status ? String(status) : undefined, message } + } + if (/heap out of memory|ENOMEM/i.test(message)) { + return { reason: "oom", message } + } + if (status && status >= 500 && status < 600) { + return { reason: "provider", code: String(status), message } + } + return { reason: "unknown", code: status ? String(status) : undefined, message } +} + +function extractMessage(err: unknown): string { + if (err instanceof Error) return err.message + if (typeof err === "string") return err + if (err && typeof err === "object" && "message" in err) return String((err as { message: unknown }).message) + return String(err) +} + +function extractStatus(err: unknown): number | undefined { + if (err && typeof err === "object") { + const e = err as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } } + const raw = e.status ?? e.statusCode ?? e.response?.status + if (typeof raw === "number") return raw + if (typeof raw === "string" && /^\d+$/.test(raw)) return Number(raw) + } + return undefined +} + +function extractName(err: unknown): string | undefined { + if (err instanceof Error) return err.name + if (err && typeof err === "object" && "name" in err) return String((err as { name: unknown }).name) + return undefined +} diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 8679e6b..9876266 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -3,6 +3,7 @@ import path from "path" import { pathToFileURL } from "bun" import { UI } from "../ui" import { cmd } from "./cmd" +import { classifySessionError, SCHEMA_VERSION } from "./run.errors" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" import { EOL } from "os" @@ -447,6 +448,12 @@ export const RunCommand = cmd({ let error: string | undefined const startTime = Date.now() const childSessions = new Set() + const seqBySession = new Map() + function nextSeq(sid: string): number { + const n = (seqBySession.get(sid) ?? 0) + 1 + seqBySession.set(sid, n) + return n + } async function loop() { const toggles = new Map() @@ -484,13 +491,13 @@ export const RunCommand = cmd({ part.type === "tool" && (part.state.status === "completed" || part.state.status === "error") ) { - emit("tool_use", { part }) + emit("tool_use", { part, sequenceNum: nextSeq(part.sessionID) }) } continue } if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue + if (emit("tool_use", { part, sequenceNum: nextSeq(part.sessionID) })) continue if (part.state.status === "completed") { tool(part) continue @@ -522,7 +529,7 @@ export const RunCommand = cmd({ } if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue + if (emit("text", { part, sequenceNum: nextSeq(part.sessionID) })) continue const text = part.text.trim() if (!text) continue if (!process.stdout.isTTY) { @@ -535,7 +542,7 @@ export const RunCommand = cmd({ } if (part.type === "reasoning" && part.time?.end && args.thinking) { - if (emit("reasoning", { part })) continue + if (emit("reasoning", { part, sequenceNum: nextSeq(part.sessionID) })) continue const text = part.text.trim() if (!text) continue const line = `Thinking: ${text}` @@ -618,10 +625,14 @@ export const RunCommand = cmd({ if (event.type === "permission.asked") { const permission = event.properties - if (permission.sessionID !== sessionID) continue + const ownSession = permission.sessionID === sessionID + if (!ownSession && !childSessions.has(permission.sessionID)) continue emit("permission_rejected", { + callID: permission.tool?.callID, + tool: (permission.metadata?.tool as string | undefined) ?? permission.permission, permission: permission.permission, patterns: permission.patterns, + input: permission.metadata?.input ?? null, }) if (args.format !== "json") { UI.println( @@ -635,12 +646,24 @@ export const RunCommand = cmd({ reply: "reject", }) } + + if (event.type === "permission.granted") { + const permission = event.properties + if (permission.sessionID !== sessionID && !childSessions.has(permission.sessionID)) continue + emit("permission_granted", { + callID: permission.tool?.callID, + tool: (permission.metadata?.tool as string | undefined) ?? permission.permission, + permission: permission.permission, + patterns: permission.patterns, + input: permission.metadata?.input ?? null, + }) + } } } // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined + const agentInfo = await (async () => { + if (!args.agent) return { name: undefined, permission: [] as PermissionNext.Ruleset } const entry = await Agent.get(args.agent) if (!entry) { UI.println( @@ -648,7 +671,7 @@ export const RunCommand = cmd({ UI.Style.TEXT_NORMAL, `agent "${args.agent}" not found. Falling back to default agent`, ) - return undefined + return { name: undefined, permission: [] as PermissionNext.Ruleset } } if (entry.mode === "subagent") { UI.println( @@ -656,10 +679,11 @@ export const RunCommand = cmd({ UI.Style.TEXT_NORMAL, `agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, ) - return undefined + return { name: undefined, permission: [] as PermissionNext.Ruleset } } - return args.agent + return { name: args.agent, permission: (entry.permission ?? []) as PermissionNext.Ruleset } })() + const agent = agentInfo.name const sessionID = await session(sdk) if (!sessionID) { @@ -669,8 +693,10 @@ export const RunCommand = cmd({ await share(sdk, sessionID) emit("session_start", { + schemaVersion: SCHEMA_VERSION, model: args.model, agent: agent, + permissions: PermissionNext.merge(agentInfo.permission, rules), }) const loopDone = loop() @@ -681,12 +707,18 @@ export const RunCommand = cmd({ }) }) .catch((e) => { + const classified = classifySessionError(e) + emit("session_error", { + reason: classified.reason, + code: classified.code, + message: classified.message, + }) emit("session_complete", { durationMs: Date.now() - startTime, - error: String(e), + error: classified.message, }) console.error(e) - process.exit(1) + process.exitCode = 1 }) if (args.command) { @@ -721,13 +753,19 @@ export const RunCommand = cmd({ () => loopDone, // If prompt rejects, surface the error immediately (e) => { - error = error ? error + EOL + String(e) : String(e) + const classified = classifySessionError(e) + error = error ? error + EOL + classified.message : classified.message + emit("session_error", { + reason: classified.reason, + code: classified.code, + message: classified.message, + }) emit("session_complete", { durationMs: Date.now() - startTime, - error: String(e), + error: classified.message, }) console.error(e) - process.exit(1) + process.exitCode = 1 }, ), ]) diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index 777f840..71f2eff 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -14,6 +14,7 @@ import { ImportCommand } from "./cli/cmd/import" import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" import { SessionCommand } from "./cli/cmd/session" +import { EventsCommand } from "./cli/cmd/events" import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" @@ -107,6 +108,7 @@ let cli = yargs(hideBin(process.argv)) .command(ExportCommand) .command(ImportCommand) .command(SessionCommand) + .command(EventsCommand) cli = cli .fail((msg, err) => { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a219482..8a333a5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,7 @@ import { EOL } from "os" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" +import { EventsCommand } from "./cli/cmd/events" import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" @@ -150,6 +151,7 @@ let cli = yargs(hideBin(process.argv)) .command(PrCommand) .command(SessionCommand) .command(DbCommand) + .command(EventsCommand) cli = cli .fail((msg, err) => { diff --git a/packages/cli/src/md.d.ts b/packages/cli/src/md.d.ts new file mode 100644 index 0000000..eb3e3b9 --- /dev/null +++ b/packages/cli/src/md.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string + export default content +} diff --git a/packages/cli/src/permission/next.ts b/packages/cli/src/permission/next.ts index 1e1df62..fec642d 100644 --- a/packages/cli/src/permission/next.ts +++ b/packages/cli/src/permission/next.ts @@ -104,6 +104,7 @@ export namespace PermissionNext { reply: Reply, }), ), + Granted: BusEvent.define("permission.granted", Request), } const state = Instance.state(() => { @@ -157,6 +158,10 @@ export namespace PermissionNext { } if (rule.action === "allow") continue } + if ((request.patterns ?? []).length > 0) { + const id = input.id ?? Identifier.ascending("permission") + Bus.publish(Event.Granted, { ...request, id } as Request) + } }, ) diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 8069c40..361a352 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -754,7 +754,7 @@ export namespace SessionPrompt { using _ = log.time("resolveTools") const tools: Record = {} - const context = (args: any, options: ToolCallOptions): Tool.Context => ({ + const context = (toolId: string, args: any, options: ToolCallOptions): Tool.Context => ({ sessionID: input.session.id, abort: options.abortSignal!, messageID: input.processor.message.id, @@ -782,6 +782,11 @@ export namespace SessionPrompt { async ask(req) { await PermissionNext.ask({ ...req, + metadata: { + ...(req.metadata ?? {}), + tool: (req.metadata as any)?.tool ?? toolId, + input: (req.metadata as any)?.input ?? args, + }, sessionID: input.session.id, tool: { messageID: input.processor.message.id, callID: options.toolCallId }, ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), @@ -800,7 +805,7 @@ export namespace SessionPrompt { description: item.description, inputSchema: jsonSchema(schema as any), async execute(args, options) { - const ctx = context(args, options) + const ctx = context(item.id, args, options) await Plugin.trigger( "tool.execute.before", { @@ -845,7 +850,7 @@ export namespace SessionPrompt { item.inputSchema = jsonSchema(transformed) // Wrap execute to add plugin hooks and format output item.execute = async (args, opts) => { - const ctx = context(args, opts) + const ctx = context(key, args, opts) await Plugin.trigger( "tool.execute.before", diff --git a/packages/cli/test/cli/classify-session-error.test.ts b/packages/cli/test/cli/classify-session-error.test.ts new file mode 100644 index 0000000..b4a3d1d --- /dev/null +++ b/packages/cli/test/cli/classify-session-error.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { classifySessionError } from "../../src/cli/cmd/run.errors" + +describe("classifySessionError (#63)", () => { + test("HTTP 429 → rate_limit", () => { + const res = classifySessionError({ status: 429, message: "Rate limit exceeded" }) + expect(res.reason).toBe("rate_limit") + expect(res.code).toBe("429") + expect(res.message).toContain("Rate limit") + }) + + test("HTTP 401 → auth", () => { + const res = classifySessionError({ status: 401, message: "Invalid API key" }) + expect(res.reason).toBe("auth") + expect(res.code).toBe("401") + }) + + test("AbortError → timeout", () => { + const err = new Error("aborted") + err.name = "AbortError" + expect(classifySessionError(err).reason).toBe("timeout") + }) + + test("heap OOM → oom", () => { + const err = new Error("JavaScript heap out of memory") + expect(classifySessionError(err).reason).toBe("oom") + }) + + test("generic Error → unknown", () => { + expect(classifySessionError(new Error("boom")).reason).toBe("unknown") + }) + + test("HTTP 500 → provider", () => { + const res = classifySessionError({ status: 500, message: "internal" }) + expect(res.reason).toBe("provider") + }) +}) diff --git a/packages/cli/test/cli/events-command.test.ts b/packages/cli/test/cli/events-command.test.ts new file mode 100644 index 0000000..0221cb0 --- /dev/null +++ b/packages/cli/test/cli/events-command.test.ts @@ -0,0 +1,18 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { SCHEMA_VERSION } from "../../src/cli/cmd/run.errors" + +const EVENTS_CMD_SRC = path.resolve(import.meta.dir, "../../src/cli/cmd/events.ts") + +describe("aictrl events (#63)", () => { + test("EventsCommand module exists", async () => { + const source = await Bun.file(EVENTS_CMD_SRC).text() + expect(source).toContain("export const EventsCommand") + expect(source).toContain("schema-version") + expect(source).toContain("SCHEMA_VERSION") + }) + + test("SCHEMA_VERSION is v1", () => { + expect(SCHEMA_VERSION).toBe("1") + }) +}) diff --git a/packages/cli/test/cli/run-schema-v1.test.ts b/packages/cli/test/cli/run-schema-v1.test.ts new file mode 100644 index 0000000..51a7b87 --- /dev/null +++ b/packages/cli/test/cli/run-schema-v1.test.ts @@ -0,0 +1,50 @@ +import path from "path" +import { describe, expect, test } from "bun:test" + +const RUN_SRC = path.resolve(import.meta.dir, "../../src/cli/cmd/run.ts") + +describe("run.ts v1 schema emissions (#63)", () => { + test("permission_rejected includes tool, input, callID fields", async () => { + const source = await Bun.file(RUN_SRC).text() + const idx = source.indexOf('emit("permission_rejected"') + expect(idx).toBeGreaterThan(-1) + const block = source.slice(idx, idx + 500) + expect(block).toContain("tool:") + expect(block).toContain("input:") + expect(block).toContain("callID:") + expect(block).toContain("permission.permission") + expect(block).toContain("permission.patterns") + }) + + test("permission_granted is emitted with matching shape", async () => { + const source = await Bun.file(RUN_SRC).text() + const idx = source.indexOf('emit("permission_granted"') + expect(idx).toBeGreaterThan(-1) + const block = source.slice(idx, idx + 400) + expect(block).toContain("tool:") + expect(block).toContain("input:") + expect(block).toContain("callID:") + }) + + test("session_start emits schemaVersion and permissions", async () => { + const source = await Bun.file(RUN_SRC).text() + const idx = source.indexOf('emit("session_start"') + expect(idx).toBeGreaterThan(-1) + const block = source.slice(idx, idx + 400) + expect(block).toContain("schemaVersion: SCHEMA_VERSION") + expect(block).toContain("permissions:") + }) + + test("session_error is emitted before session_complete on failure", async () => { + const source = await Bun.file(RUN_SRC).text() + const errIdx = source.indexOf('emit("session_error"') + expect(errIdx).toBeGreaterThan(-1) + expect(errIdx).toBeLessThan(source.lastIndexOf('emit("session_complete"')) + }) + + test("text / reasoning / tool_use include sequenceNum", async () => { + const source = await Bun.file(RUN_SRC).text() + expect(source).toContain("sequenceNum") + expect(source).toMatch(/Map/) + }) +})