Skip to content

Events

ActiveCli.Event.AbstractEvent — one thing a CLI told us, as an instance rather than parsed JSON.

abstract class AbstractEvent {
readonly raw: Readonly<Record<string, unknown>>;
get type(): string;
toJSON(): Record<string, unknown>;
}

type answers '' rather than throwing when the entry carries no string type.

ActiveCli.Event.EventParser — turns a JSON line from a CLI into the event instance it is. This is the boundary where parsed JSON stops travelling as a plain object; call it the moment a line is complete, and pass instances from then on.

class EventParser {
parse(line: string): AbstractEvent | null;
classify(raw: Readonly<Record<string, unknown>>): AbstractEvent;
}
Input Result
{"type":"control_request",…} PermissionRequestEvent
{"type":"system",…} SystemEvent
{"type":"assistant",…} AssistantEvent
{"type":"result",…} ResultEvent
Any other JSON object GenericEvent
Not JSON (Warning: …) null
JSON that is not an object ("str", [1,2], null) null

Non-JSON output is expected noise rather than an error: a CLI writes warnings and diagnostics to the same stream, and a host that treated those as failures would break on a version that got chattier. Returning null lets a caller skip the line without deciding it is broken.

classify() is the same dispatch for an entry that is already parsed — useful when replaying persisted JSONL.

ActiveCli.Event.PermissionRequestEvent — the CLI asking whether it may do something, and the means to answer.

class PermissionRequestEvent extends AbstractEvent {
static matches(raw: Readonly<Record<string, unknown>>): boolean;
get requestId(): string | null;
get subtype(): string | null;
approve(updatedInput?: Record<string, unknown>): Record<string, unknown>;
deny(reason?: string): Record<string, unknown>;
}

The answer lives on the request because a reply is only meaningful paired with the request_id it answers — the CLI matches them up, and an answer sent without one goes nowhere. That id arrived with the request, so the request is the shortest honest place to answer from.

approve() and deny() return the line to send rather than sending it: this event knows the shape of an answer, not where the process is.

event.requestId; // 'req-7'
event.subtype; // 'can_use_tool' — permission for a tool, a plan to approve, a question
event.approve();
// { type: 'control_response',
// response: { subtype: 'success', request_id: 'req-7',
// response: { behavior: 'allow', updatedInput: {} } } }
event.approve({ command: 'ls -la' });
// inner response: { behavior: 'allow', updatedInput: { command: 'ls -la' } }
event.deny('user declined');
// response: { behavior: 'deny', message: 'user declined' }

updatedInput replaces what the CLI proposed — this is how a host lets a user edit a command before it runs.

ActiveCli.Event.AssistantEvent — something the assistant said.

class AssistantEvent extends AbstractEvent {
static matches(raw: Readonly<Record<string, unknown>>): boolean;
get text(): string;
hasText(): boolean;
}

The CLI nests its text inside message.content, as an array of blocks of which only some are text. Every caller that wants “what did it say” was writing that walk by hand; text is that walk, done once. A bare string content is handled too — that is the simple form, blocks are the general one.

command.subscribe((event) => {
if (event instanceof AssistantEvent && event.hasText()) {
process.stdout.write(event.text);
}
});

ActiveCli.Event.SystemEvent — the CLI announcing something about itself rather than about the conversation.

class SystemEvent extends AbstractEvent {
static matches(raw: Readonly<Record<string, unknown>>): boolean;
reportedPermissionFlag(): string | null;
}

Two of these matter for keeping a host in step with the permission mode: init at spawn, and status when the CLI changes the mode on its own — which is how an approved plan leaving plan mode becomes observable without having to inspect the tool call that did it.

The flag is returned in the CLI’s own spelling (acceptEdits, not auto_edit). Translating it is an adapter’s job, since only the adapter knows which vocabulary its CLI speaks — pass it through ClaudePermissionFlag#toMode.

const event = parser.parse('{"type":"system","permissionMode":"acceptEdits"}') as SystemEvent;
event.reportedPermissionFlag(); // 'acceptEdits'
new ClaudePermissionFlag().toMode(event.reportedPermissionFlag());
// PermissionMode.AUTO_EDIT

ActiveCli.Event.ResultEvent — the CLI reporting that a turn finished.

class ResultEvent extends AbstractEvent {
static matches(raw: Readonly<Record<string, unknown>>): boolean;
}

Worth its own class because its absence is meaningful: a process that exits without having emitted one ended abnormally, and a host that tracked only exit codes could not tell that apart from a clean finish.

ActiveCli.Event.GenericEvent — an entry there are no typed questions for yet.

class GenericEvent extends AbstractEvent {}

Deliberately not a failure. A CLI emits more kinds of entry than this library models, and adds new ones between releases; refusing to represent those would mean a host silently loses events every time its CLI gets ahead of the library. The payload arrives whole via raw.

A subclass appearing later is not a breaking change for anyone reaching raw.

The mirror of an event is a message. ActiveCli.Message.ControlRequest is a request sent to the CLI on its control channel; both travel as control_request, so the direction is what the class names, not the wire type.

abstract class ControlRequest {
protected abstract get subtype(): string;
protected payload(): Record<string, unknown>;
toPayload(requestId: string): Record<string, unknown>;
}
class Interrupt extends ControlRequest {}
class Initialize extends ControlRequest {}
class SetModel extends ControlRequest {
constructor(model: string);
}
new Interrupt().toPayload('req-1');
// { type: 'control_request', request_id: 'req-1', request: { subtype: 'interrupt' } }
new Initialize().toPayload('req-2');
// { type: 'control_request', request_id: 'req-2', request: { subtype: 'initialize' } }
new SetModel('opus').toPayload('req-3');
// { type: 'control_request', request_id: 'req-3',
// request: { subtype: 'set_model', model: 'opus' } }

Initialize asks the CLI to describe itself — the slash commands this installation knows, the models it will accept, the settings actually in force for this directory. It is what Command.describe() sends, on a session that exists only to ask. The reply nests the same way an outgoing one does: an envelope saying which request succeeded, wrapping the description itself.

The base owns the envelope; subclasses supply only their subtype and any extra fields. That makes the class open for extension, so a host can reach a control subtype this library does not name without waiting for a release:

class Custom extends ControlRequest {
protected override get subtype(): string { return 'something_new'; }
protected override payload(): Record<string, unknown> { return { extra: true }; }
}
session.request(new Custom());