|
|
|
@@ -230,6 +230,28 @@ type PersistentSession = {
|
|
|
|
|
* and answered as another.
|
|
|
|
|
*/
|
|
|
|
|
model?: string;
|
|
|
|
|
/**
|
|
|
|
|
* Whether this session is currently in plan mode.
|
|
|
|
|
*
|
|
|
|
|
* Same shape and same reason as `model` above: `permissionMode` is a `query()` option read once, so this
|
|
|
|
|
* is the only record of what the live session is actually on, and a change has to go through a control
|
|
|
|
|
* request rather than the next turn's options.
|
|
|
|
|
*
|
|
|
|
|
* It is also what `canUseTool` reads to decide whether an `ExitPlanMode` is a real decision or a formality
|
|
|
|
|
* — see `planGate`.
|
|
|
|
|
*/
|
|
|
|
|
planMode: boolean;
|
|
|
|
|
/**
|
|
|
|
|
* Plans waiting on a human, keyed by request id.
|
|
|
|
|
*
|
|
|
|
|
* Each entry is a `canUseTool` promise the AGENT IS BLOCKED ON, so this map is the one piece of session
|
|
|
|
|
* state whose leak is a hang rather than a stale value: an entry that is never resolved is a turn that
|
|
|
|
|
* never ends. Everything that can end a session resolves these first — see `settleAllPlans`.
|
|
|
|
|
*/
|
|
|
|
|
pendingPlans: Map<
|
|
|
|
|
string,
|
|
|
|
|
{ resolve: (decision: { approve: boolean; feedback?: string }) => void; timer: ReturnType<typeof setTimeout> }
|
|
|
|
|
>;
|
|
|
|
|
/** The tail of the child's stderr, so its exit can carry a reason instead of just a number. */
|
|
|
|
|
stderrTail: string;
|
|
|
|
|
/** Whether the child ever produced a single message. False plus a non-zero exit means it never started. */
|
|
|
|
@@ -467,6 +489,8 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
|
|
|
|
emit: onEvent,
|
|
|
|
|
isGenerating: false,
|
|
|
|
|
pendingTasks: new Set<string>(),
|
|
|
|
|
planMode: params.planMode === true,
|
|
|
|
|
pendingPlans: new Map(),
|
|
|
|
|
interrupted: false,
|
|
|
|
|
stderrTail: '',
|
|
|
|
|
sawOutput: false,
|
|
|
|
@@ -502,8 +526,30 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
|
|
|
|
// HOST_HOME is this process's home — the owner's. Defaulting a member's turn to it would start them in
|
|
|
|
|
// a directory they cannot read, and the failure would look like a broken agent rather than a wrong cwd.
|
|
|
|
|
cwd: params.cwd ?? params.member?.home ?? HOST_HOME,
|
|
|
|
|
permissionMode: 'bypassPermissions',
|
|
|
|
|
// `bypassPermissions` unless the conversation opened in plan mode. CLAUDE.md is explicit that the
|
|
|
|
|
// unsandboxed default is deliberate — this is the owner's own machine — so plan mode is the only
|
|
|
|
|
// thing that may ever move it, and it moves back the moment a plan is approved.
|
|
|
|
|
permissionMode: session.planMode ? 'plan' : 'bypassPermissions',
|
|
|
|
|
allowDangerouslySkipPermissions: true,
|
|
|
|
|
/**
|
|
|
|
|
* Passed ALWAYS, and shadowed almost always.
|
|
|
|
|
*
|
|
|
|
|
* Under `bypassPermissions` the SDK never consults this and says so once at startup
|
|
|
|
|
* (`CLAUDE_SDK_CAN_USE_TOOL_SHADOWED`) — verified 2026-08-17: a bypassing session made a file edit with
|
|
|
|
|
* zero calls here. But `canUseTool` is a `query()` option read once at session creation, so a session
|
|
|
|
|
* that did not pass it can never gate anything later. Passing it unconditionally is what makes plan
|
|
|
|
|
* mode toggleable on a LIVE conversation instead of only choosable when one starts, and it costs a
|
|
|
|
|
* log line.
|
|
|
|
|
*
|
|
|
|
|
* It allows everything that is not `ExitPlanMode`, and that is not laziness. Approving a plan drops
|
|
|
|
|
* the harness into `default` — not back into `bypassPermissions` — so for the rest of that turn this
|
|
|
|
|
* callback is the only thing between the agent and its tools. A default-deny here would jail an agent
|
|
|
|
|
* the platform deliberately does not jail.
|
|
|
|
|
*/
|
|
|
|
|
canUseTool: async (toolName, toolInput) => {
|
|
|
|
|
if (toolName !== 'ExitPlanMode' || !session.planMode) return { behavior: 'allow', updatedInput: toolInput };
|
|
|
|
|
return planGate(session, toolInput);
|
|
|
|
|
},
|
|
|
|
|
includePartialMessages: true,
|
|
|
|
|
// The only warning that compaction is about to happen. Everything else the harness does narrates
|
|
|
|
|
// itself through the message stream; compaction goes silent for as long as it takes — 2.5 minutes
|
|
|
|
@@ -649,6 +695,9 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
|
|
|
|
} finally {
|
|
|
|
|
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
|
|
|
if (session.stallTimer) clearTimeout(session.stallTimer);
|
|
|
|
|
// Before the session leaves the map, so nothing is left holding a promise whose resolver has just
|
|
|
|
|
// become unreachable.
|
|
|
|
|
settleAllPlans(session, 'The session ended before the plan was answered.');
|
|
|
|
|
sessions.delete(sessionKey);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
@@ -674,6 +723,7 @@ export async function spawnClaudeStreaming(
|
|
|
|
|
if (session) {
|
|
|
|
|
session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing)
|
|
|
|
|
await applyModelChange(session, params.model);
|
|
|
|
|
await applyPlanModeChange(session, params.planMode);
|
|
|
|
|
} else {
|
|
|
|
|
session = createSession(params, onEvent);
|
|
|
|
|
}
|
|
|
|
@@ -715,6 +765,145 @@ async function applyModelChange(session: PersistentSession, model: string | unde
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
How long a plan may sit unanswered before it is refused on the user's behalf.
|
|
|
|
|
|
|
|
|
|
This is a deadlock timer, not a UX one. `canUseTool` gates the turn: while a plan is pending the agent is
|
|
|
|
|
blocked, `isGenerating` stays true, and no further events are emitted — which also means the stall
|
|
|
|
|
watchdog above sees a silent turn and would eventually tear the whole session down. Refusing after twenty
|
|
|
|
|
minutes gives a plan a realistic reading time while guaranteeing the turn ends by a route that keeps the
|
|
|
|
|
conversation, since a denial is something the agent can answer.
|
|
|
|
|
*/
|
|
|
|
|
const PLAN_DECISION_TIMEOUT_MS = 20 * 60 * 1000;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Hold `ExitPlanMode` open while a human decides.
|
|
|
|
|
*
|
|
|
|
|
* The one place in this file where the agent waits on Officer rather than the other way round. The promise
|
|
|
|
|
* returned here is the harness's permission answer, so every path out of it must resolve — a throw or a
|
|
|
|
|
* forgotten branch is a wedged turn, and the symptom would be a chat that shows a plan and then nothing.
|
|
|
|
|
*/
|
|
|
|
|
async function planGate(
|
|
|
|
|
session: PersistentSession,
|
|
|
|
|
toolInput: Record<string, unknown>,
|
|
|
|
|
): Promise<{ behavior: 'allow'; updatedInput: Record<string, unknown> } | { behavior: 'deny'; message: string }> {
|
|
|
|
|
const plan = typeof toolInput.plan === 'string' ? toolInput.plan : '';
|
|
|
|
|
const requestId = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
|
|
|
|
|
|
const decision = await new Promise<{ approve: boolean; feedback?: string }>((resolve) => {
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
session.pendingPlans.delete(requestId);
|
|
|
|
|
console.warn(`[claude:plan:${session.sessionKey}] no decision in 20m — refusing on the user's behalf`);
|
|
|
|
|
resolve({ approve: false, feedback: 'No decision was made in time. The plan was not approved.' });
|
|
|
|
|
}, PLAN_DECISION_TIMEOUT_MS);
|
|
|
|
|
|
|
|
|
|
session.pendingPlans.set(requestId, { resolve, timer });
|
|
|
|
|
session.emit({ type: 'plan:proposed', requestId, plan });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
session.emit({ type: 'plan:decided', requestId, approved: decision.approve, feedback: decision.feedback });
|
|
|
|
|
|
|
|
|
|
if (!decision.approve) {
|
|
|
|
|
// Denial is the REVISION path, not an abort: the harness feeds this string back and asks the agent to
|
|
|
|
|
// call ExitPlanMode again. So the message is the feedback, and a bare refusal still needs to say
|
|
|
|
|
// something the agent can act on.
|
|
|
|
|
return {
|
|
|
|
|
behavior: 'deny',
|
|
|
|
|
message: decision.feedback?.trim() || 'The plan was rejected. Revise it and ask again.',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
Approval leaves plan mode — but into `default`, NOT back into `bypassPermissions` (verified 2026-08-17).
|
|
|
|
|
The agent carries straight on inside this same turn, so the restore is fired here rather than at turn end.
|
|
|
|
|
|
|
|
|
|
Deliberately NOT awaited, and the ordering is the reason: the harness is waiting on the promise this
|
|
|
|
|
function returns, so awaiting a control request before answering it invites a deadlock. Any tool call
|
|
|
|
|
that lands in the gap is allowed by the callback above, which makes the window a matter of overhead
|
|
|
|
|
rather than correctness.
|
|
|
|
|
*/
|
|
|
|
|
session.planMode = false;
|
|
|
|
|
void session.query.setPermissionMode('bypassPermissions').catch((err: unknown) => {
|
|
|
|
|
console.error(`[claude:plan:${session.sessionKey}] could not restore bypassPermissions: ${String(err)}`);
|
|
|
|
|
});
|
|
|
|
|
session.emit({ type: 'plan:mode', enabled: false, ok: true });
|
|
|
|
|
|
|
|
|
|
return { behavior: 'allow', updatedInput: toolInput };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve every outstanding plan as refused.
|
|
|
|
|
*
|
|
|
|
|
* Called from every path that ends a session or a turn. A pending plan is a promise the agent is blocked
|
|
|
|
|
* on, so leaving one behind when the session goes away is the difference between a turn that ends and a
|
|
|
|
|
* turn that hangs until the stall watchdog notices.
|
|
|
|
|
*/
|
|
|
|
|
function settleAllPlans(session: PersistentSession, reason: string): void {
|
|
|
|
|
for (const [requestId, pending] of session.pendingPlans) {
|
|
|
|
|
clearTimeout(pending.timer);
|
|
|
|
|
session.pendingPlans.delete(requestId);
|
|
|
|
|
pending.resolve({ approve: false, feedback: reason });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The user's answer to a pending plan. Returns false when there is nothing waiting under that id.
|
|
|
|
|
*
|
|
|
|
|
* A miss is ordinary rather than exceptional: a client that reconnects and replays its last action can
|
|
|
|
|
* answer a plan that has already timed out, and the honest response is to ignore it — the alternative is
|
|
|
|
|
* resolving a promise that no longer exists, or worse, a newer plan that happens to be pending.
|
|
|
|
|
*/
|
|
|
|
|
export function decideClaudePlan(
|
|
|
|
|
sessionKey: string,
|
|
|
|
|
userId: number,
|
|
|
|
|
requestId: string,
|
|
|
|
|
approve: boolean,
|
|
|
|
|
feedback?: string,
|
|
|
|
|
): boolean {
|
|
|
|
|
const session = ownedSession(sessionKey, userId);
|
|
|
|
|
const pending = session?.pendingPlans.get(requestId);
|
|
|
|
|
if (!session || !pending) return false;
|
|
|
|
|
clearTimeout(pending.timer);
|
|
|
|
|
session.pendingPlans.delete(requestId);
|
|
|
|
|
pending.resolve({ approve, feedback });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Turn plan mode on or off on a live session — the same control-request shape as `applyModelChange`.
|
|
|
|
|
*
|
|
|
|
|
* Reachable two ways, because the toggle is used at two different moments: on the turn itself (a new
|
|
|
|
|
* conversation whose first turn should plan) and on its own between turns (the common case — you decide to
|
|
|
|
|
* plan while reading the last answer, when there is no turn to carry the flag).
|
|
|
|
|
*
|
|
|
|
|
* A refusal is reported and swallowed for the same reason a model refusal is: the conversation is worth more
|
|
|
|
|
* than the mode, so the session stays on what it was and says so rather than losing the turn.
|
|
|
|
|
*/
|
|
|
|
|
async function applyPlanModeChange(session: PersistentSession, enabled: boolean | undefined): Promise<void> {
|
|
|
|
|
if (enabled === undefined || enabled === session.planMode) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await session.query.setPermissionMode(enabled ? 'plan' : 'bypassPermissions');
|
|
|
|
|
session.planMode = enabled;
|
|
|
|
|
console.log(`[claude:plan:${session.sessionKey}] plan mode ${enabled ? 'on' : 'off'}`);
|
|
|
|
|
session.emit({ type: 'plan:mode', enabled, ok: true });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
|
|
|
console.error(`[claude:plan:${session.sessionKey}] refused plan mode ${enabled ? 'on' : 'off'}: ${reason}`);
|
|
|
|
|
// `session.planMode` is deliberately untouched: believing a switch that failed would put the gate above
|
|
|
|
|
// out of step with the harness, and the visible symptom of that is an ExitPlanMode nobody is asked about.
|
|
|
|
|
session.emit({ type: 'plan:mode', enabled, ok: false, error: reason });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Flip plan mode on a live session from outside a turn. Returns false when no such session is live. */
|
|
|
|
|
export async function setClaudePlanMode(sessionKey: string, userId: number, enabled: boolean): Promise<boolean> {
|
|
|
|
|
const session = ownedSession(sessionKey, userId);
|
|
|
|
|
if (!session) return false;
|
|
|
|
|
await applyPlanModeChange(session, enabled);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Interrupt the current turn but KEEP the session alive (the "stop" button). */
|
|
|
|
|
export async function interruptClaudeSession(sessionKey: string, userId: number): Promise<boolean> {
|
|
|
|
|
const session = ownedSession(sessionKey, userId);
|
|
|
|
@@ -722,6 +911,9 @@ export async function interruptClaudeSession(sessionKey: string, userId: number)
|
|
|
|
|
// Set before the await: the failed `result` can arrive while interrupt() is still resolving, and the
|
|
|
|
|
// consumer loop reads this flag to tell a stop from a fault.
|
|
|
|
|
session.interrupted = true;
|
|
|
|
|
// Stop cannot reach a turn that is blocked on a plan — the agent is not running, it is waiting on us —
|
|
|
|
|
// so the promise has to be released for the interrupt to have anything to interrupt.
|
|
|
|
|
settleAllPlans(session, 'The turn was stopped before the plan was answered.');
|
|
|
|
|
try {
|
|
|
|
|
await session.query.interrupt();
|
|
|
|
|
session.isGenerating = false;
|
|
|
|
@@ -740,6 +932,7 @@ export function killClaudeSession(sessionKey: string, userId: number): boolean {
|
|
|
|
|
if (!session) return false;
|
|
|
|
|
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
|
|
|
if (session.stallTimer) clearTimeout(session.stallTimer);
|
|
|
|
|
settleAllPlans(session, 'The session was closed before the plan was answered.');
|
|
|
|
|
try {
|
|
|
|
|
session.abort.abort();
|
|
|
|
|
} catch {
|
|
|
|
|