Skip to content

Sessions and events

Cause. The CLI was spawned with --resume <id> for an id that does not exist on disk.

The usual origin is inferring “is this a resume?” from the id being present. Every spawn carries an id, so that inference makes every session a resume — and the first one always fails. This was a real regression, caught only against the real CLI, and is why isResuming() requires resume to be set explicitly:

new SpawnOptions({ sessionId: 'abc' }).isResuming(); // false — new
new SpawnOptions({ sessionId: 'abc', resume: true }).isResuming(); // true — continuing

Fix. Use Command.open() for a new conversation and Command.resume(sessionId, …) for one that exists. If a custom adapter is involved, check that it chooses --resume versus --session-id from options.isResuming() and not from options.sessionId !== null.

const fresh = Command.open(Claude, { workingDir: '/proj' });
const later = Command.resume(fresh.sessionId, Claude, { workingDir: '/proj' });

The opposite error has its own message: starting a fresh session under an id already on disk is refused as in use.

Work through these in order.

Check How
Was send() called at all? Setters only accumulate. command.isLive() is false until the first send().
Did the process start? command.session?.pid is not null
Did it exit immediately? command.session?.isClosed(), and read command.session?.diagnostics
Was the listener registered in time? Call subscribe before send(). This is why Command.open starts nothing — there is no window in which an event can be missed.
Is the output NDJSON? parseLine returns null for every non-JSON line, and those are skipped silently

Skipping non-JSON is deliberate: CLIs write warnings to the same stream as their protocol, and a host that treated those as failures would break on a version that got chattier. To see what is being discarded, parse manually:

const parser = new EventParser();
for (const line of buffer.push(chunk)) {
if (parser.parse(line) === null) console.warn('not protocol output:', line);
}

An entry type this library does not model is not the cause. It arrives as a GenericEvent rather than being dropped, with the payload whole under raw.

Cause. send() was never called. Setters accumulate and return this; they start nothing. This is the whole arrangement working as designed, and it is the most common first surprise.

command.setMessage('fix the bug'); // nothing has run
await command.setMessage('fix the bug').send(); // now it has

Check. command.isLive() is false, and command.inspect() ends with (not started).

Cause. A name outside the closed set reached setPermissions(). The valid names are plan, ask_before_edit, auto_edit, auto, bypass — with hyphens accepted in place of underscores.

Why it throws rather than defaulting. Silently running under a different mode than the caller asked for is the kind of mistake that ends in edits nobody approved. A typo becoming “whatever the CLI was going to do anyway” is not a safe failure.

command.setPermissions('ask-before-edit'); // ok
command.setPermissions('acceptEdits'); // throws — that is the CLI's flag spelling,
// not the library's mode name

That last line is the usual cause: acceptEdits is what the Claude CLI calls the mode on the wire, and ClaudePermissionFlag translates it. The name to pass here is auto_edit.

An attachment did not travel with the second turn

Section titled “An attachment did not travel with the second turn”

This is by design. send() clears the attachments, because an attachment belongs to the turn that carried it and silently re-sending it would be a surprise.

await command.setAttachment('/proj/a.ts').setMessage('explain this').send();
command.attachments; // [] — already cleared
await command.setAttachment('/proj/a.ts').setMessage('now refactor it').send();

Note also that setAttachment (singular) replaces the whole list rather than appending. Use setAttachments([…]) to send several.

A model or permission change had no effect

Section titled “A model or permission change had no effect”

Cause depends on which one.

Setting When it applies Why
setModel() The next spawn --model is a spawn flag. A live process can be retargeted over the control channel via session.setModel(), but that reaches only something already running.
setPermissions() The next send(), which respawns --permission-mode is spawn-time only with no documented way to change it in place, so send() retires the process and starts a new one with resume: true.
setProvider() The next send(), which respawns A CLI process cannot become another one.

So a permission change is honoured automatically, while a model change made mid-conversation reaches the running process only if you ask the session directly:

command.setModel('opus'); // pinned for the next spawn
command.session?.setModel('opus'); // and retarget the live process too

Both paths exist because neither alone is sufficient — one reaches a running process, the other survives it.

JSON parse failures, or events that never classify

Section titled “JSON parse failures, or events that never classify”

