a command dropped for want of a sidecar has to say so

`sendFire` looked up a sidecar, and if none was registered it did nothing.
No log, no error, no return value — the command simply stopped existing. That
window is real and entirely predictable: every `pm2 restart officer` has one
while the sidecars reconnect, and somebody clicking a button has no idea they
are in it.

The cost landed hardest on answering a question. `answerClaudeQuestion` in the
agent documents this exact failure in a careful comment and cannot report it,
because the command never arrives to be reported. So the question card kept
spinning, which from the person's side is indistinguishable from a slow agent,
and the only move left is to send another message — the worst possible thing to
do to a waiting tool call. Observed tonight, on a restart I ran myself while a
commander was mid-question.

So `sendFire` returns whether anyone took the command and warns when nobody did,
and the two paths where a person is actively waiting — a question answer and a
plan decision — tell them instead of leaving the card to spin.

The other five callers are unchanged and now leave a log line, which is the part
that was missing when this was diagnosed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 00:06:30 +00:00
co-authored by Claude Opus 5
parent 383df51a99
commit 5c802b07b5
2 changed files with 50 additions and 9 deletions
+26 -2
View File
@@ -1396,13 +1396,20 @@ function handlePlanDecision(
): void {
const sessionId = wsToSessionMap.get(ws);
if (!sessionId) return;
sidecar.decideClaudePlan({
const delivered = sidecar.decideClaudePlan({
sessionKey: sessionId,
userId: ws.data.userId,
requestId: msg.requestId,
approve: msg.approve,
feedback: msg.feedback,
});
// Same drop as `handleQuestionAnswer`, same spinning card — see `sendFire`.
if (!delivered) {
sendToClient(ws, {
type: 'error',
message: 'The agent is not connected right now, so that decision did not reach it. Try again in a moment.',
});
}
}
/**
@@ -1416,12 +1423,29 @@ function handlePlanDecision(
function handleQuestionAnswer(ws: ServerWebSocket<WSData>, msg: { toolCallId: string; answer: string }): void {
const sessionId = wsToSessionMap.get(ws);
if (!sessionId) return;
sidecar.answerClaudeQuestion({
/*
A dropped answer has to reach the person who sent it.
`sendFire` does nothing when no sidecar is registered, and that window is real — every restart of this
process has one while the agent reconnects. The answer vanished with no log and no error, so the
question card spun on, which is indistinguishable from a slow agent. The only move left was to send
another message, which is the worst possible thing to do to a waiting tool call.
The agent's own handler already reports the failures it CAN see (no such session, question no longer
waiting). This is the one it cannot, because the command never arrives.
*/
const delivered = sidecar.answerClaudeQuestion({
sessionKey: sessionId,
userId: ws.data.userId,
toolCallId: msg.toolCallId,
answer: msg.answer,
});
if (!delivered) {
sendToClient(ws, {
type: 'error',
message: 'The agent is not connected right now, so that answer did not reach it. Try again in a moment.',
});
}
}
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
+24 -7
View File
@@ -224,11 +224,28 @@ function sendCommand(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIME
});
}
function sendFire(kind: string, cmd: SidecarCommand): void {
/**
* Fire a command at whichever sidecar handles this kind. Returns whether one was there to take it.
*
* It used to return void and drop the command when no sidecar was registered — no log, no error, nothing.
* That window is real and predictable: every `pm2 restart officer` has one while the sidecars reconnect, and
* a person clicking a button has no idea they are in it.
*
* The cost landed on `claude:question-answer`, whose own handler documents this exact failure and cannot
* report it, because the message never arrives to be reported. From the person's side the question card just
* kept spinning — indistinguishable from a slow agent, so the only move left was to send another message.
*
* Callers that can tell somebody are now able to. The boolean is the whole mechanism; the warning is for
* whoever reads the log afterwards wondering why one click did nothing.
*/
function sendFire(kind: string, cmd: SidecarCommand): boolean {
const sc = findSidecarHandling(kind);
if (sc) {
sc.ws.send(JSON.stringify(cmd));
if (!sc) {
console.warn(`[sidecar] dropped ${cmd.type}: no sidecar registered for "${kind}"`);
return false;
}
sc.ws.send(JSON.stringify(cmd));
return true;
}
function sendCommandToSidecar(
@@ -368,8 +385,8 @@ export function decideClaudePlan(params: {
requestId: string;
approve: boolean;
feedback?: string;
}): void {
sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
}): boolean {
return sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
}
// The user's answer to an AskUserQuestion. Fire-and-forget for the same reason as the plan decision: the
@@ -380,8 +397,8 @@ export function answerClaudeQuestion(params: {
userId: number;
toolCallId: string;
answer: string;
}): void {
sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
}): boolean {
return sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
}
/**