diff --git a/.changeset/spawn-inherit-thinking-level.md b/.changeset/spawn-inherit-thinking-level.md
new file mode 100644
index 0000000..0903d4d
--- /dev/null
+++ b/.changeset/spawn-inherit-thinking-level.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Sessions started via `spawn_session` and `spawn_subsession` now inherit the spawning session's thinking level instead of falling back to the pi default, clamped to the child model's capabilities.
diff --git a/docs/config.html b/docs/config.html
index 17314f4..3ba1ede 100644
--- a/docs/config.html
+++ b/docs/config.html
@@ -813,7 +813,8 @@
inheriting the dispatching session's model. The match is strict: an unknown or malformed value is
rejected with an error. A #provider/model-id reference in the prompt (see
Prompt completions) is how users ask for a specific model; agents
- forward that reference as this parameter.
+ forward that reference as this parameter. The new session also inherits the dispatching session's
+ thinking level, clamped to its model's capabilities.
In Settings → Session daemon, these keys are saved on the selected machine. Restart the
diff --git a/docs/config.md b/docs/config.md
index e9d7fe8..b15c89b 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -273,7 +273,7 @@ A completion notice wakes an idle parent or queues behind in-flight work. Each n
`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. Included output and transcripts follow a labeled marker and come last, after PI WEB guidance.
-Both `spawn_session` and `spawn_subsession` accept an optional `model` parameter, given as an exact `provider/model-id` such as `anthropic/claude-sonnet-4-5`. When set, the new session starts on that model instead of inheriting the dispatching session's model. The match is strict: an unknown or malformed value is rejected with an error. A `#provider/model-id` reference in the prompt (see [Prompt completions](#prompt-completions)) is how users ask for a specific model; agents forward that reference as this parameter.
+Both `spawn_session` and `spawn_subsession` accept an optional `model` parameter, given as an exact `provider/model-id` such as `anthropic/claude-sonnet-4-5`. When set, the new session starts on that model instead of inheriting the dispatching session's model. The match is strict: an unknown or malformed value is rejected with an error. A `#provider/model-id` reference in the prompt (see [Prompt completions](#prompt-completions)) is how users ask for a specific model; agents forward that reference as this parameter. The new session also inherits the dispatching session's thinking level, clamped to its model's capabilities.
In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.
diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts
index 439f724..47cfecb 100644
--- a/src/server/sessions/piSessionService.spawnSession.test.ts
+++ b/src/server/sessions/piSessionService.spawnSession.test.ts
@@ -61,6 +61,52 @@ describe("PiSessionService", () => {
await service.dispose();
});
+ it("passes the dispatching session's thinking level to the spawned session's runtime", async () => {
+ const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
+ let initialThinkingLevel: unknown;
+ const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
+ await Promise.resolve();
+ initialThinkingLevel = options.initialThinkingLevel;
+ return fake.runtime;
+ };
+ const service = new PiSessionService(new CapturingSessionEventHub(), {
+ agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
+ createAgentRuntime,
+ sessionManager: sessionGateway([]),
+ spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
+ heartbeatIntervalMs: 60_000,
+ });
+
+ await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature", thinkingLevel: "high" });
+
+ expect(initialThinkingLevel).toBe("high");
+ await service.dispose();
+ });
+
+ it("leaves the spawned session's thinking level to pi defaults when the dispatcher has none", async () => {
+ const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
+ let initialThinkingLevel: unknown = "unset";
+ const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
+ await Promise.resolve();
+ initialThinkingLevel = options.initialThinkingLevel;
+ return fake.runtime;
+ };
+ const service = new PiSessionService(new CapturingSessionEventHub(), {
+ agentDir: TEST_AGENT_DIR,
+ modelRuntime: testModelRuntime,
+ createAgentRuntime,
+ sessionManager: sessionGateway([]),
+ spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
+ heartbeatIntervalMs: 60_000,
+ });
+
+ await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature" });
+
+ expect(initialThinkingLevel).toBeUndefined();
+ await service.dispose();
+ });
+
it("names the spawned session's model in the result", async () => {
const spawned = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl", model: testModel() });
const service = new PiSessionService(new CapturingSessionEventHub(), {
diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts
index 4a43468..e28de31 100644
--- a/src/server/sessions/piSessionService.spawnSubsession.test.ts
+++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts
@@ -64,17 +64,19 @@ describe("PiSessionService", () => {
await service.dispose();
});
- it("uses the parent model and disables delegation before creating the tracked child runtime", async () => {
+ it("uses the parent model and thinking level and disables delegation before creating the tracked child runtime", async () => {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const model = testModel();
const initialModels: PiAgentSession["model"][] = [];
+ const initialThinkingLevels: unknown[] = [];
const delegationCapabilities: boolean[] = [];
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModels.push(options.initialModel);
+ initialThinkingLevels.push(options.initialThinkingLevel);
delegationCapabilities.push(options.delegationToolsEnabled);
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -91,9 +93,10 @@ describe("PiSessionService", () => {
});
await service.start("/workspace");
- await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model });
+ await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model, thinkingLevel: "max" });
expect(initialModels).toEqual([undefined, model]);
+ expect(initialThinkingLevels).toEqual([undefined, "max"]);
expect(delegationCapabilities).toEqual([true, false]);
await service.dispose();
});
diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts
index d85de27..5c93b3a 100644
--- a/src/server/sessions/piSessionService.ts
+++ b/src/server/sessions/piSessionService.ts
@@ -243,6 +243,11 @@ type SessionCreationProvenance = "tracked-subsession";
interface StartSessionOptions {
parentSession?: string;
initialModel?: AgentModel;
+ /**
+ * Thinking level for the brand new session; omit to resolve from settings
+ * and pi defaults. Pi clamps it to the initial model's capabilities.
+ */
+ initialThinkingLevel?: ClientThinkingLevel;
/**
* Opaque label, echoed on this construction's startup progress so a browser
* row with no session id yet can recognise its own.
@@ -439,7 +444,7 @@ interface PendingSessionOpen {
promise: Promise>;
}
-interface CreateSessionRuntimeOptions extends Pick {
+interface CreateSessionRuntimeOptions extends Pick {
notificationGeneration?: SessionNotificationGeneration;
notifications?: "enabled" | "disabled";
/**
@@ -607,11 +612,13 @@ interface CreateAgentRuntimeOptions {
sessionManager: PiSessionManager;
delegationToolsEnabled: boolean;
initialModel?: AgentModel;
+ initialThinkingLevel?: ClientThinkingLevel;
}
type PiWebRuntimeFactoryOptions = Parameters[0] & {
delegationToolsEnabled?: boolean;
initialModel?: AgentModel;
+ initialThinkingLevel?: ClientThinkingLevel;
};
type PiWebCreateAgentSessionRuntimeFactory = (
@@ -622,7 +629,7 @@ type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory,
function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise {
if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager");
- const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled);
+ const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.initialThinkingLevel, options.delegationToolsEnabled);
return createAgentSessionRuntime(runtimeFactory, {
cwd: options.cwd,
agentDir: options.agentDir,
@@ -633,20 +640,26 @@ function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntime
function createRuntimeWithOneShotSessionOptions(
createRuntime: PiWebCreateAgentSessionRuntimeFactory,
initialModel: AgentModel | undefined,
+ initialThinkingLevel: ClientThinkingLevel | undefined,
delegationToolsEnabled: boolean,
): CreateAgentSessionRuntimeFactory {
// These inputs belong only to the session being opened. A later runtime
- // replacement resolves its own model and delegation capability.
+ // replacement resolves its own model and delegation capability, and restores
+ // the thinking level from the existing session file.
let pendingInitialModel = initialModel;
+ let pendingInitialThinkingLevel = initialThinkingLevel;
let pendingDelegationToolsEnabled: boolean | undefined = delegationToolsEnabled;
return async (options) => {
const model = pendingInitialModel;
+ const thinkingLevel = pendingInitialThinkingLevel;
const toolsEnabled = pendingDelegationToolsEnabled;
pendingInitialModel = undefined;
+ pendingInitialThinkingLevel = undefined;
pendingDelegationToolsEnabled = undefined;
return createRuntime({
...options,
...(model === undefined ? {} : { initialModel: model }),
+ ...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }),
...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
});
};
@@ -678,7 +691,7 @@ function createDefaultRuntimeFactory(
subsessions?: SubsessionToolDeps,
askUser?: AskUserToolDeps,
): PiWebCreateAgentSessionRuntimeFactory {
- return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
+ return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
const resolvedDelegationToolsEnabled = delegationToolsEnabled
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
@@ -689,6 +702,7 @@ function createDefaultRuntimeFactory(
customTools,
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
...(initialModel === undefined ? {} : { model: initialModel }),
+ ...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
});
return { ...result, services, diagnostics: services.diagnostics };
};
@@ -1170,6 +1184,7 @@ export class PiSessionService implements SessionRouteService {
startupIntent: "create",
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
+ ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
},
);
@@ -1207,7 +1222,10 @@ export class PiSessionService implements SessionRouteService {
const model = input.modelSpec === undefined
? input.model
: await this.resolveSpawnModel(input.spawningSessionId, input.modelSpec);
- const created = await this.start(decision.cwd, model === undefined ? {} : { initialModel: model });
+ const created = await this.start(decision.cwd, {
+ ...(model === undefined ? {} : { initialModel: model }),
+ ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
+ });
const modelUsed = this.active.get(created.id)?.runtime.session.model;
await this.prompt(created.id, input.prompt);
this.logger.info(
@@ -1239,6 +1257,7 @@ export class PiSessionService implements SessionRouteService {
const created = await this.startSession(decision.cwd, {
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
...(model === undefined ? {} : { initialModel: model }),
+ ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
creationProvenance: "tracked-subsession",
});
const modelUsed = this.active.get(created.id)?.runtime.session.model;
@@ -2864,6 +2883,7 @@ export class PiSessionService implements SessionRouteService {
sessionManager,
delegationToolsEnabled,
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
+ ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
});
const active: ActiveSession = { runtime, unsubscribe: noop };
let boundSession = runtime.session;
diff --git a/src/server/sessions/spawnSessionTool.test.ts b/src/server/sessions/spawnSessionTool.test.ts
index 58dc660..5f03ad4 100644
--- a/src/server/sessions/spawnSessionTool.test.ts
+++ b/src/server/sessions/spawnSessionTool.test.ts
@@ -4,21 +4,21 @@ import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
-function ctxFor(sessionId: string, model?: unknown): ExtensionContext {
+function ctxFor(sessionId: string, model?: unknown, thinkingLevel?: string): ExtensionContext {
const sessionManager = { getSessionId: () => sessionId };
- // The spawn tool only reads sessionManager.getSessionId and model.
+ // The spawn tool only reads sessionManager.getSessionId, model, and thinkingLevel.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
- return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
+ return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext;
}
describe("createSpawnSessionToolDefinition", () => {
- it("passes the spawning identity, explicit cwd, dispatching model, and prompt to spawn callback", async () => {
+ it("passes the spawning identity, explicit cwd, dispatching model, thinking level, and prompt to spawn callback", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
- const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("spawner-1", dispatchModel));
+ const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("spawner-1", dispatchModel, "high"));
- expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel });
+ expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel, thinkingLevel: "high" });
expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" });
expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-1 in /repos/a-feature." });
});
@@ -31,7 +31,7 @@ describe("createSpawnSessionToolDefinition", () => {
expect(tool.description).not.toMatch(/subsession|child|parent/i);
});
- it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => {
+ it("forwards omitted cwd as undefined and omits a missing dispatching model and thinking level", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
diff --git a/src/server/sessions/spawnSessionTool.ts b/src/server/sessions/spawnSessionTool.ts
index 45c81e5..0c85e57 100644
--- a/src/server/sessions/spawnSessionTool.ts
+++ b/src/server/sessions/spawnSessionTool.ts
@@ -9,6 +9,7 @@ export interface SpawnSessionResult {
}
export type SpawnSessionModel = NonNullable;
+export type SpawnSessionThinkingLevel = NonNullable;
export interface SpawnSessionInvocation {
spawningCwd: string;
@@ -20,6 +21,8 @@ export interface SpawnSessionInvocation {
model?: SpawnSessionModel;
/** Strict `provider/model-id` requested by the dispatcher; overrides {@link model} when set. */
modelSpec?: string;
+ /** Dispatching session's current thinking level, inherited by the spawned session (pi clamps it to the spawned model's capabilities). */
+ thinkingLevel?: SpawnSessionThinkingLevel;
}
export interface SpawnSessionToolDeps {
@@ -64,6 +67,7 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
...(params.model === undefined ? {} : { modelSpec: params.model }),
+ ...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
});
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
return {
diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts
index da1f221..0dec9ff 100644
--- a/src/server/sessions/spawnSubsessionTool.test.ts
+++ b/src/server/sessions/spawnSubsessionTool.test.ts
@@ -5,11 +5,11 @@ import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spaw
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
-function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext {
+function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown, thinkingLevel?: string): ExtensionContext {
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
- // The subsession tools only read sessionManager.getSessionId/getSessionFile and model.
+ // The subsession tools only read sessionManager.getSessionId/getSessionFile, model, and thinkingLevel.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
- return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
+ return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext;
}
function tools(deps: Partial) {
@@ -48,7 +48,7 @@ describe("createSubsessionToolDefinitions", () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
const { spawn: spawnTool } = tools({ spawn });
- const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel));
+ const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel, "max"));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a",
@@ -57,6 +57,7 @@ describe("createSubsessionToolDefinitions", () => {
prompt: "do it",
cwd: "/repos/a-feature",
model: dispatchModel,
+ thinkingLevel: "max",
});
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
expect(firstText(result.content)).toContain("Started tracked subsession child-1");
diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts
index 173679b..4915d21 100644
--- a/src/server/sessions/spawnSubsessionTool.ts
+++ b/src/server/sessions/spawnSubsessionTool.ts
@@ -13,6 +13,7 @@ export interface SpawnSubsessionResult {
}
export type SpawnSubsessionModel = NonNullable;
+export type SpawnSubsessionThinkingLevel = NonNullable;
export interface SpawnSubsessionInvocation {
/** cwd of the session that invoked the tool (used for project-scope checks). */
@@ -27,6 +28,8 @@ export interface SpawnSubsessionInvocation {
model?: SpawnSubsessionModel;
/** Strict `provider/model-id` requested by the parent; overrides {@link model} when set. */
modelSpec?: string;
+ /** Parent's current thinking level, inherited by the child session (pi clamps it to the child model's capabilities). */
+ thinkingLevel?: SpawnSubsessionThinkingLevel;
}
export interface SubsessionSummary {
@@ -205,6 +208,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
...(params.model === undefined ? {} : { modelSpec: params.model }),
+ ...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
});
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
return {