Added nono profiles & orca integration - Added some extensions to start working on

This commit is contained in:
jb
2026-08-24 10:23:38 -04:00
parent 8bcb82813c
commit 10f64b593b
83 changed files with 21840 additions and 1353 deletions
+701
View File
@@ -0,0 +1,701 @@
/**
* Claude Code backend — real implementation over the Claude Agent SDK.
*
* One SDK `query()` and one streaming-input bridge live for the whole scoped
* session. User sends are pushed into that bridge, while the query iterator is
* pumped in the background and translated to normalized SubagentEvents. The
* CLI therefore owns conversation continuity, tool execution, and persisted
* `~/.claude/projects` transcripts; this file only owns lifecycle guarantees
* and the normalized view consumed by the manager.
*/
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
query,
type SDKAssistantMessage,
type SDKMessage,
type SDKResultMessage,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { Cause, Scope } from "effect";
import { Effect, Queue, Stream } from "effect";
import type { SubagentBackend, SubagentSession } from "../backend.ts";
import type {
QueuedMessage,
ReasoningEffort,
RunOutcome,
SpawnTask,
SubagentEvent,
SubagentMeta,
TranscriptPart,
} from "../domain.ts";
import { SendError, SpawnError } from "../domain.ts";
const CLAUDE_CONTEXT_WINDOW = 200_000;
const INTERRUPT_TIMEOUT_MS = 2_000;
const PREVIEW_MAX_LENGTH = 4_096;
// --- Binary resolution --------------------------------------------------------
let cachedClaudeBinary: string | null | undefined;
function executable(file: string) {
try {
fs.accessSync(file, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Resolve the Claude Code CLI once from PATH. The SDK can also run without
* this (it bundles a CLI), but pointing it at the user's installed binary
* keeps versions, settings, and login state consistent with their terminal.
*/
function resolveClaudeBinary() {
if (cachedClaudeBinary !== undefined) return cachedClaudeBinary ?? undefined;
const names =
process.platform === "win32"
? ["claude.exe", "claude.cmd", "claude"]
: ["claude"];
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
if (!directory) continue;
for (const name of names) {
const candidate = path.join(directory, name);
if (executable(candidate)) {
cachedClaudeBinary = candidate;
return candidate;
}
}
}
cachedClaudeBinary = null;
return undefined;
}
// --- Streaming input ---------------------------------------------------------
/** A single-consumer push queue adapted to the SDK's AsyncIterable input. */
class ClaudeInput implements AsyncIterable<SDKUserMessage> {
private pending: SDKUserMessage[] = [];
private waiter:
((result: IteratorResult<SDKUserMessage>) => void) | undefined;
private closed = false;
push(text: string) {
if (this.closed) return undefined;
const message: SDKUserMessage = {
type: "user",
message: { role: "user", content: text },
parent_tool_use_id: null,
// Stamped UUIDs make queued messages visible in interrupt receipts.
uuid: randomUUID(),
};
const waiter = this.waiter;
if (waiter) {
this.waiter = undefined;
waiter({ value: message, done: false });
} else {
this.pending.push(message);
}
return message;
}
clear() {
return this.pending.splice(0);
}
end() {
if (this.closed) return;
this.closed = true;
const waiter = this.waiter;
this.waiter = undefined;
waiter?.({ value: undefined, done: true });
}
async *[Symbol.asyncIterator]() {
while (true) {
const message = this.pending.shift();
if (message) {
yield message;
continue;
}
if (this.closed) return;
const next = await new Promise<IteratorResult<SDKUserMessage>>(
(resolve) => {
this.waiter = resolve;
},
);
if (next.done) return;
yield next.value;
}
}
}
// --- Model, effort, and transcript helpers ----------------------------------
/**
* Claude's deprecated-but-supported maxThinkingTokens is the closest match to
* the shared numeric scale requested by this extension. Zero explicitly
* disables extended thinking in SDK 0.3.207; an omitted effort leaves the CLI
* default untouched.
*/
const THINKING_BUDGETS = {
off: 0,
minimal: 1_024,
low: 4_096,
medium: 10_000,
high: 16_000,
xhigh: 32_000,
max: 63_999,
} satisfies Record<ReasoningEffort, number>;
function boundedError(error: unknown) {
return (error instanceof Error ? error.message : String(error)).slice(
0,
4_096,
);
}
function singleLine(text: string) {
const flattened = text.replace(/\s+/g, " ").trim();
return flattened ? flattened.slice(0, PREVIEW_MAX_LENGTH) : undefined;
}
function safeJson(value: unknown) {
try {
const text = JSON.stringify(value);
if (!text || text === "{}") return undefined;
return singleLine(text);
} catch {
return undefined;
}
}
function outputPreview(value: unknown): string | undefined {
if (typeof value === "string") return singleLine(value);
if (Array.isArray(value)) {
const text = value
.flatMap((part) => {
if (!part || typeof part !== "object") return [];
const record = part as { type?: unknown; text?: unknown };
return record.type === "text" && typeof record.text === "string"
? [record.text]
: [];
})
.join(" ");
return singleLine(text) ?? safeJson(value);
}
return safeJson(value);
}
function assistantParts(message: SDKAssistantMessage): TranscriptPart[] {
const parts: TranscriptPart[] = [];
for (const block of message.message.content) {
if (block.type === "text") {
parts.push({ type: "text", text: block.text });
} else if (block.type === "thinking") {
parts.push({ type: "thinking", text: block.thinking });
} else if (block.type === "redacted_thinking") {
parts.push({ type: "thinking", text: "", redacted: true });
} else if (block.type === "tool_use") {
parts.push({
type: "toolCall",
toolId: block.id,
name: block.name,
argsPreview: safeJson(block.input),
});
}
}
return parts;
}
/** Claude Code's project-directory escaping, verified against CLI 2.1.207. */
function sessionFilePath(cwd: string, sessionId: string) {
const projectDirectory = cwd.replace(/[/.]/g, "-");
return path.join(
os.homedir(),
".claude",
"projects",
projectDirectory,
`${sessionId}.jsonl`,
);
}
/**
* Context occupancy after one API request. An assistant message's `usage`
* describes only that request: the full prompt (fresh + cache-read +
* cache-written input) plus this response's output — exactly what now sits
* in the context window. The result message's `usage` instead sums these
* per-request counts across the whole run, so a multi-request turn
* re-counts cached context once per request and quickly exceeds the real
* window; it must never be treated as occupancy.
*/
export function contextOccupancyTokens(
usage:
| {
input_tokens?: number | null;
cache_read_input_tokens?: number | null;
cache_creation_input_tokens?: number | null;
output_tokens?: number | null;
}
| null
| undefined,
) {
if (!usage || typeof usage.input_tokens !== "number") return undefined;
const count = (value: number | null | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
return (
count(usage.input_tokens) +
count(usage.cache_read_input_tokens) +
count(usage.cache_creation_input_tokens) +
count(usage.output_tokens)
);
}
function resultContextWindow(result: SDKResultMessage) {
return Object.values(result.modelUsage)[0]?.contextWindow;
}
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
});
return Promise.race([
operation.then(
() => undefined,
() => undefined,
),
timeout,
]).finally(() => {
if (timer) clearTimeout(timer);
});
}
// --- The session -------------------------------------------------------------
interface NativeQueuedMessage extends QueuedMessage {
readonly uuid: string;
/** Raw response sequence after which this steer can be consumed. */
readonly afterResponse: number;
}
const makeClaudeSession = (
task: SpawnTask,
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
Effect.gen(function* () {
const input = new ClaudeInput();
const abortController = new AbortController();
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
const emit = (event: SubagentEvent) => {
Queue.offerUnsafe(events, event);
};
const state = {
closed: false,
activeRun: false,
interruptRequested: false,
runVersion: 0,
lastSettledVersion: 0,
responseSequence: 0,
queued: [] as NativeQueuedMessage[],
submittedUuids: new Set<string>(),
currentText: "",
liveText: "",
tools: new Map<string, string>(),
settleWaiters: new Set<() => void>(),
meta: {
backend: "claude",
modelLabel: task.model,
// Claude models used by this backend currently expose 200k context;
// result.modelUsage replaces this fallback when the CLI knows better.
contextWindow: CLAUDE_CONTEXT_WINDOW,
} satisfies SubagentMeta as SubagentMeta,
};
const thinkingBudget = task.reasoningEffort
? THINKING_BUDGETS[task.reasoningEffort]
: undefined;
const claudeBinary = resolveClaudeBinary();
const nativeQuery = yield* Effect.try({
try: () =>
query({
prompt: input,
options: {
cwd: task.cwd,
// Headless children cannot answer approval prompts. The caller
// already chose to launch an autonomous subagent, so let it use
// its tools without interactive permission checks.
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
// Keep child orchestration inside this extension's global manager
// and concurrency cap rather than Claude Code's native subagents.
disallowedTools: ["Agent", "Task"],
// For cwds pi marked untrusted, restrict to user-level settings so
// an untrusted project's config cannot reconfigure the child.
...(task.parent.projectTrusted
? {}
: { settingSources: ["user" as const] }),
includePartialMessages: true,
abortController,
...(claudeBinary
? { pathToClaudeCodeExecutable: claudeBinary }
: {}),
...(task.model ? { model: task.model } : {}),
...(thinkingBudget !== undefined
? { maxThinkingTokens: thinkingBudget }
: {}),
},
}),
catch: (error) => new SpawnError({ message: boundedError(error) }),
});
let resolvePumpDone: (() => void) | undefined;
const pumpDone = new Promise<void>((resolve) => {
resolvePumpDone = resolve;
});
const queuedView = (): ReadonlyArray<QueuedMessage> =>
state.queued.map(({ text, kind }) => ({ text, kind }));
const notifySettled = () => {
for (const waiter of state.settleWaiters) waiter();
state.settleWaiters.clear();
};
const partialText = () => state.liveText || state.currentText || undefined;
const settle = (outcome: RunOutcome) => {
if (!state.activeRun) return;
emit({ _tag: "RunSettled", outcome });
state.activeRun = false;
state.lastSettledVersion = state.runVersion;
state.interruptRequested = false;
state.liveText = "";
notifySettled();
};
const updateMeta = (patch: Partial<SubagentMeta>) => {
state.meta = { ...state.meta, ...patch };
emit({ _tag: "MetaChanged", meta: patch });
};
const beginQueuedRunIfNeeded = () => {
if (state.activeRun || state.queued.length === 0) return;
// A steer arriving too late for the prior turn becomes a fresh queued
// turn. The repeated init is the first reliable signal that it started.
state.activeRun = true;
state.runVersion++;
state.currentText = "";
state.liveText = "";
emit({ _tag: "RunStarted" });
};
const clearConsumedSteers = () => {
const remaining = state.queued.filter(
(message) => message.afterResponse >= state.responseSequence,
);
if (remaining.length === state.queued.length) return;
state.queued = remaining;
emit({ _tag: "QueueChanged", queued: queuedView() });
};
const handleAssistant = (message: SDKAssistantMessage) => {
const parts = assistantParts(message);
if (parts.length > 0) emit({ _tag: "AssistantMessage", parts });
// Top-level messages only: subagent (sidechain) requests have their own
// context and must not overwrite this conversation's occupancy.
if (message.parent_tool_use_id == null) {
const tokens = contextOccupancyTokens(message.message.usage);
if (tokens !== undefined) emit({ _tag: "UsageChanged", tokens });
}
const text = message.message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim();
if (text) state.currentText = text;
state.liveText = "";
if (message.message.model !== state.meta.modelLabel) {
updateMeta({ modelLabel: message.message.model });
}
for (const block of message.message.content) {
if (block.type !== "tool_use") continue;
state.tools.set(block.id, block.name);
emit({
_tag: "ToolStart",
toolId: block.id,
name: block.name,
argsPreview: safeJson(block.input),
});
}
};
const handleUser = (message: SDKUserMessage) => {
const content = message.message.content;
if (!Array.isArray(content)) return;
for (const block of content) {
if (block.type !== "tool_result") continue;
const name = state.tools.get(block.tool_use_id) ?? "Tool";
state.tools.delete(block.tool_use_id);
emit({
_tag: "ToolEnd",
toolId: block.tool_use_id,
name,
isError: block.is_error ?? false,
outputPreview: outputPreview(block.content),
});
}
// External prompts are emitted synchronously by submit(); ignoring text
// here prevents a future CLI echo from duplicating the transcript row.
};
const handleResult = (result: SDKResultMessage) => {
// result.usage is a whole-run aggregate, not occupancy (see
// contextOccupancyTokens); only the capacity is trustworthy here. The
// occupancy itself was already emitted by the last assistant message.
const contextWindow = resultContextWindow(result);
emit({
_tag: "UsageChanged",
contextWindow: contextWindow ?? state.meta.contextWindow,
});
if (
contextWindow !== undefined &&
contextWindow !== state.meta.contextWindow
) {
updateMeta({ contextWindow });
}
if (state.interruptRequested) {
settle({ _tag: "Interrupted", partialText: partialText() });
} else if (result.subtype === "success") {
settle({
_tag: "Completed",
finalText: result.result.trim() || state.currentText,
});
} else {
const details =
result.errors.filter((error) => error.trim()).join("\n") ||
result.stop_reason ||
`Claude Code ended with ${result.subtype}`;
settle({
_tag: "Failed",
errorText: boundedError(details),
partialText: partialText(),
});
}
};
const handleMessage = (message: SDKMessage) => {
if (state.closed) return;
// A queued steer's turn normally announces itself with a repeated
// system/init, but any turn activity is an equally valid begin signal —
// without this, a missed init would stream events into a "done" run
// that could then never settle.
if (
message.type === "stream_event" ||
message.type === "assistant" ||
message.type === "result"
) {
beginQueuedRunIfNeeded();
}
if (message.type === "system" && message.subtype === "init") {
beginQueuedRunIfNeeded();
updateMeta({
modelLabel: message.model,
nativeSessionId: message.session_id,
sessionFilePath: sessionFilePath(message.cwd, message.session_id),
contextWindow: CLAUDE_CONTEXT_WINDOW,
});
} else if (message.type === "stream_event") {
if (message.parent_tool_use_id !== null) return;
if (message.event.type === "message_start") {
state.responseSequence++;
clearConsumedSteers();
} else if (message.event.type === "content_block_delta") {
const delta = message.event.delta;
if (delta.type === "text_delta") {
state.liveText += delta.text;
emit({ _tag: "AssistantDelta", kind: "text", delta: delta.text });
} else if (delta.type === "thinking_delta") {
emit({
_tag: "AssistantDelta",
kind: "thinking",
delta: delta.thinking,
});
}
}
} else if (message.type === "assistant") {
handleAssistant(message);
} else if (message.type === "user") {
handleUser(message);
} else if (message.type === "result") {
handleResult(message);
}
};
const pump = async () => {
let failure: string | undefined;
try {
for await (const message of nativeQuery) handleMessage(message);
} catch (error) {
if (!state.closed && !abortController.signal.aborted) {
failure = boundedError(error);
}
} finally {
if (!state.closed) {
if (state.activeRun) {
settle(
state.interruptRequested
? { _tag: "Interrupted", partialText: partialText() }
: {
_tag: "Failed",
errorText:
failure ?? "Claude Code query ended unexpectedly",
partialText: partialText(),
},
);
} else if (failure) {
emit({ _tag: "BackendError", message: failure });
}
state.closed = true;
Queue.endUnsafe(events);
}
resolvePumpDone?.();
}
};
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
// Settle before marking closed: the pump's finally skips settlement
// once closed, and every run must end in a RunSettled even when the
// scope closes mid-run.
if (state.activeRun) {
settle({ _tag: "Interrupted", partialText: partialText() });
}
state.closed = true;
input.end();
abortController.abort();
nativeQuery.close();
await waitBounded(pumpDone, INTERRUPT_TIMEOUT_MS);
Queue.endUnsafe(events);
}),
);
void pump();
const submit = (text: string) => {
const wasActive = state.activeRun;
const message = input.push(text);
if (!message) return false;
if (!wasActive) {
state.activeRun = true;
state.runVersion++;
state.currentText = "";
state.liveText = "";
}
state.submittedUuids.add(message.uuid ?? "");
// Idle restarts flip status synchronously (like pi's startRun); a steer
// into an active run must NOT re-emit RunStarted — its own turn begins
// later via beginQueuedRunIfNeeded.
if (!wasActive) emit({ _tag: "RunStarted" });
emit({ _tag: "UserMessage", text });
if (wasActive) {
state.queued.push({
text,
kind: "steer",
uuid: message.uuid ?? "",
afterResponse: state.responseSequence,
});
emit({ _tag: "QueueChanged", queued: queuedView() });
}
return true;
};
const waitForVersion = (version: number) => {
if (state.lastSettledVersion >= version) return Promise.resolve();
return new Promise<void>((resolve) => {
const waiter = () => {
if (state.lastSettledVersion < version && !state.closed) return;
state.settleWaiters.delete(waiter);
resolve();
};
state.settleWaiters.add(waiter);
});
};
emit({ _tag: "MetaChanged", meta: state.meta });
submit(task.prompt);
return {
meta: Effect.sync(() => state.meta),
events: Stream.fromQueue(events),
send: (text) =>
Effect.suspend((): Effect.Effect<void, SendError> => {
if (state.closed) {
return new SendError({ message: "Subagent session is closed." });
}
return submit(text)
? Effect.void
: new SendError({ message: "Subagent session is closed." });
}),
interrupt: Effect.promise(async () => {
if (state.closed || !state.activeRun) return;
const version = state.runVersion;
state.interruptRequested = true;
input.clear();
state.queued = [];
emit({ _tag: "QueueChanged", queued: [] });
const interruptAndSettle = (async () => {
try {
const receipt = await nativeQuery.interrupt();
const hasOwnQueuedMessage = receipt?.still_queued?.some((uuid) =>
state.submittedUuids.has(uuid),
);
if (hasOwnQueuedMessage) {
// 0.3.207 exposes cancellation receipts but no public per-message
// cancel method. Closing is the only way to prevent a cancelled
// queued prompt from immediately starting another turn.
input.end();
nativeQuery.close();
}
} catch (error) {
if (!state.closed) {
emit({ _tag: "BackendError", message: boundedError(error) });
}
}
await waitForVersion(version);
})();
await waitBounded(interruptAndSettle, INTERRUPT_TIMEOUT_MS);
if (!state.closed && state.activeRun && state.runVersion === version) {
// Covers pre-init/pending races and SDK versions that acknowledge an
// interrupt without delivering a result. Force-close after settling
// so a late native result cannot resurrect or re-settle this run.
settle({ _tag: "Interrupted", partialText: partialText() });
state.closed = true;
input.end();
abortController.abort();
nativeQuery.close();
Queue.endUnsafe(events);
}
}),
} satisfies SubagentSession;
});
// --- Backend -----------------------------------------------------------------
export const claudeBackend: SubagentBackend = {
name: "claude",
capabilities: { steering: true, modelSelection: true, reasoningEffort: true },
available: Effect.sync(() => resolveClaudeBinary() !== undefined),
spawn: makeClaudeSession,
};
File diff suppressed because it is too large Load Diff
+575
View File
@@ -0,0 +1,575 @@
/**
* pi backend — real implementation over the pi SDK.
*
* Each subagent is an in-process `AgentSession` (a port of v1
* subagents/manager.ts + shared/child-session.ts):
* - real session files visible in /resume, child resources loaded per-cwd
* with trust gating, and the child tool denylist;
* - `session.subscribe()` events translated to normalized SubagentEvents;
* - send() steers a streaming run or starts a fresh prompt() when idle;
* - interrupt clears the queue and aborts; closing the session scope emits
* the child session_shutdown hook and disposes the session.
*/
import type { AssistantMessage, Message, Model } from "@earendil-works/pi-ai";
import type {
AgentSession,
AgentSessionEvent,
ModelRegistry,
} from "@earendil-works/pi-coding-agent";
import {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import type { Cause, Scope } from "effect";
import { Effect, Queue, Stream } from "effect";
import type { SubagentBackend, SubagentSession } from "../backend.ts";
import type {
SpawnTask,
SubagentEvent,
SubagentMeta,
TranscriptPart,
} from "../domain.ts";
import { SendError, SpawnError } from "../domain.ts";
import { createToolCallTimeoutGuard } from "../../../shared/tool-call-timeout.ts";
const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
/** Tools that headless children must not receive. Everything else stays enabled. */
const CHILD_EXCLUDED_TOOL_NAMES = [
"subagent_spawn",
"subagent_wait",
"subagent_cancel",
"subagent_check",
"subagent_list",
"workflow",
"ask_user",
] as const;
// --- Model + effort resolution -----------------------------------------------
type ThinkingLevel = NonNullable<
NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"]
>;
/**
* Resolve the generic model hint against the parent registry (v1 semantics):
* "provider/model-id" is exact; a bare id prefers the inherited provider,
* then must be unambiguous across providers. No hint inherits the parent
* model; with nothing to inherit, the SDK default applies.
*/
function resolvePiModel(
registry: ModelRegistry,
hint: string | undefined,
inherited: { provider: string; id: string } | undefined,
): Model<any> | undefined {
if (!hint) {
if (!inherited) return undefined;
return registry.find(inherited.provider, inherited.id) ?? undefined;
}
const slash = hint.indexOf("/");
if (slash > 0) {
const provider = hint.slice(0, slash);
const id = hint.slice(slash + 1);
const found = registry.find(provider, id);
if (found) return found;
throw new Error(`Unknown model "${hint}".`);
}
if (inherited) {
const found = registry.find(inherited.provider, hint);
if (found) return found;
}
const matches = registry.getAll().filter((m) => m.id === hint);
if (matches.length === 1) return matches[0];
if (matches.length > 1) {
throw new Error(
`Model "${hint}" exists in multiple providers (${matches.map((m) => m.provider).join(", ")}). Use "provider/${hint}".`,
);
}
throw new Error(`Unknown model "${hint}".`);
}
// --- Child session helpers (ported from v1 shared/child-session.ts) -----------
/** Load normal global/package resources and trust-gated project resources. */
async function createChildResources(cwd: string, projectTrusted: boolean) {
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir, {
projectTrusted,
});
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await loader.reload();
return { loader, settingsManager };
}
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
});
return Promise.race([
operation.then(
() => undefined,
() => undefined,
),
timeout,
])
.catch(() => {})
.finally(() => {
if (timer) clearTimeout(timer);
});
}
/** Emit child session_shutdown (bounded), then dispose. Never throws. */
async function shutdownAndDisposeChildSession(session: AgentSession) {
try {
if (session.extensionRunner.hasHandlers("session_shutdown")) {
await waitBounded(
session.extensionRunner.emit({
type: "session_shutdown",
reason: "quit",
}),
CHILD_SHUTDOWN_TIMEOUT_MS,
);
}
} catch {
// Extension runner inspection/emission is best-effort during teardown.
} finally {
try {
session.dispose();
} catch {
// Disposal is terminal and must remain idempotent for callers.
}
}
}
// --- Event translation ----------------------------------------------------------
function messageRole(msg: unknown): Message["role"] | undefined {
const role = (msg as { role?: string } | undefined)?.role;
if (role === "user" || role === "assistant" || role === "toolResult")
return role;
return undefined;
}
function lastAssistantMessage(
session: AgentSession,
): AssistantMessage | undefined {
const messages = session.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (messageRole(msg) === "assistant") return msg as AssistantMessage;
}
return undefined;
}
/** Final assistant text output (last assistant message with text), v1 semantics. */
function finalOutput(session: AgentSession): string {
const messages = session.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (messageRole(msg) !== "assistant") continue;
const text = (msg as AssistantMessage).content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
if (text) return text;
}
return "";
}
function safeJson(value: unknown): string | undefined {
try {
const text = JSON.stringify(value);
return text === "{}" ? undefined : text.slice(0, 4_096);
} catch {
return undefined;
}
}
/** First non-empty line of a tool result-ish value (v1 liveToolPreview). */
function toolPreview(value: unknown): string | undefined {
if (typeof value === "string") {
return value
.split("\n")
.find((line) => line.trim())
?.trim();
}
if (!value || typeof value !== "object") return undefined;
const content = (value as { content?: unknown }).content;
if (!Array.isArray(content)) return undefined;
for (const part of content) {
if (!part || typeof part !== "object") continue;
const record = part as { type?: unknown; text?: unknown };
if (record.type !== "text" || typeof record.text !== "string") continue;
const firstLine = record.text.split("\n").find((line) => line.trim());
if (firstLine) return firstLine.trim();
}
return undefined;
}
function assistantParts(msg: AssistantMessage): TranscriptPart[] {
const parts: TranscriptPart[] = [];
for (const part of msg.content) {
if (part.type === "text") {
parts.push({ type: "text", text: part.text });
} else if (part.type === "thinking") {
parts.push({
type: "thinking",
text: part.redacted ? "" : part.thinking,
redacted: part.redacted,
});
} else if (part.type === "toolCall") {
parts.push({
type: "toolCall",
toolId: part.id,
name: part.name,
argsPreview: safeJson(part.arguments),
});
}
}
return parts;
}
function userText(msg: Message): string {
const content = (msg as { content: unknown }).content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(part): part is { type: "text"; text: string } =>
!!part &&
typeof part === "object" &&
(part as { type?: unknown }).type === "text",
)
.map((part) => part.text)
.join("\n");
}
// --- The session ------------------------------------------------------------------
function boundedError(error: unknown) {
return (error instanceof Error ? error.message : String(error)).slice(
0,
4096,
);
}
const makePiSession = (
task: SpawnTask,
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
Effect.gen(function* () {
const registry = task.parent.modelRegistry;
if (!registry) {
return yield* new SpawnError({
message: "pi backend requires the parent session's model registry.",
});
}
const model = yield* Effect.try({
try: () =>
resolvePiModel(registry, task.model, task.parent.inheritedModel),
catch: (error) => new SpawnError({ message: boundedError(error) }),
});
// pi's thinking levels ARE the shared reasoning-effort scale.
const thinkingLevel = (task.reasoningEffort ??
task.parent.inheritedThinkingLevel) as ThinkingLevel | undefined;
const session = yield* Effect.tryPromise({
try: async () => {
const { loader, settingsManager } = await createChildResources(
task.cwd,
task.parent.projectTrusted,
);
const { session } = await createAgentSession({
cwd: task.cwd,
sessionManager: SessionManager.create(task.cwd),
settingsManager,
resourceLoader: loader,
model,
thinkingLevel,
excludeTools: [...CHILD_EXCLUDED_TOOL_NAMES],
});
// Start child extension session hooks/resources in headless mode.
// A rejection here would otherwise leak the freshly created session:
// the scope finalizer that owns cleanup is only registered later.
try {
await session.bindExtensions({ mode: "print" });
} catch (error) {
await shutdownAndDisposeChildSession(session);
throw error;
}
return session;
},
catch: (error) => new SpawnError({ message: boundedError(error) }),
});
const state = {
closed: false,
/** prompt() rejection for the active run; folded into RunSettled. */
runError: undefined as string | undefined,
/** One terminal event per run: lifecycle, prompt-rejection, and abort
* fallbacks can all race to settle; the first wins. */
settled: false,
};
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
const emit = (event: SubagentEvent) => {
Queue.offerUnsafe(events, event);
};
const toolTimeout = createToolCallTimeoutGuard();
toolTimeout.apply(session);
const activeModel = (): Model<any> | undefined => {
const sessionModel = session.model;
const last = lastAssistantMessage(session);
if (!last) return sessionModel;
if (
sessionModel &&
(last.provider !== sessionModel.provider ||
last.model !== sessionModel.id)
) {
// The session changed models after this assistant response.
return sessionModel;
}
return (
registry.find(last.provider, last.responseModel ?? last.model) ??
sessionModel
);
};
const currentMeta = (): SubagentMeta => {
const m = activeModel();
return {
backend: "pi",
modelLabel: m ? `${m.provider}/${m.id}` : undefined,
contextWindow: m?.contextWindow,
sessionFilePath: session.sessionFile,
};
};
const emitUsage = () => {
const usage = session.getContextUsage();
emit({
_tag: "UsageChanged",
tokens: usage?.tokens ?? undefined,
contextWindow: activeModel()?.contextWindow ?? usage?.contextWindow,
});
};
const settle = () => {
if (state.settled) return;
state.settled = true;
const last = lastAssistantMessage(session);
const partialText = finalOutput(session) || undefined;
if (last?.stopReason === "aborted") {
emit({
_tag: "RunSettled",
outcome: { _tag: "Interrupted", partialText },
});
return;
}
const errorText =
state.runError ??
(last?.stopReason === "error"
? (last.errorMessage ?? "Run failed")
: undefined);
if (errorText !== undefined) {
emit({
_tag: "RunSettled",
outcome: {
_tag: "Failed",
errorText: boundedError(errorText),
partialText,
},
});
return;
}
emit({
_tag: "RunSettled",
outcome: { _tag: "Completed", finalText: finalOutput(session) },
});
};
const handleEvent = (event: AgentSessionEvent) => {
if (state.closed) return;
switch (event.type) {
case "agent_start":
// Extensions may register tools between runs; guard new ones too.
toolTimeout.apply(session);
state.settled = false;
emit({ _tag: "RunStarted" });
break;
case "message_update": {
const streamEvent = event.assistantMessageEvent;
if (streamEvent.type === "text_delta") {
emit({
_tag: "AssistantDelta",
kind: "text",
delta: streamEvent.delta,
});
} else if (streamEvent.type === "thinking_delta") {
emit({
_tag: "AssistantDelta",
kind: "thinking",
delta: streamEvent.delta,
});
}
break;
}
case "message_end": {
const role = messageRole(event.message);
if (role === "user") {
const text = userText(event.message as Message);
if (text.trim()) emit({ _tag: "UserMessage", text });
} else if (role === "assistant") {
emit({
_tag: "AssistantMessage",
parts: assistantParts(event.message as AssistantMessage),
});
emitUsage();
emit({ _tag: "MetaChanged", meta: currentMeta() });
}
// toolResult messages are covered by tool_execution_end.
break;
}
case "tool_execution_start":
emit({
_tag: "ToolStart",
toolId: event.toolCallId,
name: event.toolName,
argsPreview: safeJson(event.args),
});
break;
case "tool_execution_update":
emit({
_tag: "ToolUpdate",
toolId: event.toolCallId,
outputPreview: toolPreview(event.partialResult),
});
break;
case "tool_execution_end":
emit({
_tag: "ToolEnd",
toolId: event.toolCallId,
name: event.toolName,
isError: event.isError,
outputPreview: toolPreview(event.result),
});
break;
case "queue_update":
emit({
_tag: "QueueChanged",
queued: [
...event.steering.map((text) => ({
text,
kind: "steer" as const,
})),
...event.followUp.map((text) => ({
text,
kind: "follow-up" as const,
})),
],
});
break;
case "agent_settled":
settle();
break;
}
};
const unsubscribe = session.subscribe(handleEvent);
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
state.closed = true;
unsubscribe();
try {
session.clearQueue();
} catch {
// Continue with abort/dispose.
}
await waitBounded(session.abort(), CHILD_SHUTDOWN_TIMEOUT_MS);
await shutdownAndDisposeChildSession(session);
Queue.endUnsafe(events);
}),
);
/** Start a fresh run (v1 manager.run): fire-and-forget, errors -> events. */
const startRun = (text: string) => {
state.runError = undefined;
state.settled = false;
emit({ _tag: "RunStarted" });
void session.prompt(text).catch((error) => {
state.runError = boundedError(error);
// Preflight failures may never start the agent lifecycle, so no
// agent_settled will arrive for them.
if (!session.isStreaming) settle();
});
};
// Session naming is best-effort.
yield* Effect.try(() =>
session.sessionManager.appendSessionInfo(
`${task.origin === "btw" ? "btw" : "subagent"}: ${task.title}`,
),
).pipe(Effect.ignore);
emit({ _tag: "MetaChanged", meta: currentMeta() });
startRun(task.prompt);
return {
meta: Effect.sync(currentMeta),
events: Stream.fromQueue(events),
send: (text) =>
Effect.suspend((): Effect.Effect<void, SendError> => {
if (state.closed) {
return new SendError({ message: "Subagent session is closed." });
}
if (session.isStreaming) {
// Steer the active run via the SDK's queue; queue_update events
// render it, message_end(user) lands it in the transcript. A
// rejected steer is a real send failure, not a diagnostic.
return Effect.tryPromise({
try: () => session.steer(text),
catch: (error) => new SendError({ message: boundedError(error) }),
}).pipe(Effect.asVoid);
}
return Effect.sync(() => startRun(text));
}),
interrupt: Effect.promise(async () => {
if (state.closed) return;
try {
session.clearQueue();
} catch {
// Abort regardless.
}
await session.abort().catch(() => undefined);
// Only resolve once streaming has actually stopped: reporting the
// interrupt as complete while the run keeps working would let the
// manager settle a run that is still mutating the workspace. The
// manager bounds this effect at 5s and force-disposes on timeout.
while (!state.closed && session.isStreaming) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
// No streaming run means no agent_settled will arrive; emit the
// terminal event (once) so the run cannot look running forever.
if (!state.closed && !state.settled) {
state.settled = true;
emit({ _tag: "RunSettled", outcome: { _tag: "Interrupted" } });
}
}),
} satisfies SubagentSession;
});
export const piBackend: SubagentBackend = {
name: "pi",
capabilities: { steering: true, modelSelection: true, reasoningEffort: true },
// In-process SDK: always available.
available: Effect.succeed(true),
spawn: makePiSession,
};
+300
View File
@@ -0,0 +1,300 @@
/**
* Scripted stub sessions shared by all three backend implementations while
* the real integrations are pending. A stub session:
*
* - streams a plausible turn (thinking deltas, one fake tool cycle, text
* deltas, usage ramp, a final assistant message, RunSettled) over a few
* seconds so streaming UI, wait, and the footer counters are observable;
* - supports send() while running (queued-steer rendering) and while idle
* (fresh run);
* - supports interrupt (RunSettled Interrupted -> status "error", matching v1);
* - fails the run when the prompt starts with "FAIL:" (error-path testing);
* - appends every event to a JSONL "session file" in tmpdir so the
* "full transcript in session file" pointers resolve.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { Cause, Scope } from "effect";
import { Duration, Effect, Fiber, Queue, Ref, Stream } from "effect";
import type { SubagentBackend, SubagentSession } from "../backend.ts";
import type {
BackendName,
QueuedMessage,
SpawnTask,
SubagentEvent,
SubagentMeta,
} from "../domain.ts";
import { SendError } from "../domain.ts";
export interface StubProfile {
readonly backend: BackendName;
readonly defaultModelLabel: string;
readonly contextWindow: number;
readonly toolName: string;
/** Delay between scripted events; varies per backend so streams differ. */
readonly cadenceMs: number;
}
const STUB_DIR = path.join(os.tmpdir(), "subagents-stub");
let sessionCounter = 0;
export function makeStubBackend(profile: StubProfile): SubagentBackend {
return {
name: profile.backend,
capabilities: {
steering: true,
modelSelection: true,
reasoningEffort: true,
},
// Real impls probe binary-on-PATH / SDK import / credentials here.
available: Effect.succeed(true),
spawn: (task) => makeStubSession(profile, task),
};
}
function firstLine(text: string): string {
return (
text
.split("\n")
.find((line) => line.trim())
?.trim() ?? ""
);
}
function chunked(text: string, size: number): string[] {
const chunks: string[] = [];
for (let i = 0; i < text.length; i += size)
chunks.push(text.slice(i, i + size));
return chunks;
}
const makeStubSession = (
profile: StubProfile,
task: SpawnTask,
): Effect.Effect<SubagentSession, never, Scope.Scope> =>
Effect.gen(function* () {
const sessionId = `stub-${profile.backend}-${++sessionCounter}`;
const sessionFile = path.join(STUB_DIR, `${sessionId}.jsonl`);
const state = {
meta: {
backend: profile.backend,
modelLabel: task.model ?? profile.defaultModelLabel,
contextWindow: profile.contextWindow,
sessionFilePath: sessionFile,
nativeSessionId: sessionId,
} satisfies SubagentMeta as SubagentMeta,
pending: [] as string[],
turnCount: 0,
closed: false,
/** True between the driver dequeuing a prompt and registering its turn fiber. */
dispatching: false,
};
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
const inbox = yield* Queue.make<string, Cause.Done>();
const activeTurn = yield* Ref.make<Fiber.Fiber<void> | undefined>(
undefined,
);
const emit = (event: SubagentEvent) =>
Effect.suspend(() => {
try {
fs.appendFileSync(sessionFile, `${JSON.stringify(event)}\n`);
} catch {
// The fake session file is best-effort.
}
if (event._tag === "MetaChanged") {
state.meta = { ...state.meta, ...event.meta };
}
return Queue.offer(events, event);
}).pipe(Effect.asVoid);
const pause = Effect.sleep(Duration.millis(profile.cadenceMs));
const runTurn = (userText: string, turn: number) =>
Effect.gen(function* () {
yield* emit({ _tag: "RunStarted" });
const failing = userText.trimStart().startsWith("FAIL:");
const thinking = "Looking at the task and planning an approach...";
for (const delta of chunked(thinking, 16)) {
yield* emit({ _tag: "AssistantDelta", kind: "thinking", delta });
yield* pause;
}
const toolId = `${sessionId}-tool-${turn}`;
const argsPreview = `{"command":"ls ${task.cwd}"}`;
yield* emit({
_tag: "AssistantMessage",
parts: [
{ type: "thinking", text: thinking },
{
type: "text",
text: `I'll run ${profile.toolName} to look around first.`,
},
{ type: "toolCall", toolId, name: profile.toolName, argsPreview },
],
});
yield* emit({
_tag: "ToolStart",
toolId,
name: profile.toolName,
argsPreview,
});
yield* pause;
yield* emit({
_tag: "ToolUpdate",
toolId,
outputPreview: "src docs package.json",
});
yield* pause;
yield* emit({
_tag: "ToolEnd",
toolId,
name: profile.toolName,
isError: false,
outputPreview: "src docs package.json",
});
yield* emit({
_tag: "UsageChanged",
tokens: Math.min(profile.contextWindow, 2400 * (turn + 1)),
contextWindow: profile.contextWindow,
});
if (failing) {
yield* pause;
yield* emit({
_tag: "RunSettled",
outcome: {
_tag: "Failed",
errorText: `[stub:${profile.backend}] task failed as requested by FAIL: prefix`,
},
});
return;
}
const finalText =
`[stub:${profile.backend}] completed: ${firstLine(userText).slice(0, 200)}\n\n` +
`This is a stubbed ${profile.backend} subagent turn ${turn + 1}. ` +
`The real backend integration will replace this scripted output.`;
for (const delta of chunked(finalText, 24)) {
yield* emit({ _tag: "AssistantDelta", kind: "text", delta });
yield* pause;
}
yield* emit({
_tag: "AssistantMessage",
parts: [{ type: "text", text: finalText }],
});
yield* emit({
_tag: "UsageChanged",
tokens: Math.min(profile.contextWindow, 2400 * (turn + 1) + 900),
contextWindow: profile.contextWindow,
});
yield* emit({
_tag: "RunSettled",
outcome: { _tag: "Completed", finalText },
});
});
const queuedView = (): ReadonlyArray<QueuedMessage> =>
state.pending.map((text) => ({ text, kind: "steer" as const }));
// Driver: one turn at a time, in submission order. Turns run as child
// fibers so interrupt() stops the turn without killing the driver.
const driver = Effect.gen(function* () {
while (true) {
const text = yield* Queue.take(inbox);
state.dispatching = true;
state.pending.shift();
yield* emit({ _tag: "QueueChanged", queued: queuedView() });
yield* emit({ _tag: "UserMessage", text });
const turn = state.turnCount++;
const fiber = yield* Effect.forkChild(
runTurn(text, turn).pipe(
Effect.onInterrupt(() =>
emit({
_tag: "RunSettled",
outcome: { _tag: "Interrupted" },
}).pipe(Effect.ignore),
),
),
);
yield* Ref.set(activeTurn, fiber);
state.dispatching = false;
yield* Fiber.await(fiber);
yield* Ref.set(activeTurn, undefined);
}
});
yield* Effect.forkScoped(driver.pipe(Effect.ignore));
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
state.closed = true;
yield* Queue.end(inbox).pipe(Effect.ignore);
yield* Queue.end(events).pipe(Effect.ignore);
}),
);
const submit = (text: string) =>
Effect.gen(function* () {
if (state.closed) {
return yield* new SendError({
message: "Subagent session is closed.",
});
}
state.pending.push(text);
const busy = (yield* Ref.get(activeTurn)) !== undefined;
if (busy) {
// Show the queued steer line until the driver picks it up.
yield* emit({ _tag: "QueueChanged", queued: queuedView() });
}
yield* Queue.offer(inbox, text);
});
// Announce metadata, then kick off the initial run.
yield* Effect.try(() => fs.mkdirSync(STUB_DIR, { recursive: true })).pipe(
Effect.ignore, // The fake session file directory is best-effort.
);
yield* emit({ _tag: "MetaChanged", meta: state.meta });
// The session cannot be closed yet, so the initial submit cannot fail.
yield* submit(task.prompt).pipe(Effect.orDie);
return {
meta: Effect.sync(() => state.meta),
events: Stream.fromQueue(events),
send: submit,
interrupt: Effect.gen(function* () {
// Drop queued prompts so interrupting cannot immediately start
// another turn, then stop the active turn. A prompt may be mid-flight
// between the driver dequeuing it and registering its fiber, so wait
// that window out instead of silently missing the turn.
const cleared = yield* Queue.clear(inbox).pipe(
Effect.orElseSucceed(() => []),
);
state.pending = [];
yield* emit({ _tag: "QueueChanged", queued: [] });
while (true) {
const fiber = yield* Ref.get(activeTurn);
if (fiber) {
yield* Fiber.interrupt(fiber);
return;
}
if (!state.dispatching) {
// No turn ever started. If we cancelled queued prompts, the run
// still needs a terminal event or it would look running forever.
if (cleared.length > 0) {
yield* emit({
_tag: "RunSettled",
outcome: { _tag: "Interrupted" },
});
}
return;
}
yield* Effect.sleep(Duration.millis(5));
}
}),
} satisfies SubagentSession;
});