From 74e3f1fba5b024bf55d4010ebb8ea6d7a973bd7c Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 11:00:32 +0100 Subject: [PATCH 01/14] docs: spec for NDJSON event schema gaps (#63) Design doc closing the 5 asks + permission_granted nice-to-have from issue #63. Fixes schemaVersion to v1; defers reason field on permission_rejected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-23-ndjson-schema-gaps-design.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md diff --git a/docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md b/docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md new file mode 100644 index 0000000..9dd5b2f --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md @@ -0,0 +1,211 @@ +# NDJSON Event Schema Gaps for Downstream Consumers + +Issue: [aictrl-dev/cli#63](https://github.com/aictrl-dev/cli/issues/63) +Date: 2026-04-23 +Branch: `feature/ndjson-schema-gaps` + +## Problem + +The aictrl executor (downstream consumer, `aictrl-dev/aictrl`) aggregates metrics and renders an Agent Activity view from the CLI's `aictrl run --format json` NDJSON stream. Five schema gaps force defensive shape-guessing, stderr parsing, and out-of-band config reads. This work closes those gaps for v1 of the event schema. + +## Scope + +In scope: + +1. Enrich `permission_rejected` payload (stable shape with tool/input/sessionID/callID) +2. New `session_error` event with structured `{reason, code, message}`; deprecate `session_complete.error` string +3. Publish `EVENTS.md` via npm package, new `aictrl events` subcommand, `schemaVersion` in `session_start` +4. Echo resolved permission ruleset in `session_start` +5. Add `sequenceNum` to `reasoning`, `text`, and `tool_use` events; document per-event emission semantics +6. New `permission_granted` event (symmetric with `permission_rejected`) + +Out of scope: + +- Changing `subagent_start`/`subagent_complete` — already emitted, match downstream fixture. +- Size caps on reasoning text — documented as unbounded; downstream sizes buffers based on observed distribution. +- Back-compat shim for `session_complete.error` — deprecated in docs only this PR; still emitted as today. Full removal deferred to a later CLI major. + +## Design + +### 1. `permission_rejected` — enriched flat shape + +Current emission (`packages/cli/src/cli/cmd/run.ts:622`): + +```json +{"type":"permission_rejected","permission":"bash","patterns":["rm -rf /"]} +``` + +New emission: + +```json +{ + "type": "permission_rejected", + "sessionID": "session_01abc...", + "callID": "call_01xyz...", + "tool": "bash", + "permission": "bash", + "patterns": ["rm -rf /"], + "input": { "command": "rm -rf /" } +} +``` + +Field sources: + +- `sessionID` — `permission.asked.sessionID` (already present). +- `callID` — `permission.asked.tool.callID` when present (populated by per-tool `ctx.ask` in `packages/cli/src/session/prompt.ts:786`); omitted otherwise. +- `tool` — tool registry id; see "Tool id threading" below. Falls back to `permission` string if the ask did not originate from a tool execute closure. +- `permission` — kept for back-compat; same string as today. +- `patterns` — unchanged. +- `input` — tool arguments at ask time. See "Input threading" below. +- `reason` — not emitted in v1 (deferred). + +Tool id threading: the per-tool `execute(args, options)` closure in `prompt.ts` knows `item.id` and `args`. Wrap the existing `ctx.ask` so it forwards `tool: item.id` and `input: args` into the `PermissionNext.Request.metadata` field. The `permission.asked` bus event already carries `metadata: z.record(...)`. The run.ts consumer reads `metadata.tool` and `metadata.input` when emitting `permission_rejected`. + +Edge cases: + +- Permission asks not originating from a tool execute closure (e.g., `processor.ts:166` doom_loop check) already populate `metadata.tool` and `metadata.input`; this works automatically. +- Permission asks with no metadata (task subagent permission, `prompt.ts:444`) — emit with `tool` absent and `input: null`. Consumers MUST tolerate missing `tool`/`input`. + +### 2. `session_error` event — structured failure + +Emitted immediately before `session_complete` on abnormal termination (provider error, auth failure, rate limit, timeout, OOM, uncaught exception in the prompt loop). + +```json +{ + "type": "session_error", + "sessionID": "session_01abc...", + "reason": "rate_limit", + "code": "429", + "message": "Rate limit exceeded: 40000 input tokens per minute" +} +``` + +`reason` vocabulary (closed set, documented in `EVENTS.md`): + +- `rate_limit` — provider returned 429 or equivalent. +- `auth` — 401/403 or missing/invalid API key. +- `timeout` — request timeout at provider or CLI level. +- `oom` — out-of-memory (detected via process exit signal or Node heap error). +- `provider` — any other provider-side error (5xx, malformed response). +- `unknown` — unclassified; `message` contains the raw string. + +`code` is the provider HTTP status code when available, otherwise the error `name`/`code` property, otherwise omitted. + +Classification happens in the `.catch` blocks of `run.ts:683` and `run.ts:723`. A small helper `classifySessionError(err) -> {reason, code, message}` lives in `packages/cli/src/cli/cmd/run.ts` (or a new `packages/cli/src/cli/cmd/run.errors.ts` if it grows). + +`session_complete.error` remains a string for now — set to `message` so existing consumers keep working. `EVENTS.md` marks it **deprecated**; new consumers read `session_error`. + +### 3. Schema publishing + +Three changes: + +a. **`schemaVersion` on `session_start`** — literal `"1"` in this release. Declared as a versioned contract going forward; breaking changes bump the integer. + +```json +{"type":"session_start","schemaVersion":"1","model":"...","agent":"...","permissions":[...]} +``` + +b. **Ship `EVENTS.md` in the npm package** — add `"EVENTS.md"` to `packages/cli/package.json`'s `files` array. Consumers can `require.resolve('aictrl/EVENTS.md')`. + +c. **`aictrl events` subcommand** — new CLI command prints the bundled `EVENTS.md` to stdout. Flag `--schema-version` prints just the version string. + +Implementation: new file `packages/cli/src/cli/cmd/events.ts`, registered alongside existing commands in the CLI entrypoint. Reads `EVENTS.md` via `import.meta.resolve` (or equivalent bun-compatible path resolution). + +### 4. `session_start` — echo permission ruleset + +Current (`run.ts:671`): + +```json +{"type":"session_start","model":"...","agent":"..."} +``` + +New: + +```json +{ + "type": "session_start", + "schemaVersion": "1", + "model": "anthropic/claude-sonnet-4-20250514", + "agent": "default", + "permissions": [ + {"permission": "bash", "pattern": "*", "action": "allow"}, + {"permission": "write", "pattern": "*", "action": "ask"}, + {"permission": "webfetch", "pattern": "*", "action": "deny"} + ] +} +``` + +`permissions` is the fully-resolved `PermissionNext.Ruleset` — the merge of agent permission and session permission that would be passed to `PermissionNext.ask(...)`. Computed once at session start via the existing `PermissionNext.merge(agent.permission, session.permission ?? [])` call. + +In headless mode the effective rule for anything not explicitly `allow` is auto-reject, so downstream can compute "would this have been rejected?" from the echoed ruleset alone. + +### 5. `sequenceNum` on event stream + +Add a monotonic per-session counter (reset per session, starts at `0`) to the interleavable events: + +- `text` +- `reasoning` +- `tool_use` + +Top-level events (`session_start`, `session_complete`, `session_error`, `message_complete`, `step_start`, `step_finish`, skill events, subagent events, permission events) do **not** get `sequenceNum`. Timestamps already order them adequately; they don't interleave with reasoning. + +Counter implementation: module-level `Map` in `run.ts` incremented before each `emit()` call for the three interleavable types. Child session (subagent) sequence numbers are distinct from the parent's — keyed on `part.sessionID`, not the top-level session. + +Documentation guarantees: + +- `reasoning`: one event per complete reasoning block; text is unbounded in size; consumers must accept arbitrarily large `part.text` strings. +- `text`: one event per complete text block (same as today). +- `tool_use`: one event per completed (or errored) tool call. +- `sequenceNum` is strictly monotonic per session and reflects emission order within that session's stream. + +### 6. `permission_granted` event + +Symmetric to `permission_rejected`. Emitted when a permission ask resolves to `allow` (either by matching an `allow` rule or by an `always` pattern already on file). Same payload shape as `permission_rejected` minus the rejection-specific framing. + +```json +{ + "type": "permission_granted", + "sessionID": "session_01abc...", + "callID": "call_01xyz...", + "tool": "bash", + "permission": "bash", + "patterns": ["ls"], + "input": { "command": "ls" } +} +``` + +Emission site: `PermissionNext.Event.Replied` (new or existing — see `packages/cli/src/permission/next.ts:99`). The `permission.replied` event fires with `reply: "once" | "always" | "reject"`. In run.ts, subscribe to replies; on `once` or `always`, emit `permission_granted` with metadata from the matching open request (tracked in a `Map` populated on `permission.asked`). + +Edge case: if a rule matches with `action: "allow"` up front, `PermissionNext.ask` short-circuits without a `permission.replied` event. Add emission at the short-circuit point (see `packages/cli/src/permission/next.ts:139` RejectedError path; mirror for the allow path). + +## Testing + +Unit tests live in `packages/cli/test/` (following existing convention). Strategy: + +- **Event shape snapshots**: for each enriched/new event type, write a fixture test that constructs the underlying bus event and asserts the emitted NDJSON line matches a golden JSON object. Tests target the emission code in `run.ts` directly (extract emitters into pure functions if they aren't already). +- **`classifySessionError` unit tests**: cover each `reason` category with representative error shapes (HTTP 429, `ENOTFOUND`, `AbortError`, heap OOM signal, generic Error). +- **`aictrl events` command**: smoke test that it prints the bundled file and `--schema-version` prints `"1"`. +- **Ruleset echo**: test that `session_start.permissions` equals the merged ruleset for a session with both agent-level and session-level rules. + +No full end-to-end test spawning a real provider is needed for this scope; the existing integration tests cover the overall NDJSON stream wiring. + +## Migration Notes for Downstream + +Consumers pin to `schemaVersion: "1"` on `session_start`. + +- `session_complete.error` remains a string; treat as deprecated. +- `permission_rejected` gains fields; existing `permission`/`patterns` keys unchanged. +- `permission_granted` is new; consumers ignoring unknown event types are unaffected. +- `session_error` is new; precedes `session_complete` on failure. +- `sequenceNum` on `text`/`reasoning`/`tool_use` is new; consumers ignoring unknown fields are unaffected. + +## Build Order + +1. `EVENTS.md` text updates describing final v1 shapes (docs land first so subsequent PRs can reference). +2. Enrich `permission_rejected` (thread tool/input metadata); add `permission_granted`. +3. Enrich `session_start` (schemaVersion + permissions). +4. Add `session_error` + `classifySessionError`; deprecate `session_complete.error` in docs. +5. Add `sequenceNum` to `text`/`reasoning`/`tool_use`. +6. `aictrl events` command + npm `files` addition for `EVENTS.md`. + +Each step ships with tests. Whole scope targets a single PR unless review feedback suggests splitting. From 45ee9f7d62cbaad72dc595aa76eb4deda8931268 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 13:01:13 +0100 Subject: [PATCH 02/14] docs: implementation plan for NDJSON schema gaps (#63) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-23-ndjson-schema-gaps.md | 950 ++++++++++++++++++ 1 file changed, 950 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-ndjson-schema-gaps.md diff --git a/docs/superpowers/plans/2026-04-23-ndjson-schema-gaps.md b/docs/superpowers/plans/2026-04-23-ndjson-schema-gaps.md new file mode 100644 index 0000000..4fd2f41 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-ndjson-schema-gaps.md @@ -0,0 +1,950 @@ +# NDJSON Event Schema Gaps Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close five NDJSON event schema gaps from issue #63 so the downstream aictrl executor can consume the stream without defensive shape-guessing. + +**Architecture:** All NDJSON emission is centralized in `packages/cli/src/cli/cmd/run.ts`. Permission events pass through `PermissionNext` (`packages/cli/src/permission/next.ts`) carrying a `metadata` record we can enrich at tool-execution time in `packages/cli/src/session/prompt.ts`. The `emit(type, data)` helper at `run.ts:438` automatically injects `type`/`timestamp`/`sessionID`, so tasks only add business-specific fields. + +**Tech Stack:** TypeScript, bun, yargs (CLI commands), bun:test (tests), zod (schemas on the permission bus). + +**Spec:** `docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md` + +--- + +## File Structure + +| Path | Role | Change | +|---|---|---| +| `EVENTS.md` | Public event schema doc | Modify | +| `packages/cli/package.json` | Add `EVENTS.md` to `files` | Modify | +| `packages/cli/src/cli/cmd/run.ts` | Event emission site | Modify (primary work) | +| `packages/cli/src/cli/cmd/run.errors.ts` | `classifySessionError` helper | Create | +| `packages/cli/src/cli/cmd/events.ts` | `aictrl events` subcommand | Create | +| `packages/cli/src/index.ts` | Register `EventsCommand` | Modify | +| `packages/cli/src/headless.ts` | Register `EventsCommand` | Modify | +| `packages/cli/src/session/prompt.ts` | Thread tool id/input into `ctx.ask` metadata | Modify | +| `packages/cli/test/cli/run-schema-v1.test.ts` | Source-level assertions on emissions | Create | +| `packages/cli/test/cli/classify-session-error.test.ts` | Unit tests for classifier | Create | +| `packages/cli/test/cli/events-command.test.ts` | Smoke test for `aictrl events` | Create | + +--- + +## Task 1: EVENTS.md — document v1 shapes + +**Files:** +- Modify: `EVENTS.md` (whole file rewrite for the changed sections) + +- [ ] **Step 1.1: Add top-level schema version note** + +Insert after the "base shape" paragraph at the top: + +```markdown +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. +``` + +- [ ] **Step 1.2: Update `session_start` section** + +Replace the current `session_start` example block with: + +````markdown +### `session_start` + +Emitted once when the session begins. + +```json +{ + "type": "session_start", + "schemaVersion": "1", + "model": "anthropic/claude-sonnet-4-20250514", + "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. +```` + +- [ ] **Step 1.3: Update `session_complete` section — mark `error` deprecated** + +Replace the trailing paragraph under `session_complete` with: + +```markdown +`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. +``` + +- [ ] **Step 1.4: Add `session_error` section** + +Insert a new subsection after `session_complete`: + +````markdown +### `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. +```` + +- [ ] **Step 1.5: Rewrite `permission_rejected` section** + +Replace the `permission_rejected` block with: + +````markdown +### `permission_rejected` + +Emitted when a permission request is auto-rejected (headless mode has no user to approve). + +```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). +```` + +- [ ] **Step 1.6: Add `permission_granted` section** + +Insert immediately after `permission_rejected`: + +````markdown +### `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": ["ls"], + "input": { "command": "ls" } +} +``` +```` + +- [ ] **Step 1.7: Add `sequenceNum` note to reasoning/text/tool_use** + +Under each of `text`, `reasoning`, and `tool_use`, add a bullet after the example: + +```markdown +- `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`. +``` + +Under `reasoning`, add a second bullet: + +```markdown +- One event is emitted per complete reasoning block. `part.text` has no size cap; consumers must accept arbitrarily large strings. +``` + +- [ ] **Step 1.8: Commit** + +```bash +git add EVENTS.md +git commit -m "docs(events): document v1 schema shapes for #63" +``` + +--- + +## Task 2: Thread tool id + input into permission metadata + +**Files:** +- Modify: `packages/cli/src/session/prompt.ts:782-789` + +- [ ] **Step 2.1: Read the existing ask closure** + +Read lines 770–800 of `packages/cli/src/session/prompt.ts` to confirm current structure. The per-tool loop starting at line 792 creates a `context(args, options)` that wraps `ctx.ask`. The closure has access to `item.id` (tool name) and `args` (tool arguments). + +- [ ] **Step 2.2: Enrich `ctx.ask` metadata** + +Locate the `ask` closure inside `context()` (around line 782). The `req` object is what a tool passes to `ctx.ask`. Change the body so it merges `tool` and `input` into the metadata record before forwarding to `PermissionNext.ask`: + +```ts +async ask(req) { + await PermissionNext.ask({ + ...req, + metadata: { + ...(req.metadata ?? {}), + tool: req.metadata?.tool ?? options.toolCallId ? input.processor.toolIdByCallID?.get(options.toolCallId) : undefined, + input: req.metadata?.input ?? args, + }, + sessionID: input.session.id, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), + }) +}, +``` + +**NOTE for implementer:** The tool registry id is `item.id` in the outer `for` loop at `packages/cli/src/session/prompt.ts:792-796`. If that variable is not in scope inside `context()`, capture it: inside the `for (const item of ...)` block, bind `const toolId = item.id` and reference `toolId` in the `ask` closure. Adjust the object literal above accordingly — the goal is that `metadata.tool` receives the registry id (e.g., `"bash"`) and `metadata.input` receives the raw tool args. + +Simpler version, if `item.id` is reachable: + +```ts +async ask(req) { + await PermissionNext.ask({ + ...req, + metadata: { + ...(req.metadata ?? {}), + tool: req.metadata?.tool ?? item.id, + input: req.metadata?.input ?? args, + }, + sessionID: input.session.id, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), + }) +}, +``` + +Use this simpler form if `item` / `args` are in scope; otherwise capture them into local `const` first. + +- [ ] **Step 2.3: Typecheck** + +```bash +bun turbo typecheck +``` + +Expected: PASS (0 errors). + +- [ ] **Step 2.4: Commit** + +```bash +git add packages/cli/src/session/prompt.ts +git commit -m "feat(permission): thread tool id and input into ask metadata (#63)" +``` + +--- + +## Task 3: Enriched `permission_rejected` emission + +**Files:** +- Modify: `packages/cli/src/cli/cmd/run.ts:619-637` (the `permission.asked` handler) +- Create: `packages/cli/test/cli/run-schema-v1.test.ts` + +- [ ] **Step 3.1: Write the failing test** + +Create `packages/cli/test/cli/run-schema-v1.test.ts`: + +```ts +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") + }) +}) +``` + +- [ ] **Step 3.2: Run test to verify it fails** + +```bash +bun test packages/cli/test/cli/run-schema-v1.test.ts +``` + +Expected: FAIL — current emission has only `permission` and `patterns`. + +- [ ] **Step 3.3: Update emission** + +In `packages/cli/src/cli/cmd/run.ts`, replace the `emit("permission_rejected", ...)` call (around line 622) with: + +```ts +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, +}) +``` + +- [ ] **Step 3.4: Run tests to verify pass** + +```bash +bun test packages/cli/test/cli/run-schema-v1.test.ts +bun turbo typecheck +``` + +Expected: PASS. + +- [ ] **Step 3.5: Commit** + +```bash +git add packages/cli/src/cli/cmd/run.ts packages/cli/test/cli/run-schema-v1.test.ts +git commit -m "feat(events): enrich permission_rejected with tool/input/callID (#63)" +``` + +--- + +## Task 4: `permission_granted` event + +**Files:** +- Modify: `packages/cli/src/cli/cmd/run.ts` (around the permission handler block) +- Modify: `packages/cli/test/cli/run-schema-v1.test.ts` + +- [ ] **Step 4.1: Add failing test** + +Append to `packages/cli/test/cli/run-schema-v1.test.ts`: + +```ts + 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:") + }) +``` + +- [ ] **Step 4.2: Run test to verify it fails** + +```bash +bun test packages/cli/test/cli/run-schema-v1.test.ts +``` + +Expected: FAIL on the new test. + +- [ ] **Step 4.3: Track open permission requests + subscribe to replies** + +In `packages/cli/src/cli/cmd/run.ts`, inside the `loop()` function where other `event.type` branches live (near line 619), add: + +```ts +// Track open permission requests so we can resolve granted/rejected with full metadata. +const openPermissions = new Map() + +if (event.type === "permission.asked") { + const p = event.properties + if (p.sessionID !== sessionID) continue + openPermissions.set(p.id, p) + // existing rejection code below... +} + +if (event.type === "permission.replied") { + const { sessionID: sid, requestID, reply } = event.properties + if (sid !== sessionID) continue + const p = openPermissions.get(requestID) + if (!p) continue + openPermissions.delete(requestID) + if (reply === "once" || reply === "always") { + emit("permission_granted", { + callID: p.tool?.callID, + tool: (p.metadata?.tool as string | undefined) ?? p.permission, + permission: p.permission, + patterns: p.patterns, + input: p.metadata?.input ?? null, + }) + } +} +``` + +**NOTE for implementer:** Adjust the placement of `openPermissions` declaration to live at the same scope as `childSessions` (before the `for await` loop, see `run.ts:449`). The current `permission.asked` handler auto-rejects via `PermissionNext.reply({ requestID, reply: "reject" })` — that reply will fire a `permission.replied` event we must ignore for the granted path. The `if (reply === "once" || reply === "always")` guard handles that. + +Also: an `allow`-rule match inside `PermissionNext.ask` may short-circuit without firing `permission.replied`. Inspect `packages/cli/src/permission/next.ts` near line 139 (where `RejectedError` is thrown). If there's a short-circuit allow path that doesn't emit `Replied`, add a `PermissionNext.Event.Granted` bus event (new) and fire it symmetrically. Mirror the `Replied` event definition at `packages/cli/src/permission/next.ts:99`: + +```ts +Granted: BusEvent.define( + "permission.granted", + z.object({ + sessionID: z.string(), + requestID: z.string(), + }), +), +``` + +And fire it from the short-circuit allow path. Then handle it in run.ts alongside `permission.replied`. + +- [ ] **Step 4.4: Run tests** + +```bash +bun test packages/cli/test/cli/run-schema-v1.test.ts +bun turbo typecheck +``` + +Expected: PASS. + +- [ ] **Step 4.5: Commit** + +```bash +git add packages/cli/src/cli/cmd/run.ts packages/cli/src/permission/next.ts packages/cli/test/cli/run-schema-v1.test.ts +git commit -m "feat(events): emit permission_granted symmetric to rejected (#63)" +``` + +--- + +## Task 5: `session_start` — schemaVersion + permissions + +**Files:** +- Modify: `packages/cli/src/cli/cmd/run.ts` around line 671 +- Modify: `packages/cli/test/cli/run-schema-v1.test.ts` + +- [ ] **Step 5.1: Add failing test** + +Append to `run-schema-v1.test.ts`: + +```ts + 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: "1"') + expect(block).toContain("permissions:") + }) +``` + +- [ ] **Step 5.2: Run test — expect fail** + +```bash +bun test packages/cli/test/cli/run-schema-v1.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 5.3: Compute ruleset and emit** + +In `packages/cli/src/cli/cmd/run.ts`, locate the `emit("session_start", {...})` call near line 671. Immediately before it, compute the resolved ruleset. The session and agent objects are reachable via the SDK — check how agent permission is loaded earlier in the function. A minimal approach: + +```ts +const agentEntry = agent ? await Agent.get(agent) : undefined +const sessionInfo = await sdk.session.get({ sessionID }).catch(() => undefined) +const permissions = PermissionNext.merge( + agentEntry?.permission ?? [], + sessionInfo?.permission ?? [], +) + +emit("session_start", { + schemaVersion: "1", + model: args.model, + agent: agent, + permissions, +}) +``` + +**NOTE for implementer:** The exact method to fetch the session with its resolved permission list may differ — verify against `packages/cli/src/session/index.ts` and the SDK. If the SDK session shape doesn't carry `permission`, fall back to `sessionInfo?.permission ?? []` (empty array). The test only asserts the field is present, not its value — runtime correctness is validated by integration run. + +- [ ] **Step 5.4: Run tests** + +```bash +bun test packages/cli/test/cli/run-schema-v1.test.ts +bun turbo typecheck +``` + +Expected: PASS. + +- [ ] **Step 5.5: Commit** + +```bash +git add packages/cli/src/cli/cmd/run.ts packages/cli/test/cli/run-schema-v1.test.ts +git commit -m "feat(events): add schemaVersion and permissions to session_start (#63)" +``` + +--- + +## Task 6: `session_error` event + `classifySessionError` + +**Files:** +- Create: `packages/cli/src/cli/cmd/run.errors.ts` +- Create: `packages/cli/test/cli/classify-session-error.test.ts` +- Modify: `packages/cli/src/cli/cmd/run.ts` (both `.catch` blocks around lines 683 and 723) + +- [ ] **Step 6.1: Write failing tests for the classifier** + +Create `packages/cli/test/cli/classify-session-error.test.ts`: + +```ts +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") + }) +}) +``` + +- [ ] **Step 6.2: Run test — expect module-not-found** + +```bash +bun test packages/cli/test/cli/classify-session-error.test.ts +``` + +Expected: FAIL — cannot find `run.errors`. + +- [ ] **Step 6.3: Implement the classifier** + +Create `packages/cli/src/cli/cmd/run.errors.ts`: + +```ts +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 +} +``` + +- [ ] **Step 6.4: Run tests** + +```bash +bun test packages/cli/test/cli/classify-session-error.test.ts +``` + +Expected: PASS (6/6). + +- [ ] **Step 6.5: Emit `session_error` before `session_complete` on failure** + +In `packages/cli/src/cli/cmd/run.ts`, at the top of the file add: + +```ts +import { classifySessionError } from "./run.errors" +``` + +Then update the two `.catch` blocks that call `emit("session_complete", ...)` with an `error` string. The first is around line 683: + +```ts +.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: classified.message, + }) + console.error(e) + process.exit(1) +}) +``` + +And the second around line 723: + +```ts +(e) => { + error = error ? error + EOL + String(e) : String(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: classified.message, + }) + console.error(e) + process.exit(1) +}, +``` + +The normal (success) path that emits `session_complete` with `error: error ?? null` remains unchanged. + +- [ ] **Step 6.6: Add source-level assertion to schema test** + +Append to `run-schema-v1.test.ts`: + +```ts + 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"') + const completeIdx = source.indexOf('emit("session_complete"') + expect(errIdx).toBeGreaterThan(-1) + // At least one session_error emission precedes a session_complete emission in source order. + expect(errIdx).toBeLessThan(source.lastIndexOf('emit("session_complete"')) + }) +``` + +- [ ] **Step 6.7: Run all tests + typecheck** + +```bash +bun test packages/cli/test/cli/ +bun turbo typecheck +``` + +Expected: PASS. + +- [ ] **Step 6.8: Commit** + +```bash +git add packages/cli/src/cli/cmd/run.errors.ts packages/cli/src/cli/cmd/run.ts packages/cli/test/cli/classify-session-error.test.ts packages/cli/test/cli/run-schema-v1.test.ts +git commit -m "feat(events): structured session_error event with classifier (#63)" +``` + +--- + +## Task 7: `sequenceNum` on text / reasoning / tool_use + +**Files:** +- Modify: `packages/cli/src/cli/cmd/run.ts` (the `emit` helper + call sites for `text`, `reasoning`, `tool_use`) +- Modify: `packages/cli/test/cli/run-schema-v1.test.ts` + +- [ ] **Step 7.1: Add failing test** + +Append to `run-schema-v1.test.ts`: + +```ts + test("text / reasoning / tool_use include sequenceNum", async () => { + const source = await Bun.file(RUN_SRC).text() + // The emitter wraps the three interleavable event types with a sequenceNum counter. + expect(source).toContain("sequenceNum") + // Counter keyed on session id (parent + subagents) — use a Map + expect(source).toMatch(/Map/) + }) +``` + +- [ ] **Step 7.2: Run test — expect fail** + +```bash +bun test packages/cli/test/cli/run-schema-v1.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 7.3: Add per-session counter and wrap the three emissions** + +Near the `childSessions` declaration in `run.ts` (around line 449), add: + +```ts +const seqBySession = new Map() + +function nextSeq(sid: string): number { + const n = (seqBySession.get(sid) ?? 0) + 1 + seqBySession.set(sid, n) + return n +} +``` + +Then update each of the three `emit` calls that carry a `part` field. + +For `tool_use` (both call sites around lines 487 and 493): + +```ts +emit("tool_use", { part, sequenceNum: nextSeq(part.sessionID) }) +``` + +For `text` (around line 525): + +```ts +if (emit("text", { part, sequenceNum: nextSeq(part.sessionID) })) continue +``` + +For `reasoning` (around line 538): + +```ts +if (emit("reasoning", { part, sequenceNum: nextSeq(part.sessionID) })) continue +``` + +**NOTE for implementer:** Confirm `part.sessionID` is present on all three part types. For `text` and `reasoning` it's inside `part.sessionID` on a `MessagePart`. If any part type lacks `sessionID`, fall back to the outer `sessionID` variable. + +- [ ] **Step 7.4: Run tests + typecheck** + +```bash +bun test packages/cli/test/cli/run-schema-v1.test.ts +bun turbo typecheck +``` + +Expected: PASS. + +- [ ] **Step 7.5: Commit** + +```bash +git add packages/cli/src/cli/cmd/run.ts packages/cli/test/cli/run-schema-v1.test.ts +git commit -m "feat(events): add monotonic sequenceNum to text/reasoning/tool_use (#63)" +``` + +--- + +## Task 8: `aictrl events` subcommand + bundle `EVENTS.md` + +**Files:** +- Create: `packages/cli/src/cli/cmd/events.ts` +- Modify: `packages/cli/src/index.ts` +- Modify: `packages/cli/src/headless.ts` +- Modify: `packages/cli/package.json` +- Create: `packages/cli/test/cli/events-command.test.ts` + +- [ ] **Step 8.1: Check current package.json `files` field** + +Read `packages/cli/package.json`. Identify the `files` array. + +- [ ] **Step 8.2: Add `EVENTS.md` to bundled files** + +Modify `packages/cli/package.json` to include `EVENTS.md` in `files`. Since `EVENTS.md` lives at the repo root (not inside `packages/cli`), copy it into the package build at publish time, OR reference it via the monorepo root. Simplest approach: add a build step in `packages/cli/package.json` that copies the root `EVENTS.md` into `packages/cli/` before publish, and git-ignore the copy. + +Alternative (preferred): move the authoritative `EVENTS.md` into `packages/cli/EVENTS.md` and leave a root-level symlink or short pointer. The docs file is CLI-specific so this is the right home. + +Implement as: + +1. `git mv EVENTS.md packages/cli/EVENTS.md` +2. Leave a stub `EVENTS.md` at root: `# NDJSON Events\n\nMoved to [packages/cli/EVENTS.md](packages/cli/EVENTS.md).\n` +3. Add `"EVENTS.md"` to `packages/cli/package.json` `files` array. + +- [ ] **Step 8.3: Write failing test for the command** + +Create `packages/cli/test/cli/events-command.test.ts`: + +```ts +import path from "path" +import { describe, expect, test } from "bun:test" + +const EVENTS_CMD_SRC = path.resolve(import.meta.dir, "../../src/cli/cmd/events.ts") + +describe("aictrl events (#63)", () => { + test("command module exists and exports EventsCommand", async () => { + const mod = await import(EVENTS_CMD_SRC) + expect(mod.EventsCommand).toBeDefined() + expect(typeof mod.EventsCommand).toBe("object") + }) + + test("EventsCommand has schema-version flag", async () => { + const source = await Bun.file(EVENTS_CMD_SRC).text() + expect(source).toContain("schema-version") + expect(source).toContain('"1"') + }) +}) +``` + +- [ ] **Step 8.4: Run test — expect fail** + +```bash +bun test packages/cli/test/cli/events-command.test.ts +``` + +Expected: FAIL — file does not exist. + +- [ ] **Step 8.5: Implement the command** + +Create `packages/cli/src/cli/cmd/events.ts`: + +```ts +import type { Argv } from "yargs" +import path from "path" +import { cmd } from "./cmd" + +const SCHEMA_VERSION = "1" + +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 + } + // EVENTS.md is bundled next to the package.json at publish time. + // At dev time it lives in the same directory as the cli package root. + const eventsPath = path.resolve(import.meta.dir, "../../../EVENTS.md") + const contents = await Bun.file(eventsPath).text() + process.stdout.write(contents) + }, +}) +``` + +**NOTE for implementer:** Verify the resolved path matches the published package layout. The dist/build output may flatten or nest directories differently; adjust the relative path so it finds `EVENTS.md` in both `bun run` (source) and the built published artifact. If `import.meta.dir` proves unreliable across build outputs, read the path via a build-time constant injected by the bundler, or bundle the file's contents directly via `await import("../../../EVENTS.md")` with a text loader. + +- [ ] **Step 8.6: Register the command** + +In `packages/cli/src/index.ts`, add: + +```ts +import { EventsCommand } from "./cli/cmd/events" +``` + +and in the yargs chain near line 138–147: + +```ts +.command(EventsCommand) +``` + +Mirror the addition in `packages/cli/src/headless.ts`. + +- [ ] **Step 8.7: Run tests + typecheck + smoke the command** + +```bash +bun test packages/cli/test/cli/events-command.test.ts +bun turbo typecheck +bun run dev events --schema-version +bun run dev events | head -20 +``` + +Expected: tests PASS; `--schema-version` prints `1`; `events` prints the first lines of the docs. + +- [ ] **Step 8.8: Commit** + +```bash +git add packages/cli/src/cli/cmd/events.ts packages/cli/src/index.ts packages/cli/src/headless.ts packages/cli/package.json packages/cli/EVENTS.md EVENTS.md packages/cli/test/cli/events-command.test.ts +git commit -m "feat(cli): add 'aictrl events' subcommand and bundle EVENTS.md (#63)" +``` + +--- + +## Task 9: Final verification + +- [ ] **Step 9.1: Run whole test + typecheck suite** + +```bash +bun test +bun turbo typecheck +``` + +Expected: PASS (no new failures; changed tests green). + +- [ ] **Step 9.2: Smoke an end-to-end JSON run locally** + +```bash +bun run dev run --format json "what is 1+1" 2>/dev/null | head -5 +``` + +Expected: first line is a `session_start` NDJSON event containing `"schemaVersion":"1"` and `"permissions":[...]`. + +- [ ] **Step 9.3: Push branch and open PR** + +```bash +git push -u origin feature/ndjson-schema-gaps +gh pr create --title "feat(events): close NDJSON schema gaps for downstream consumers (#63)" --body "$(cat <<'EOF' +## Summary +- Stabilizes \`permission_rejected\` shape (tool/input/callID); adds symmetric \`permission_granted\` +- Adds structured \`session_error\` event; deprecates \`session_complete.error\` string +- Echoes resolved permission ruleset + \`schemaVersion: "1"\` on \`session_start\` +- Adds monotonic \`sequenceNum\` to \`text\`/\`reasoning\`/\`tool_use\` for correct interleaving +- Bundles \`EVENTS.md\` in npm package and adds \`aictrl events [--schema-version]\` + +Closes #63. + +## Test plan +- [ ] \`bun test packages/cli/test/cli/\` green +- [ ] \`bun turbo typecheck\` green +- [ ] \`bun run dev events --schema-version\` prints \`1\` +- [ ] End-to-end \`aictrl run --format json\` shows \`schemaVersion\` on session_start and enriched permission events when a tool is denied + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** Tasks 1 (docs), 2–4 (permission events), 5 (session_start), 6 (session_error), 7 (sequenceNum), 8 (publishing) map 1:1 to the six scope items. ✅ +- **Placeholder scan:** NOTE-for-implementer blocks are implementation hints requiring verification against runtime code, not TBDs. They call out specific facts the implementer must confirm with line numbers. ✅ +- **Type consistency:** `ClassifiedSessionError.reason` values (`rate_limit` etc.) match the EVENTS.md documentation vocabulary. `SCHEMA_VERSION = "1"` constant matches the string literal expected in `run.ts` and EVENTS.md. ✅ +- **Known unknowns flagged inline:** session/agent permission resolution path (Task 5), short-circuit-allow handling in `PermissionNext` (Task 4), bundler path for `EVENTS.md` (Task 8). The implementer must verify these against the concrete codebase rather than accept the plan's hint blindly. From ebf9f63d6c24a9a0de1e2232bdc75aace7d13491 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 15:14:57 +0100 Subject: [PATCH 03/14] feat(permission): thread tool id and input into ask metadata (#63) --- packages/cli/src/session/prompt.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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", From 5d7343e6128de14d0a65f1ffca4f0fe28ed04d9f Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 15:16:26 +0100 Subject: [PATCH 04/14] feat(events): enrich permission_rejected with tool/input/callID (#63) --- packages/cli/src/cli/cmd/run.ts | 3 +++ packages/cli/test/cli/run-schema-v1.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 packages/cli/test/cli/run-schema-v1.test.ts diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 8679e6b..07741b6 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -620,8 +620,11 @@ export const RunCommand = cmd({ const permission = event.properties if (permission.sessionID !== 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( 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..d8a585e --- /dev/null +++ b/packages/cli/test/cli/run-schema-v1.test.ts @@ -0,0 +1,18 @@ +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") + }) +}) From 919d1537f111d4f6582a8471a99f964476473a59 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 15:18:30 +0100 Subject: [PATCH 05/14] feat(events): emit permission_granted symmetric to rejected (#63) --- packages/cli/src/cli/cmd/run.ts | 12 ++++++++++++ packages/cli/src/permission/next.ts | 5 +++++ packages/cli/test/cli/run-schema-v1.test.ts | 10 ++++++++++ 3 files changed, 27 insertions(+) diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 07741b6..eef9fc2 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -638,6 +638,18 @@ export const RunCommand = cmd({ reply: "reject", }) } + + if (event.type === "permission.granted") { + const permission = event.properties + if (permission.sessionID !== 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, + }) + } } } diff --git a/packages/cli/src/permission/next.ts b/packages/cli/src/permission/next.ts index 1e1df62..f94d566 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, { id, ...request } as Request) + } }, ) diff --git a/packages/cli/test/cli/run-schema-v1.test.ts b/packages/cli/test/cli/run-schema-v1.test.ts index d8a585e..2fca790 100644 --- a/packages/cli/test/cli/run-schema-v1.test.ts +++ b/packages/cli/test/cli/run-schema-v1.test.ts @@ -15,4 +15,14 @@ describe("run.ts v1 schema emissions (#63)", () => { 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:") + }) }) From 8d1fe42388f847343b0ff23a75b0122995caf56f Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 15:20:18 +0100 Subject: [PATCH 06/14] feat(events): add schemaVersion and permissions to session_start (#63) --- packages/cli/src/cli/cmd/run.ts | 13 ++++++++----- packages/cli/test/cli/run-schema-v1.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index eef9fc2..d767312 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -654,8 +654,8 @@ export const RunCommand = cmd({ } // 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( @@ -663,7 +663,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( @@ -671,10 +671,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) { @@ -684,8 +685,10 @@ export const RunCommand = cmd({ await share(sdk, sessionID) emit("session_start", { + schemaVersion: "1", model: args.model, agent: agent, + permissions: PermissionNext.merge(agentInfo.permission, rules), }) const loopDone = loop() diff --git a/packages/cli/test/cli/run-schema-v1.test.ts b/packages/cli/test/cli/run-schema-v1.test.ts index 2fca790..ab58a8f 100644 --- a/packages/cli/test/cli/run-schema-v1.test.ts +++ b/packages/cli/test/cli/run-schema-v1.test.ts @@ -25,4 +25,13 @@ describe("run.ts v1 schema emissions (#63)", () => { 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: "1"') + expect(block).toContain("permissions:") + }) }) From c6b7344f8eb1687005145be9843c85ae38cb16ae Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 15:22:59 +0100 Subject: [PATCH 07/14] feat(events): structured session_error event with classifier (#63) --- packages/cli/src/cli/cmd/run.errors.ts | 49 +++++++++++++++++++ packages/cli/src/cli/cmd/run.ts | 17 ++++++- .../test/cli/classify-session-error.test.ts | 37 ++++++++++++++ packages/cli/test/cli/run-schema-v1.test.ts | 7 +++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/cli/cmd/run.errors.ts create mode 100644 packages/cli/test/cli/classify-session-error.test.ts 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..80cd4b3 --- /dev/null +++ b/packages/cli/src/cli/cmd/run.errors.ts @@ -0,0 +1,49 @@ +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 d767312..381711a 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 } from "./run.errors" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" import { EOL } from "os" @@ -699,9 +700,15 @@ 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) @@ -740,9 +747,15 @@ export const RunCommand = cmd({ // If prompt rejects, surface the error immediately (e) => { error = error ? error + EOL + String(e) : String(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) 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/run-schema-v1.test.ts b/packages/cli/test/cli/run-schema-v1.test.ts index ab58a8f..0f22b72 100644 --- a/packages/cli/test/cli/run-schema-v1.test.ts +++ b/packages/cli/test/cli/run-schema-v1.test.ts @@ -34,4 +34,11 @@ describe("run.ts v1 schema emissions (#63)", () => { expect(block).toContain('schemaVersion: "1"') 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"')) + }) }) From 56e5091b347b447ff067562815656d0271a16c7f Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 15:24:31 +0100 Subject: [PATCH 08/14] feat(events): add monotonic sequenceNum to text/reasoning/tool_use (#63) --- packages/cli/src/cli/cmd/run.ts | 14 ++++++++++---- packages/cli/test/cli/run-schema-v1.test.ts | 6 ++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 381711a..98f01b8 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -448,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() @@ -485,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 @@ -523,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) { @@ -536,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}` diff --git a/packages/cli/test/cli/run-schema-v1.test.ts b/packages/cli/test/cli/run-schema-v1.test.ts index 0f22b72..2934c01 100644 --- a/packages/cli/test/cli/run-schema-v1.test.ts +++ b/packages/cli/test/cli/run-schema-v1.test.ts @@ -41,4 +41,10 @@ describe("run.ts v1 schema emissions (#63)", () => { 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/) + }) }) From a21ab2386b459c0df7e05ddf006ddbba01e5a1ae Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 15:28:10 +0100 Subject: [PATCH 09/14] feat(cli): add 'aictrl events' subcommand with embedded EVENTS.md (#63) Embed EVENTS.md via Bun's text import so the schema ships with both `bun run dev` and the compiled single-file binary. Add `--schema-version` flag for quick version discovery. Register command in both index.ts and headless.ts entrypoints. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/cli/cmd/events.ts | 23 ++++++++++++++++++++ packages/cli/src/headless.ts | 2 ++ packages/cli/src/index.ts | 2 ++ packages/cli/src/md.d.ts | 4 ++++ packages/cli/test/cli/events-command.test.ts | 13 +++++++++++ 5 files changed, 44 insertions(+) create mode 100644 packages/cli/src/cli/cmd/events.ts create mode 100644 packages/cli/src/md.d.ts create mode 100644 packages/cli/test/cli/events-command.test.ts diff --git a/packages/cli/src/cli/cmd/events.ts b/packages/cli/src/cli/cmd/events.ts new file mode 100644 index 0000000..d95f421 --- /dev/null +++ b/packages/cli/src/cli/cmd/events.ts @@ -0,0 +1,23 @@ +import type { Argv } from "yargs" +import { cmd } from "./cmd" +import EVENTS_MD from "../../../../../EVENTS.md" with { type: "text" } + +const SCHEMA_VERSION = "1" + +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 as unknown as string) + }, +}) 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/test/cli/events-command.test.ts b/packages/cli/test/cli/events-command.test.ts new file mode 100644 index 0000000..5dd9eef --- /dev/null +++ b/packages/cli/test/cli/events-command.test.ts @@ -0,0 +1,13 @@ +import path from "path" +import { describe, expect, test } from "bun:test" + +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('"1"') + }) +}) From 30334d8dbef38dfcddd7d5d95172d5589bde013a Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 15:12:58 +0100 Subject: [PATCH 10/14] docs(events): document v1 schema shapes for #63 --- EVENTS.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index 40d5435..d65f99f 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,25 @@ 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. + +### `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 +102,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`. + ### `reasoning` Emitted when extended thinking content is complete (requires `--thinking` flag). @@ -88,6 +119,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`. +- 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 +144,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`. + `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 +251,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" } } ``` From a99c6ba267c5093fc664e3950ca523d5e37e25fa Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 16:00:56 +0100 Subject: [PATCH 11/14] fix: address review on PR #64 - next.ts: reverse spread order in Event.Granted publish so computed id always wins over any id that might appear in request (latent bug). - run.errors.ts: export SCHEMA_VERSION as the shared source of truth; events.ts and run.ts now import it instead of duplicating "1". - events.ts: drop unnecessary `as unknown as string` cast; md.d.ts already types the import as string. - run.ts: in the prompt-rejection catch, use classified.message for the local error string too, so session_complete.error matches session_error. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/cli/cmd/events.ts | 5 ++--- packages/cli/src/cli/cmd/run.errors.ts | 2 ++ packages/cli/src/cli/cmd/run.ts | 6 +++--- packages/cli/src/permission/next.ts | 2 +- packages/cli/test/cli/events-command.test.ts | 7 ++++++- packages/cli/test/cli/run-schema-v1.test.ts | 2 +- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cli/cmd/events.ts b/packages/cli/src/cli/cmd/events.ts index d95f421..f777ee2 100644 --- a/packages/cli/src/cli/cmd/events.ts +++ b/packages/cli/src/cli/cmd/events.ts @@ -1,9 +1,8 @@ import type { Argv } from "yargs" import { cmd } from "./cmd" +import { SCHEMA_VERSION } from "./run.errors" import EVENTS_MD from "../../../../../EVENTS.md" with { type: "text" } -const SCHEMA_VERSION = "1" - export const EventsCommand = cmd({ command: "events", describe: "print the NDJSON event schema (EVENTS.md) bundled with this CLI", @@ -18,6 +17,6 @@ export const EventsCommand = cmd({ process.stdout.write(SCHEMA_VERSION + "\n") return } - process.stdout.write(EVENTS_MD as unknown as string) + 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 index 80cd4b3..adb031e 100644 --- a/packages/cli/src/cli/cmd/run.errors.ts +++ b/packages/cli/src/cli/cmd/run.errors.ts @@ -1,3 +1,5 @@ +export const SCHEMA_VERSION = "1" + export type SessionErrorReason = "rate_limit" | "auth" | "timeout" | "oom" | "provider" | "unknown" export type ClassifiedSessionError = { diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 98f01b8..82ee584 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -3,7 +3,7 @@ import path from "path" import { pathToFileURL } from "bun" import { UI } from "../ui" import { cmd } from "./cmd" -import { classifySessionError } from "./run.errors" +import { classifySessionError, SCHEMA_VERSION } from "./run.errors" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" import { EOL } from "os" @@ -692,7 +692,7 @@ export const RunCommand = cmd({ await share(sdk, sessionID) emit("session_start", { - schemaVersion: "1", + schemaVersion: SCHEMA_VERSION, model: args.model, agent: agent, permissions: PermissionNext.merge(agentInfo.permission, rules), @@ -752,8 +752,8 @@ 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, diff --git a/packages/cli/src/permission/next.ts b/packages/cli/src/permission/next.ts index f94d566..fec642d 100644 --- a/packages/cli/src/permission/next.ts +++ b/packages/cli/src/permission/next.ts @@ -160,7 +160,7 @@ export namespace PermissionNext { } if ((request.patterns ?? []).length > 0) { const id = input.id ?? Identifier.ascending("permission") - Bus.publish(Event.Granted, { id, ...request } as Request) + Bus.publish(Event.Granted, { ...request, id } as Request) } }, ) diff --git a/packages/cli/test/cli/events-command.test.ts b/packages/cli/test/cli/events-command.test.ts index 5dd9eef..0221cb0 100644 --- a/packages/cli/test/cli/events-command.test.ts +++ b/packages/cli/test/cli/events-command.test.ts @@ -1,5 +1,6 @@ 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") @@ -8,6 +9,10 @@ describe("aictrl events (#63)", () => { const source = await Bun.file(EVENTS_CMD_SRC).text() expect(source).toContain("export const EventsCommand") expect(source).toContain("schema-version") - expect(source).toContain('"1"') + 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 index 2934c01..51a7b87 100644 --- a/packages/cli/test/cli/run-schema-v1.test.ts +++ b/packages/cli/test/cli/run-schema-v1.test.ts @@ -31,7 +31,7 @@ describe("run.ts v1 schema emissions (#63)", () => { const idx = source.indexOf('emit("session_start"') expect(idx).toBeGreaterThan(-1) const block = source.slice(idx, idx + 400) - expect(block).toContain('schemaVersion: "1"') + expect(block).toContain("schemaVersion: SCHEMA_VERSION") expect(block).toContain("permissions:") }) From a95d7dcba014be5a509c5feabdc07d0790d9a566 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 16:09:01 +0100 Subject: [PATCH 12/14] chore: drop internal planning docs from branch --- .../plans/2026-04-23-ndjson-schema-gaps.md | 950 ------------------ .../2026-04-23-ndjson-schema-gaps-design.md | 211 ---- 2 files changed, 1161 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-23-ndjson-schema-gaps.md delete mode 100644 docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md diff --git a/docs/superpowers/plans/2026-04-23-ndjson-schema-gaps.md b/docs/superpowers/plans/2026-04-23-ndjson-schema-gaps.md deleted file mode 100644 index 4fd2f41..0000000 --- a/docs/superpowers/plans/2026-04-23-ndjson-schema-gaps.md +++ /dev/null @@ -1,950 +0,0 @@ -# NDJSON Event Schema Gaps Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close five NDJSON event schema gaps from issue #63 so the downstream aictrl executor can consume the stream without defensive shape-guessing. - -**Architecture:** All NDJSON emission is centralized in `packages/cli/src/cli/cmd/run.ts`. Permission events pass through `PermissionNext` (`packages/cli/src/permission/next.ts`) carrying a `metadata` record we can enrich at tool-execution time in `packages/cli/src/session/prompt.ts`. The `emit(type, data)` helper at `run.ts:438` automatically injects `type`/`timestamp`/`sessionID`, so tasks only add business-specific fields. - -**Tech Stack:** TypeScript, bun, yargs (CLI commands), bun:test (tests), zod (schemas on the permission bus). - -**Spec:** `docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md` - ---- - -## File Structure - -| Path | Role | Change | -|---|---|---| -| `EVENTS.md` | Public event schema doc | Modify | -| `packages/cli/package.json` | Add `EVENTS.md` to `files` | Modify | -| `packages/cli/src/cli/cmd/run.ts` | Event emission site | Modify (primary work) | -| `packages/cli/src/cli/cmd/run.errors.ts` | `classifySessionError` helper | Create | -| `packages/cli/src/cli/cmd/events.ts` | `aictrl events` subcommand | Create | -| `packages/cli/src/index.ts` | Register `EventsCommand` | Modify | -| `packages/cli/src/headless.ts` | Register `EventsCommand` | Modify | -| `packages/cli/src/session/prompt.ts` | Thread tool id/input into `ctx.ask` metadata | Modify | -| `packages/cli/test/cli/run-schema-v1.test.ts` | Source-level assertions on emissions | Create | -| `packages/cli/test/cli/classify-session-error.test.ts` | Unit tests for classifier | Create | -| `packages/cli/test/cli/events-command.test.ts` | Smoke test for `aictrl events` | Create | - ---- - -## Task 1: EVENTS.md — document v1 shapes - -**Files:** -- Modify: `EVENTS.md` (whole file rewrite for the changed sections) - -- [ ] **Step 1.1: Add top-level schema version note** - -Insert after the "base shape" paragraph at the top: - -```markdown -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. -``` - -- [ ] **Step 1.2: Update `session_start` section** - -Replace the current `session_start` example block with: - -````markdown -### `session_start` - -Emitted once when the session begins. - -```json -{ - "type": "session_start", - "schemaVersion": "1", - "model": "anthropic/claude-sonnet-4-20250514", - "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. -```` - -- [ ] **Step 1.3: Update `session_complete` section — mark `error` deprecated** - -Replace the trailing paragraph under `session_complete` with: - -```markdown -`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. -``` - -- [ ] **Step 1.4: Add `session_error` section** - -Insert a new subsection after `session_complete`: - -````markdown -### `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. -```` - -- [ ] **Step 1.5: Rewrite `permission_rejected` section** - -Replace the `permission_rejected` block with: - -````markdown -### `permission_rejected` - -Emitted when a permission request is auto-rejected (headless mode has no user to approve). - -```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). -```` - -- [ ] **Step 1.6: Add `permission_granted` section** - -Insert immediately after `permission_rejected`: - -````markdown -### `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": ["ls"], - "input": { "command": "ls" } -} -``` -```` - -- [ ] **Step 1.7: Add `sequenceNum` note to reasoning/text/tool_use** - -Under each of `text`, `reasoning`, and `tool_use`, add a bullet after the example: - -```markdown -- `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`. -``` - -Under `reasoning`, add a second bullet: - -```markdown -- One event is emitted per complete reasoning block. `part.text` has no size cap; consumers must accept arbitrarily large strings. -``` - -- [ ] **Step 1.8: Commit** - -```bash -git add EVENTS.md -git commit -m "docs(events): document v1 schema shapes for #63" -``` - ---- - -## Task 2: Thread tool id + input into permission metadata - -**Files:** -- Modify: `packages/cli/src/session/prompt.ts:782-789` - -- [ ] **Step 2.1: Read the existing ask closure** - -Read lines 770–800 of `packages/cli/src/session/prompt.ts` to confirm current structure. The per-tool loop starting at line 792 creates a `context(args, options)` that wraps `ctx.ask`. The closure has access to `item.id` (tool name) and `args` (tool arguments). - -- [ ] **Step 2.2: Enrich `ctx.ask` metadata** - -Locate the `ask` closure inside `context()` (around line 782). The `req` object is what a tool passes to `ctx.ask`. Change the body so it merges `tool` and `input` into the metadata record before forwarding to `PermissionNext.ask`: - -```ts -async ask(req) { - await PermissionNext.ask({ - ...req, - metadata: { - ...(req.metadata ?? {}), - tool: req.metadata?.tool ?? options.toolCallId ? input.processor.toolIdByCallID?.get(options.toolCallId) : undefined, - input: req.metadata?.input ?? args, - }, - sessionID: input.session.id, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), - }) -}, -``` - -**NOTE for implementer:** The tool registry id is `item.id` in the outer `for` loop at `packages/cli/src/session/prompt.ts:792-796`. If that variable is not in scope inside `context()`, capture it: inside the `for (const item of ...)` block, bind `const toolId = item.id` and reference `toolId` in the `ask` closure. Adjust the object literal above accordingly — the goal is that `metadata.tool` receives the registry id (e.g., `"bash"`) and `metadata.input` receives the raw tool args. - -Simpler version, if `item.id` is reachable: - -```ts -async ask(req) { - await PermissionNext.ask({ - ...req, - metadata: { - ...(req.metadata ?? {}), - tool: req.metadata?.tool ?? item.id, - input: req.metadata?.input ?? args, - }, - sessionID: input.session.id, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), - }) -}, -``` - -Use this simpler form if `item` / `args` are in scope; otherwise capture them into local `const` first. - -- [ ] **Step 2.3: Typecheck** - -```bash -bun turbo typecheck -``` - -Expected: PASS (0 errors). - -- [ ] **Step 2.4: Commit** - -```bash -git add packages/cli/src/session/prompt.ts -git commit -m "feat(permission): thread tool id and input into ask metadata (#63)" -``` - ---- - -## Task 3: Enriched `permission_rejected` emission - -**Files:** -- Modify: `packages/cli/src/cli/cmd/run.ts:619-637` (the `permission.asked` handler) -- Create: `packages/cli/test/cli/run-schema-v1.test.ts` - -- [ ] **Step 3.1: Write the failing test** - -Create `packages/cli/test/cli/run-schema-v1.test.ts`: - -```ts -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") - }) -}) -``` - -- [ ] **Step 3.2: Run test to verify it fails** - -```bash -bun test packages/cli/test/cli/run-schema-v1.test.ts -``` - -Expected: FAIL — current emission has only `permission` and `patterns`. - -- [ ] **Step 3.3: Update emission** - -In `packages/cli/src/cli/cmd/run.ts`, replace the `emit("permission_rejected", ...)` call (around line 622) with: - -```ts -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, -}) -``` - -- [ ] **Step 3.4: Run tests to verify pass** - -```bash -bun test packages/cli/test/cli/run-schema-v1.test.ts -bun turbo typecheck -``` - -Expected: PASS. - -- [ ] **Step 3.5: Commit** - -```bash -git add packages/cli/src/cli/cmd/run.ts packages/cli/test/cli/run-schema-v1.test.ts -git commit -m "feat(events): enrich permission_rejected with tool/input/callID (#63)" -``` - ---- - -## Task 4: `permission_granted` event - -**Files:** -- Modify: `packages/cli/src/cli/cmd/run.ts` (around the permission handler block) -- Modify: `packages/cli/test/cli/run-schema-v1.test.ts` - -- [ ] **Step 4.1: Add failing test** - -Append to `packages/cli/test/cli/run-schema-v1.test.ts`: - -```ts - 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:") - }) -``` - -- [ ] **Step 4.2: Run test to verify it fails** - -```bash -bun test packages/cli/test/cli/run-schema-v1.test.ts -``` - -Expected: FAIL on the new test. - -- [ ] **Step 4.3: Track open permission requests + subscribe to replies** - -In `packages/cli/src/cli/cmd/run.ts`, inside the `loop()` function where other `event.type` branches live (near line 619), add: - -```ts -// Track open permission requests so we can resolve granted/rejected with full metadata. -const openPermissions = new Map() - -if (event.type === "permission.asked") { - const p = event.properties - if (p.sessionID !== sessionID) continue - openPermissions.set(p.id, p) - // existing rejection code below... -} - -if (event.type === "permission.replied") { - const { sessionID: sid, requestID, reply } = event.properties - if (sid !== sessionID) continue - const p = openPermissions.get(requestID) - if (!p) continue - openPermissions.delete(requestID) - if (reply === "once" || reply === "always") { - emit("permission_granted", { - callID: p.tool?.callID, - tool: (p.metadata?.tool as string | undefined) ?? p.permission, - permission: p.permission, - patterns: p.patterns, - input: p.metadata?.input ?? null, - }) - } -} -``` - -**NOTE for implementer:** Adjust the placement of `openPermissions` declaration to live at the same scope as `childSessions` (before the `for await` loop, see `run.ts:449`). The current `permission.asked` handler auto-rejects via `PermissionNext.reply({ requestID, reply: "reject" })` — that reply will fire a `permission.replied` event we must ignore for the granted path. The `if (reply === "once" || reply === "always")` guard handles that. - -Also: an `allow`-rule match inside `PermissionNext.ask` may short-circuit without firing `permission.replied`. Inspect `packages/cli/src/permission/next.ts` near line 139 (where `RejectedError` is thrown). If there's a short-circuit allow path that doesn't emit `Replied`, add a `PermissionNext.Event.Granted` bus event (new) and fire it symmetrically. Mirror the `Replied` event definition at `packages/cli/src/permission/next.ts:99`: - -```ts -Granted: BusEvent.define( - "permission.granted", - z.object({ - sessionID: z.string(), - requestID: z.string(), - }), -), -``` - -And fire it from the short-circuit allow path. Then handle it in run.ts alongside `permission.replied`. - -- [ ] **Step 4.4: Run tests** - -```bash -bun test packages/cli/test/cli/run-schema-v1.test.ts -bun turbo typecheck -``` - -Expected: PASS. - -- [ ] **Step 4.5: Commit** - -```bash -git add packages/cli/src/cli/cmd/run.ts packages/cli/src/permission/next.ts packages/cli/test/cli/run-schema-v1.test.ts -git commit -m "feat(events): emit permission_granted symmetric to rejected (#63)" -``` - ---- - -## Task 5: `session_start` — schemaVersion + permissions - -**Files:** -- Modify: `packages/cli/src/cli/cmd/run.ts` around line 671 -- Modify: `packages/cli/test/cli/run-schema-v1.test.ts` - -- [ ] **Step 5.1: Add failing test** - -Append to `run-schema-v1.test.ts`: - -```ts - 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: "1"') - expect(block).toContain("permissions:") - }) -``` - -- [ ] **Step 5.2: Run test — expect fail** - -```bash -bun test packages/cli/test/cli/run-schema-v1.test.ts -``` - -Expected: FAIL. - -- [ ] **Step 5.3: Compute ruleset and emit** - -In `packages/cli/src/cli/cmd/run.ts`, locate the `emit("session_start", {...})` call near line 671. Immediately before it, compute the resolved ruleset. The session and agent objects are reachable via the SDK — check how agent permission is loaded earlier in the function. A minimal approach: - -```ts -const agentEntry = agent ? await Agent.get(agent) : undefined -const sessionInfo = await sdk.session.get({ sessionID }).catch(() => undefined) -const permissions = PermissionNext.merge( - agentEntry?.permission ?? [], - sessionInfo?.permission ?? [], -) - -emit("session_start", { - schemaVersion: "1", - model: args.model, - agent: agent, - permissions, -}) -``` - -**NOTE for implementer:** The exact method to fetch the session with its resolved permission list may differ — verify against `packages/cli/src/session/index.ts` and the SDK. If the SDK session shape doesn't carry `permission`, fall back to `sessionInfo?.permission ?? []` (empty array). The test only asserts the field is present, not its value — runtime correctness is validated by integration run. - -- [ ] **Step 5.4: Run tests** - -```bash -bun test packages/cli/test/cli/run-schema-v1.test.ts -bun turbo typecheck -``` - -Expected: PASS. - -- [ ] **Step 5.5: Commit** - -```bash -git add packages/cli/src/cli/cmd/run.ts packages/cli/test/cli/run-schema-v1.test.ts -git commit -m "feat(events): add schemaVersion and permissions to session_start (#63)" -``` - ---- - -## Task 6: `session_error` event + `classifySessionError` - -**Files:** -- Create: `packages/cli/src/cli/cmd/run.errors.ts` -- Create: `packages/cli/test/cli/classify-session-error.test.ts` -- Modify: `packages/cli/src/cli/cmd/run.ts` (both `.catch` blocks around lines 683 and 723) - -- [ ] **Step 6.1: Write failing tests for the classifier** - -Create `packages/cli/test/cli/classify-session-error.test.ts`: - -```ts -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") - }) -}) -``` - -- [ ] **Step 6.2: Run test — expect module-not-found** - -```bash -bun test packages/cli/test/cli/classify-session-error.test.ts -``` - -Expected: FAIL — cannot find `run.errors`. - -- [ ] **Step 6.3: Implement the classifier** - -Create `packages/cli/src/cli/cmd/run.errors.ts`: - -```ts -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 -} -``` - -- [ ] **Step 6.4: Run tests** - -```bash -bun test packages/cli/test/cli/classify-session-error.test.ts -``` - -Expected: PASS (6/6). - -- [ ] **Step 6.5: Emit `session_error` before `session_complete` on failure** - -In `packages/cli/src/cli/cmd/run.ts`, at the top of the file add: - -```ts -import { classifySessionError } from "./run.errors" -``` - -Then update the two `.catch` blocks that call `emit("session_complete", ...)` with an `error` string. The first is around line 683: - -```ts -.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: classified.message, - }) - console.error(e) - process.exit(1) -}) -``` - -And the second around line 723: - -```ts -(e) => { - error = error ? error + EOL + String(e) : String(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: classified.message, - }) - console.error(e) - process.exit(1) -}, -``` - -The normal (success) path that emits `session_complete` with `error: error ?? null` remains unchanged. - -- [ ] **Step 6.6: Add source-level assertion to schema test** - -Append to `run-schema-v1.test.ts`: - -```ts - 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"') - const completeIdx = source.indexOf('emit("session_complete"') - expect(errIdx).toBeGreaterThan(-1) - // At least one session_error emission precedes a session_complete emission in source order. - expect(errIdx).toBeLessThan(source.lastIndexOf('emit("session_complete"')) - }) -``` - -- [ ] **Step 6.7: Run all tests + typecheck** - -```bash -bun test packages/cli/test/cli/ -bun turbo typecheck -``` - -Expected: PASS. - -- [ ] **Step 6.8: Commit** - -```bash -git add packages/cli/src/cli/cmd/run.errors.ts packages/cli/src/cli/cmd/run.ts packages/cli/test/cli/classify-session-error.test.ts packages/cli/test/cli/run-schema-v1.test.ts -git commit -m "feat(events): structured session_error event with classifier (#63)" -``` - ---- - -## Task 7: `sequenceNum` on text / reasoning / tool_use - -**Files:** -- Modify: `packages/cli/src/cli/cmd/run.ts` (the `emit` helper + call sites for `text`, `reasoning`, `tool_use`) -- Modify: `packages/cli/test/cli/run-schema-v1.test.ts` - -- [ ] **Step 7.1: Add failing test** - -Append to `run-schema-v1.test.ts`: - -```ts - test("text / reasoning / tool_use include sequenceNum", async () => { - const source = await Bun.file(RUN_SRC).text() - // The emitter wraps the three interleavable event types with a sequenceNum counter. - expect(source).toContain("sequenceNum") - // Counter keyed on session id (parent + subagents) — use a Map - expect(source).toMatch(/Map/) - }) -``` - -- [ ] **Step 7.2: Run test — expect fail** - -```bash -bun test packages/cli/test/cli/run-schema-v1.test.ts -``` - -Expected: FAIL. - -- [ ] **Step 7.3: Add per-session counter and wrap the three emissions** - -Near the `childSessions` declaration in `run.ts` (around line 449), add: - -```ts -const seqBySession = new Map() - -function nextSeq(sid: string): number { - const n = (seqBySession.get(sid) ?? 0) + 1 - seqBySession.set(sid, n) - return n -} -``` - -Then update each of the three `emit` calls that carry a `part` field. - -For `tool_use` (both call sites around lines 487 and 493): - -```ts -emit("tool_use", { part, sequenceNum: nextSeq(part.sessionID) }) -``` - -For `text` (around line 525): - -```ts -if (emit("text", { part, sequenceNum: nextSeq(part.sessionID) })) continue -``` - -For `reasoning` (around line 538): - -```ts -if (emit("reasoning", { part, sequenceNum: nextSeq(part.sessionID) })) continue -``` - -**NOTE for implementer:** Confirm `part.sessionID` is present on all three part types. For `text` and `reasoning` it's inside `part.sessionID` on a `MessagePart`. If any part type lacks `sessionID`, fall back to the outer `sessionID` variable. - -- [ ] **Step 7.4: Run tests + typecheck** - -```bash -bun test packages/cli/test/cli/run-schema-v1.test.ts -bun turbo typecheck -``` - -Expected: PASS. - -- [ ] **Step 7.5: Commit** - -```bash -git add packages/cli/src/cli/cmd/run.ts packages/cli/test/cli/run-schema-v1.test.ts -git commit -m "feat(events): add monotonic sequenceNum to text/reasoning/tool_use (#63)" -``` - ---- - -## Task 8: `aictrl events` subcommand + bundle `EVENTS.md` - -**Files:** -- Create: `packages/cli/src/cli/cmd/events.ts` -- Modify: `packages/cli/src/index.ts` -- Modify: `packages/cli/src/headless.ts` -- Modify: `packages/cli/package.json` -- Create: `packages/cli/test/cli/events-command.test.ts` - -- [ ] **Step 8.1: Check current package.json `files` field** - -Read `packages/cli/package.json`. Identify the `files` array. - -- [ ] **Step 8.2: Add `EVENTS.md` to bundled files** - -Modify `packages/cli/package.json` to include `EVENTS.md` in `files`. Since `EVENTS.md` lives at the repo root (not inside `packages/cli`), copy it into the package build at publish time, OR reference it via the monorepo root. Simplest approach: add a build step in `packages/cli/package.json` that copies the root `EVENTS.md` into `packages/cli/` before publish, and git-ignore the copy. - -Alternative (preferred): move the authoritative `EVENTS.md` into `packages/cli/EVENTS.md` and leave a root-level symlink or short pointer. The docs file is CLI-specific so this is the right home. - -Implement as: - -1. `git mv EVENTS.md packages/cli/EVENTS.md` -2. Leave a stub `EVENTS.md` at root: `# NDJSON Events\n\nMoved to [packages/cli/EVENTS.md](packages/cli/EVENTS.md).\n` -3. Add `"EVENTS.md"` to `packages/cli/package.json` `files` array. - -- [ ] **Step 8.3: Write failing test for the command** - -Create `packages/cli/test/cli/events-command.test.ts`: - -```ts -import path from "path" -import { describe, expect, test } from "bun:test" - -const EVENTS_CMD_SRC = path.resolve(import.meta.dir, "../../src/cli/cmd/events.ts") - -describe("aictrl events (#63)", () => { - test("command module exists and exports EventsCommand", async () => { - const mod = await import(EVENTS_CMD_SRC) - expect(mod.EventsCommand).toBeDefined() - expect(typeof mod.EventsCommand).toBe("object") - }) - - test("EventsCommand has schema-version flag", async () => { - const source = await Bun.file(EVENTS_CMD_SRC).text() - expect(source).toContain("schema-version") - expect(source).toContain('"1"') - }) -}) -``` - -- [ ] **Step 8.4: Run test — expect fail** - -```bash -bun test packages/cli/test/cli/events-command.test.ts -``` - -Expected: FAIL — file does not exist. - -- [ ] **Step 8.5: Implement the command** - -Create `packages/cli/src/cli/cmd/events.ts`: - -```ts -import type { Argv } from "yargs" -import path from "path" -import { cmd } from "./cmd" - -const SCHEMA_VERSION = "1" - -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 - } - // EVENTS.md is bundled next to the package.json at publish time. - // At dev time it lives in the same directory as the cli package root. - const eventsPath = path.resolve(import.meta.dir, "../../../EVENTS.md") - const contents = await Bun.file(eventsPath).text() - process.stdout.write(contents) - }, -}) -``` - -**NOTE for implementer:** Verify the resolved path matches the published package layout. The dist/build output may flatten or nest directories differently; adjust the relative path so it finds `EVENTS.md` in both `bun run` (source) and the built published artifact. If `import.meta.dir` proves unreliable across build outputs, read the path via a build-time constant injected by the bundler, or bundle the file's contents directly via `await import("../../../EVENTS.md")` with a text loader. - -- [ ] **Step 8.6: Register the command** - -In `packages/cli/src/index.ts`, add: - -```ts -import { EventsCommand } from "./cli/cmd/events" -``` - -and in the yargs chain near line 138–147: - -```ts -.command(EventsCommand) -``` - -Mirror the addition in `packages/cli/src/headless.ts`. - -- [ ] **Step 8.7: Run tests + typecheck + smoke the command** - -```bash -bun test packages/cli/test/cli/events-command.test.ts -bun turbo typecheck -bun run dev events --schema-version -bun run dev events | head -20 -``` - -Expected: tests PASS; `--schema-version` prints `1`; `events` prints the first lines of the docs. - -- [ ] **Step 8.8: Commit** - -```bash -git add packages/cli/src/cli/cmd/events.ts packages/cli/src/index.ts packages/cli/src/headless.ts packages/cli/package.json packages/cli/EVENTS.md EVENTS.md packages/cli/test/cli/events-command.test.ts -git commit -m "feat(cli): add 'aictrl events' subcommand and bundle EVENTS.md (#63)" -``` - ---- - -## Task 9: Final verification - -- [ ] **Step 9.1: Run whole test + typecheck suite** - -```bash -bun test -bun turbo typecheck -``` - -Expected: PASS (no new failures; changed tests green). - -- [ ] **Step 9.2: Smoke an end-to-end JSON run locally** - -```bash -bun run dev run --format json "what is 1+1" 2>/dev/null | head -5 -``` - -Expected: first line is a `session_start` NDJSON event containing `"schemaVersion":"1"` and `"permissions":[...]`. - -- [ ] **Step 9.3: Push branch and open PR** - -```bash -git push -u origin feature/ndjson-schema-gaps -gh pr create --title "feat(events): close NDJSON schema gaps for downstream consumers (#63)" --body "$(cat <<'EOF' -## Summary -- Stabilizes \`permission_rejected\` shape (tool/input/callID); adds symmetric \`permission_granted\` -- Adds structured \`session_error\` event; deprecates \`session_complete.error\` string -- Echoes resolved permission ruleset + \`schemaVersion: "1"\` on \`session_start\` -- Adds monotonic \`sequenceNum\` to \`text\`/\`reasoning\`/\`tool_use\` for correct interleaving -- Bundles \`EVENTS.md\` in npm package and adds \`aictrl events [--schema-version]\` - -Closes #63. - -## Test plan -- [ ] \`bun test packages/cli/test/cli/\` green -- [ ] \`bun turbo typecheck\` green -- [ ] \`bun run dev events --schema-version\` prints \`1\` -- [ ] End-to-end \`aictrl run --format json\` shows \`schemaVersion\` on session_start and enriched permission events when a tool is denied - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** Tasks 1 (docs), 2–4 (permission events), 5 (session_start), 6 (session_error), 7 (sequenceNum), 8 (publishing) map 1:1 to the six scope items. ✅ -- **Placeholder scan:** NOTE-for-implementer blocks are implementation hints requiring verification against runtime code, not TBDs. They call out specific facts the implementer must confirm with line numbers. ✅ -- **Type consistency:** `ClassifiedSessionError.reason` values (`rate_limit` etc.) match the EVENTS.md documentation vocabulary. `SCHEMA_VERSION = "1"` constant matches the string literal expected in `run.ts` and EVENTS.md. ✅ -- **Known unknowns flagged inline:** session/agent permission resolution path (Task 5), short-circuit-allow handling in `PermissionNext` (Task 4), bundler path for `EVENTS.md` (Task 8). The implementer must verify these against the concrete codebase rather than accept the plan's hint blindly. diff --git a/docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md b/docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md deleted file mode 100644 index 9dd5b2f..0000000 --- a/docs/superpowers/specs/2026-04-23-ndjson-schema-gaps-design.md +++ /dev/null @@ -1,211 +0,0 @@ -# NDJSON Event Schema Gaps for Downstream Consumers - -Issue: [aictrl-dev/cli#63](https://github.com/aictrl-dev/cli/issues/63) -Date: 2026-04-23 -Branch: `feature/ndjson-schema-gaps` - -## Problem - -The aictrl executor (downstream consumer, `aictrl-dev/aictrl`) aggregates metrics and renders an Agent Activity view from the CLI's `aictrl run --format json` NDJSON stream. Five schema gaps force defensive shape-guessing, stderr parsing, and out-of-band config reads. This work closes those gaps for v1 of the event schema. - -## Scope - -In scope: - -1. Enrich `permission_rejected` payload (stable shape with tool/input/sessionID/callID) -2. New `session_error` event with structured `{reason, code, message}`; deprecate `session_complete.error` string -3. Publish `EVENTS.md` via npm package, new `aictrl events` subcommand, `schemaVersion` in `session_start` -4. Echo resolved permission ruleset in `session_start` -5. Add `sequenceNum` to `reasoning`, `text`, and `tool_use` events; document per-event emission semantics -6. New `permission_granted` event (symmetric with `permission_rejected`) - -Out of scope: - -- Changing `subagent_start`/`subagent_complete` — already emitted, match downstream fixture. -- Size caps on reasoning text — documented as unbounded; downstream sizes buffers based on observed distribution. -- Back-compat shim for `session_complete.error` — deprecated in docs only this PR; still emitted as today. Full removal deferred to a later CLI major. - -## Design - -### 1. `permission_rejected` — enriched flat shape - -Current emission (`packages/cli/src/cli/cmd/run.ts:622`): - -```json -{"type":"permission_rejected","permission":"bash","patterns":["rm -rf /"]} -``` - -New emission: - -```json -{ - "type": "permission_rejected", - "sessionID": "session_01abc...", - "callID": "call_01xyz...", - "tool": "bash", - "permission": "bash", - "patterns": ["rm -rf /"], - "input": { "command": "rm -rf /" } -} -``` - -Field sources: - -- `sessionID` — `permission.asked.sessionID` (already present). -- `callID` — `permission.asked.tool.callID` when present (populated by per-tool `ctx.ask` in `packages/cli/src/session/prompt.ts:786`); omitted otherwise. -- `tool` — tool registry id; see "Tool id threading" below. Falls back to `permission` string if the ask did not originate from a tool execute closure. -- `permission` — kept for back-compat; same string as today. -- `patterns` — unchanged. -- `input` — tool arguments at ask time. See "Input threading" below. -- `reason` — not emitted in v1 (deferred). - -Tool id threading: the per-tool `execute(args, options)` closure in `prompt.ts` knows `item.id` and `args`. Wrap the existing `ctx.ask` so it forwards `tool: item.id` and `input: args` into the `PermissionNext.Request.metadata` field. The `permission.asked` bus event already carries `metadata: z.record(...)`. The run.ts consumer reads `metadata.tool` and `metadata.input` when emitting `permission_rejected`. - -Edge cases: - -- Permission asks not originating from a tool execute closure (e.g., `processor.ts:166` doom_loop check) already populate `metadata.tool` and `metadata.input`; this works automatically. -- Permission asks with no metadata (task subagent permission, `prompt.ts:444`) — emit with `tool` absent and `input: null`. Consumers MUST tolerate missing `tool`/`input`. - -### 2. `session_error` event — structured failure - -Emitted immediately before `session_complete` on abnormal termination (provider error, auth failure, rate limit, timeout, OOM, uncaught exception in the prompt loop). - -```json -{ - "type": "session_error", - "sessionID": "session_01abc...", - "reason": "rate_limit", - "code": "429", - "message": "Rate limit exceeded: 40000 input tokens per minute" -} -``` - -`reason` vocabulary (closed set, documented in `EVENTS.md`): - -- `rate_limit` — provider returned 429 or equivalent. -- `auth` — 401/403 or missing/invalid API key. -- `timeout` — request timeout at provider or CLI level. -- `oom` — out-of-memory (detected via process exit signal or Node heap error). -- `provider` — any other provider-side error (5xx, malformed response). -- `unknown` — unclassified; `message` contains the raw string. - -`code` is the provider HTTP status code when available, otherwise the error `name`/`code` property, otherwise omitted. - -Classification happens in the `.catch` blocks of `run.ts:683` and `run.ts:723`. A small helper `classifySessionError(err) -> {reason, code, message}` lives in `packages/cli/src/cli/cmd/run.ts` (or a new `packages/cli/src/cli/cmd/run.errors.ts` if it grows). - -`session_complete.error` remains a string for now — set to `message` so existing consumers keep working. `EVENTS.md` marks it **deprecated**; new consumers read `session_error`. - -### 3. Schema publishing - -Three changes: - -a. **`schemaVersion` on `session_start`** — literal `"1"` in this release. Declared as a versioned contract going forward; breaking changes bump the integer. - -```json -{"type":"session_start","schemaVersion":"1","model":"...","agent":"...","permissions":[...]} -``` - -b. **Ship `EVENTS.md` in the npm package** — add `"EVENTS.md"` to `packages/cli/package.json`'s `files` array. Consumers can `require.resolve('aictrl/EVENTS.md')`. - -c. **`aictrl events` subcommand** — new CLI command prints the bundled `EVENTS.md` to stdout. Flag `--schema-version` prints just the version string. - -Implementation: new file `packages/cli/src/cli/cmd/events.ts`, registered alongside existing commands in the CLI entrypoint. Reads `EVENTS.md` via `import.meta.resolve` (or equivalent bun-compatible path resolution). - -### 4. `session_start` — echo permission ruleset - -Current (`run.ts:671`): - -```json -{"type":"session_start","model":"...","agent":"..."} -``` - -New: - -```json -{ - "type": "session_start", - "schemaVersion": "1", - "model": "anthropic/claude-sonnet-4-20250514", - "agent": "default", - "permissions": [ - {"permission": "bash", "pattern": "*", "action": "allow"}, - {"permission": "write", "pattern": "*", "action": "ask"}, - {"permission": "webfetch", "pattern": "*", "action": "deny"} - ] -} -``` - -`permissions` is the fully-resolved `PermissionNext.Ruleset` — the merge of agent permission and session permission that would be passed to `PermissionNext.ask(...)`. Computed once at session start via the existing `PermissionNext.merge(agent.permission, session.permission ?? [])` call. - -In headless mode the effective rule for anything not explicitly `allow` is auto-reject, so downstream can compute "would this have been rejected?" from the echoed ruleset alone. - -### 5. `sequenceNum` on event stream - -Add a monotonic per-session counter (reset per session, starts at `0`) to the interleavable events: - -- `text` -- `reasoning` -- `tool_use` - -Top-level events (`session_start`, `session_complete`, `session_error`, `message_complete`, `step_start`, `step_finish`, skill events, subagent events, permission events) do **not** get `sequenceNum`. Timestamps already order them adequately; they don't interleave with reasoning. - -Counter implementation: module-level `Map` in `run.ts` incremented before each `emit()` call for the three interleavable types. Child session (subagent) sequence numbers are distinct from the parent's — keyed on `part.sessionID`, not the top-level session. - -Documentation guarantees: - -- `reasoning`: one event per complete reasoning block; text is unbounded in size; consumers must accept arbitrarily large `part.text` strings. -- `text`: one event per complete text block (same as today). -- `tool_use`: one event per completed (or errored) tool call. -- `sequenceNum` is strictly monotonic per session and reflects emission order within that session's stream. - -### 6. `permission_granted` event - -Symmetric to `permission_rejected`. Emitted when a permission ask resolves to `allow` (either by matching an `allow` rule or by an `always` pattern already on file). Same payload shape as `permission_rejected` minus the rejection-specific framing. - -```json -{ - "type": "permission_granted", - "sessionID": "session_01abc...", - "callID": "call_01xyz...", - "tool": "bash", - "permission": "bash", - "patterns": ["ls"], - "input": { "command": "ls" } -} -``` - -Emission site: `PermissionNext.Event.Replied` (new or existing — see `packages/cli/src/permission/next.ts:99`). The `permission.replied` event fires with `reply: "once" | "always" | "reject"`. In run.ts, subscribe to replies; on `once` or `always`, emit `permission_granted` with metadata from the matching open request (tracked in a `Map` populated on `permission.asked`). - -Edge case: if a rule matches with `action: "allow"` up front, `PermissionNext.ask` short-circuits without a `permission.replied` event. Add emission at the short-circuit point (see `packages/cli/src/permission/next.ts:139` RejectedError path; mirror for the allow path). - -## Testing - -Unit tests live in `packages/cli/test/` (following existing convention). Strategy: - -- **Event shape snapshots**: for each enriched/new event type, write a fixture test that constructs the underlying bus event and asserts the emitted NDJSON line matches a golden JSON object. Tests target the emission code in `run.ts` directly (extract emitters into pure functions if they aren't already). -- **`classifySessionError` unit tests**: cover each `reason` category with representative error shapes (HTTP 429, `ENOTFOUND`, `AbortError`, heap OOM signal, generic Error). -- **`aictrl events` command**: smoke test that it prints the bundled file and `--schema-version` prints `"1"`. -- **Ruleset echo**: test that `session_start.permissions` equals the merged ruleset for a session with both agent-level and session-level rules. - -No full end-to-end test spawning a real provider is needed for this scope; the existing integration tests cover the overall NDJSON stream wiring. - -## Migration Notes for Downstream - -Consumers pin to `schemaVersion: "1"` on `session_start`. - -- `session_complete.error` remains a string; treat as deprecated. -- `permission_rejected` gains fields; existing `permission`/`patterns` keys unchanged. -- `permission_granted` is new; consumers ignoring unknown event types are unaffected. -- `session_error` is new; precedes `session_complete` on failure. -- `sequenceNum` on `text`/`reasoning`/`tool_use` is new; consumers ignoring unknown fields are unaffected. - -## Build Order - -1. `EVENTS.md` text updates describing final v1 shapes (docs land first so subsequent PRs can reference). -2. Enrich `permission_rejected` (thread tool/input metadata); add `permission_granted`. -3. Enrich `session_start` (schemaVersion + permissions). -4. Add `session_error` + `classifySessionError`; deprecate `session_complete.error` in docs. -5. Add `sequenceNum` to `text`/`reasoning`/`tool_use`. -6. `aictrl events` command + npm `files` addition for `EVENTS.md`. - -Each step ships with tests. Whole scope targets a single PR unless review feedback suggests splitting. From 7a5fbdc87298ce9a4f9360e995739eb39d36e8c9 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 16:19:45 +0100 Subject: [PATCH 13/14] fix: propagate subagent permission events; clarify session_error scope - run.ts: emit permission_rejected/permission_granted for subagent sessions too (previously filtered out), matching the existing subagent tool_use pass-through. Fixes an asymmetry where a subagent's denied tool surfaced as a failed tool_use with no explaining permission_rejected event. - EVENTS.md: document that session_error is strictly for abnormal termination; session_complete.error may carry concatenated non-fatal errors without a preceding session_error. Alerting should key on session_error. Co-Authored-By: Claude Opus 4.7 (1M context) --- EVENTS.md | 2 ++ packages/cli/src/cli/cmd/run.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index d65f99f..f98d965 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -49,6 +49,8 @@ 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` diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 82ee584..0c9f9b3 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -625,7 +625,8 @@ 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, @@ -648,7 +649,7 @@ export const RunCommand = cmd({ if (event.type === "permission.granted") { const permission = event.properties - if (permission.sessionID !== sessionID) continue + 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, From ce225d5e84c78e196712d3ef8995320e8ccae5a3 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 23 Apr 2026 16:46:34 +0100 Subject: [PATCH 14/14] fix: avoid truncating NDJSON on fatal error; clarify sequenceNum for subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run.ts: replace process.exit(1) with process.exitCode = 1 in both error paths so stdout drains before the process exits. Piped consumers were at risk of missing the session_error / session_complete lines that describe why the session failed. - EVENTS.md: note that in JSON mode subagent sessions only emit tool_use events, so subagent sequenceNum counters only advance on tool_use — not shared across text/reasoning/tool_use the way the primary session counter is. Co-Authored-By: Claude Opus 4.7 (1M context) --- EVENTS.md | 6 +++--- packages/cli/src/cli/cmd/run.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index f98d965..45a6ff3 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -104,7 +104,7 @@ 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`. +- `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` @@ -121,7 +121,7 @@ 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`. +- `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 @@ -146,7 +146,7 @@ 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`. +- `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. diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 0c9f9b3..9876266 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -718,7 +718,7 @@ export const RunCommand = cmd({ error: classified.message, }) console.error(e) - process.exit(1) + process.exitCode = 1 }) if (args.command) { @@ -765,7 +765,7 @@ export const RunCommand = cmd({ error: classified.message, }) console.error(e) - process.exit(1) + process.exitCode = 1 }, ), ])