Cause. ANSI escape sequences on the protocol stream. A CLI that believes it is attached to a capable terminal decorates its output, and \x1b[1 q{"type":… is not parseable JSON.

Fix. This is what TERM=dumb prevents, and ClaudeAdapter sets it on every spawn. If you are seeing it, either a custom adapter omits that floor, or a caller’s own env overrode it — the caller’s env is merged over the protocol env by design, so it can.

// This re-breaks parsing:
Command.open(Claude, { env: { TERM: 'xterm-256color' } });

CI=true matters for the same reason, and CLAUDECODE is deliberately cleared rather than set — inheriting it makes the CLI behave as a nested run when it is not one.

This may be correct. No MCP configured is the ordinary case, and it is indistinguishable from failure in the CLI’s own output — so list() answers [] for both rather than throwing on the common case.

Check by running the command the library runs:

const result = await command.exec(['mcp', 'list']);
console.log(result.stdout, result.stderr, result.isOk());
What you see Meaning
Empty stdout Genuinely nothing configured
Server lines present, but list() still empty A parse miss — lines need a : and a final to be read
An error on stderr The CLI could not enumerate; scope or config may be at fault

A line that does not fit the expected shape is skipped rather than failing the whole list, so a partial result means some lines parsed and others did not.

Cause. The scope was omitted. For project- and local-scoped servers the CLI looks in the wrong configuration file and finds nothing to remove — without reporting much about it.

await command.mcp.remove('playwright', { scope: 'project' });
Symptom Cause Fix
The browser opened twice The host opened the URL as well as the CLI Show the URL; do not open it. The CLI already tries, and does not report whether it worked.
The user has a code and nowhere to paste it The host inferred no code was needed Always offer the field. Whether one is required is decided browser-side and never appears in the output.
onUrl never fires Read login.output — both streams are scanned, so whatever the CLI printed is there.

describe() or supportsModel() returns nothing useful

Section titled “describe() or supportsModel() returns nothing useful”
Result Meaning
describe()null The CLI did not answer within the timeout (15s default). An older CLI may not support the initialize control request at all — a fact to handle, not a failure to throw over.
supportsModel()false for a model that works The probe turn failed for an unrelated reason — no auth, no network. It sends a real request, so anything that breaks a request breaks the probe.
supportsModel() is slow It costs a request, by necessity: no command answers “may I use this model”, since entitlement depends on the account and org. Cache the answer rather than probing per render.

Both run ephemerally (--no-session-persistence), so neither leaves anything in the user’s transcript.

Cause. The stream flags are missing or incomplete. Without --input-format stream-json, stdin does not stay open, so the CLI treats the run as a single-shot prompt and the conversation cannot continue.

Fix. A custom adapter must include the full set:

-p --output-format stream-json --input-format stream-json
--verbose --include-partial-messages --permission-prompt-tool stdio

Cause. --permission-prompt-tool stdio is missing, so approval requests are not routed to the channel the host reads.

Check. Confirm the events are being classified:

command.subscribe((event) => {
console.log(event.type, event.constructor.name);
});

A control_request entry should classify as PermissionRequestEvent.

A related failure: the request arrives, the host replies, and the CLI ignores it. Two causes, both avoided by using onPermission() with the event’s own approve() / deny():

Cause Detail
The reply lacked the matching request_id The CLI pairs them up; an unmatched reply goes nowhere. The id arrived with the request, which is why the reply is built on it.
The reply envelope was flattened It nests twice — an outer envelope saying which request succeeded, wrapping the decision. Flattening is accepted without complaint and then fails the turn with error_during_execution, with nothing saying why.
command.onPermission((request) => request.approve());
// { type: 'control_response',
// response: { subtype: 'success', request_id: '…',
// response: { behavior: 'allow', updatedInput: {} } } }

They are not being stripped. raw is the CLI’s own entry with no field removed or renamed, so anything the CLI sent is present:

event.raw['cost_usd'];
event.raw['unknown_future_field'];

If a field is genuinely absent, the CLI did not send it. A typed accessor returning nullrequestId, subtype, reportedPermissionFlag() — means the field was missing or not a string, not that it was discarded.

This should not happen, and if it does the cause is a custom read loop rather than Session.

A CLI that exits without a trailing newline leaves its final event in the buffer. Session calls NdjsonBuffer.flush() on close, before notifying close listeners, so that event is still delivered. A hand-rolled loop that only calls push() will drop it.

process.on('close', () => {
for (const line of buffer.flush()) emit(line); // do not omit this
});

The session stops answering after an interrupt

Section titled “The session stops answering after an interrupt”

This is expected, and worth knowing before it looks like a bug.

Measured against claude 2.1.170: the CLI answers on the control channel and ends the turn with a ResultEvent whose subtype is error_during_execution — which here means interrupted, not broken. The process then stays up but serves nothing further. A turn sent afterwards gets no reply, and waiting for the process to exit waits forever.

Fix. Interrupt, then stop, then continue in a fresh session. The conversation survives on disk; only the process is spent.

command.subscribe((event) => {
if (event instanceof ResultEvent) command.close();
});
command.interrupt();
// Same conversation, new process.
const next = Command.resume(command.sessionId, Claude, { workingDir });

Interrupting still beats calling close() alone, because the CLI writes its result and closes the transcript entry rather than being killed mid-write.

Cause. The control channel only reaches a live, responsive process. A CLI that is wedged will not answer it.

Fix. Follow an unanswered interrupt with close(). The control channel is an optimisation over killing the process, not a replacement for it — Interrupt has no fallback of its own, by design.

command.interrupt();
setTimeout(() => {
if (command.isLive()) command.close();
}, timeoutMs);

The same applies to session.setModel(): it reaches only a running process. A model chosen while nothing runs must be pinned for the next spawn with command.setModel().

Cause. The session is closed. Writing is a deliberate no-op once the CLI has exited, because writing to a dead process throws EPIPE and a caller racing the exit did nothing wrong.

Check. command.isLive(), or command.session?.isClosed().