Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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).
Expand All @@ -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`
Expand Down Expand Up @@ -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).
Expand All @@ -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`
Expand All @@ -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.
Expand Down Expand Up @@ -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" }
}
```
22 changes: 22 additions & 0 deletions packages/cli/src/cli/cmd/events.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
51 changes: 51 additions & 0 deletions packages/cli/src/cli/cmd/run.errors.ts
Original file line number Diff line number Diff line change
@@ -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
}
68 changes: 53 additions & 15 deletions packages/cli/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -447,6 +448,12 @@ export const RunCommand = cmd({
let error: string | undefined
const startTime = Date.now()
const childSessions = new Set<string>()
const seqBySession = new Map<string, number>()
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<string, boolean>()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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}`
Expand Down Expand Up @@ -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(
Expand All @@ -635,31 +646,44 @@ 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(
UI.Style.TEXT_WARNING_BOLD + "!",
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(
UI.Style.TEXT_WARNING_BOLD + "!",
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) {
Expand All @@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -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
},
),
])
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -107,6 +108,7 @@ let cli = yargs(hideBin(process.argv))
.command(ExportCommand)
.command(ImportCommand)
.command(SessionCommand)
.command(EventsCommand)

cli = cli
.fail((msg, err) => {
Expand Down
Loading
Loading