feat(config): add askUser capability and shared ask contract

Introduce the shared contract for the upcoming ask_user tool: question and
pending-ask types in the API contract, the pendingAsk field on
SessionStatus, ask.opened/ask.closed session UI events, the askUser global
config key with a PI_WEB_ASK_USER env override, and the sessions.askUser
capability requiring both the web and session daemon runtimes.

askUser defaults to true: the questions land in the session the user is
already watching and nothing happens until they act, unlike the beta-off
subsessions flag.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-26 21:26:23 +02:00
parent 2a05d67436
commit 6fa57b524b
23 changed files with 181 additions and 15 deletions
+1 -1
View File
@@ -578,7 +578,7 @@ function piWebConfigResponse(config: PiWebConfigValues) {
exists: true, exists: true,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
+2 -2
View File
@@ -61,13 +61,13 @@ describe("API parsers", () => {
exists: true, exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } }, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
})).toEqual({ })).toEqual({
path: "/tmp/config.json", path: "/tmp/config.json",
exists: true, exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } }, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
}); });
}); });
+3
View File
@@ -958,6 +958,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
...optionalField("agent", optionalAgent(record["agent"])), ...optionalField("agent", optionalAgent(record["agent"])),
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
...optionalField("subsessions", optionalBoolean(record, "subsessions")), ...optionalField("subsessions", optionalBoolean(record, "subsessions")),
...optionalField("askUser", optionalBoolean(record, "askUser")),
}; };
} }
@@ -1038,6 +1039,8 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
allowedHosts: requireBoolean(record, "allowedHosts"), allowedHosts: requireBoolean(record, "allowedHosts"),
spawnSessions: requireBoolean(record, "spawnSessions"), spawnSessions: requireBoolean(record, "spawnSessions"),
subsessions: requireBoolean(record, "subsessions"), subsessions: requireBoolean(record, "subsessions"),
// Older servers predate the ask_user tool; a missing flag means "not overridden".
askUser: optionalBoolean(record, "askUser") ?? false,
agentCommand: optionalBoolean(record, "agentCommand") ?? false, agentCommand: optionalBoolean(record, "agentCommand") ?? false,
agentDir: optionalBoolean(record, "agentDir") ?? false, agentDir: optionalBoolean(record, "agentDir") ?? false,
...optionalAgentDirSource(record), ...optionalAgentDirSource(record),
@@ -63,7 +63,7 @@ export function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true, exists: true,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
@@ -230,6 +230,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true, exists: true,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
@@ -162,7 +162,7 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true, exists: true,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
@@ -153,6 +153,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true, exists: true,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
@@ -258,6 +258,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true, exists: true,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
@@ -101,6 +101,7 @@ function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebCo
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }), ...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }), ...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }), ...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
...(baseConfig.askUser === undefined ? {} : { askUser: baseConfig.askUser }),
...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }), ...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }),
}; };
} }
@@ -8,7 +8,7 @@ const configResponse: PiWebConfigResponse = {
exists: true, exists: true,
config: { host: "127.0.0.1" }, config: { host: "127.0.0.1" },
effectiveConfig: { host: "127.0.0.1" }, effectiveConfig: { host: "127.0.0.1" },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
const pluginsResponse: PiWebPluginsResponse = { plugins: [] }; const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
@@ -81,6 +81,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true, exists: true,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
@@ -64,6 +64,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true, exists: true,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
@@ -86,6 +86,7 @@ describe("session daemon settings config helpers", () => {
allowedHosts: false, allowedHosts: false,
spawnSessions: true, spawnSessions: true,
subsessions: false, subsessions: false,
askUser: false,
agentCommand: true, agentCommand: true,
agentDir: false, agentDir: false,
agentDirSource: "pi-compatibility", agentDirSource: "pi-compatibility",
@@ -121,6 +122,7 @@ function configResponse(
allowedHosts: false, allowedHosts: false,
spawnSessions: false, spawnSessions: false,
subsessions: false, subsessions: false,
askUser: false,
agentCommand: false, agentCommand: false,
agentDir: false, agentDir: false,
agentSessionDir: false, agentSessionDir: false,
@@ -41,6 +41,7 @@ export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, se
...base.envOverrides, ...base.envOverrides,
spawnSessions: selectedMachine.envOverrides.spawnSessions, spawnSessions: selectedMachine.envOverrides.spawnSessions,
subsessions: selectedMachine.envOverrides.subsessions, subsessions: selectedMachine.envOverrides.subsessions,
askUser: selectedMachine.envOverrides.askUser,
agentCommand: selectedMachine.envOverrides.agentCommand, agentCommand: selectedMachine.envOverrides.agentCommand,
agentDir: selectedMachine.envOverrides.agentDir, agentDir: selectedMachine.envOverrides.agentDir,
agentSessionDir: selectedMachine.envOverrides.agentSessionDir, agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
+40 -1
View File
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, offlineModeEnabled, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, askUserEnabled, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, offlineModeEnabled, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
let tempDir: string; let tempDir: string;
let configPath: string; let configPath: string;
@@ -182,6 +182,26 @@ describe("PI WEB config persistence", () => {
expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER }); expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER });
}); });
it("resolves askUser in the effective config so the runtime has a single source of truth", async () => {
expect(effectivePiWebConfig(testOptions()).config.askUser).toBe(true);
await writeFile(configPath, `${JSON.stringify({ askUser: false }, null, 2)}\n`, "utf8");
expect(effectivePiWebConfig(testOptions()).config.askUser).toBe(false);
expect(effectivePiWebConfig({ ...testOptions(), env: { ...testOptions().env, PI_WEB_ASK_USER: "1" } }).config.askUser).toBe(true);
});
it("round-trips the askUser key through save and load", () => {
expect(savePiWebConfig({ askUser: false }, testOptions()).config).toEqual({ askUser: false });
expect(loadPiWebConfig(testOptions()).config).toEqual({ askUser: false });
});
it("rejects a non-boolean askUser key", async () => {
await writeFile(configPath, `${JSON.stringify({ askUser: "yes" }, null, 2)}\n`, "utf8");
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config askUser must be a boolean");
});
it("rejects upload defaults that are not workspace-relative", async () => { it("rejects upload defaults that are not workspace-relative", async () => {
await writeFile(configPath, `${JSON.stringify({ uploads: { defaultFolder: "../outside" } }, null, 2)}\n`, "utf8"); await writeFile(configPath, `${JSON.stringify({ uploads: { defaultFolder: "../outside" } }, null, 2)}\n`, "utf8");
@@ -233,6 +253,25 @@ describe("subsessionsEnabled", () => {
}); });
}); });
describe("askUserEnabled", () => {
it("is on by default because the user is present for every ask", () => {
expect(askUserEnabled({}, {})).toBe(true);
});
it("honors an explicit config opt-out", () => {
expect(askUserEnabled({}, { askUser: false })).toBe(false);
});
it("lets the env var override the config in both directions", () => {
expect(askUserEnabled({ PI_WEB_ASK_USER: "0" }, { askUser: true })).toBe(false);
expect(askUserEnabled({ PI_WEB_ASK_USER: "true" }, { askUser: false })).toBe(true);
});
it("treats an empty env value as unset", () => {
expect(askUserEnabled({ PI_WEB_ASK_USER: "" }, { askUser: false })).toBe(false);
});
});
describe("offlineModeEnabled", () => { describe("offlineModeEnabled", () => {
it("is off when no offline env var is set", () => { it("is off when no offline env var is set", () => {
expect(offlineModeEnabled({})).toBe(false); expect(offlineModeEnabled({})).toBe(false);
+25 -1
View File
@@ -15,10 +15,11 @@ export interface LoadedPiWebConfig {
config: PiWebConfig; config: PiWebConfig;
} }
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "agent"> { export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "askUser" | "agent"> {
uploads: NonNullable<PiWebConfig["uploads"]>; uploads: NonNullable<PiWebConfig["uploads"]>;
spawnSessions: boolean; spawnSessions: boolean;
subsessions: boolean; subsessions: boolean;
askUser: boolean;
agent: Required<NonNullable<PiWebConfig["agent"]>>; agent: Required<NonNullable<PiWebConfig["agent"]>>;
} }
@@ -156,6 +157,8 @@ export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options:
spawnSessions: spawnSessionsEnabled(env, loaded.config), spawnSessions: spawnSessionsEnabled(env, loaded.config),
// Beta capability, resolved off by default. // Beta capability, resolved off by default.
subsessions: subsessionsEnabled(env, loaded.config), subsessions: subsessionsEnabled(env, loaded.config),
// Always resolved (on by default); the user is present for every ask.
askUser: askUserEnabled(env, loaded.config),
agent: { command: agent.command, dir: agent.dir }, agent: { command: agent.command, dir: agent.dir },
}, },
}; };
@@ -178,6 +181,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
delete existing["maxUploadBytes"]; delete existing["maxUploadBytes"];
delete existing["spawnSessions"]; delete existing["spawnSessions"];
delete existing["subsessions"]; delete existing["subsessions"];
delete existing["askUser"];
delete existing["agent"]; delete existing["agent"];
const merged = { ...existing, ...piWebConfigRecord(normalized) }; const merged = { ...existing, ...piWebConfigRecord(normalized) };
mkdirSync(dirname(path), { recursive: true }); mkdirSync(dirname(path), { recursive: true });
@@ -204,6 +208,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
...(config.askUser !== undefined ? { askUser: config.askUser } : {}),
...(config.agent !== undefined ? { agent: config.agent } : {}), ...(config.agent !== undefined ? { agent: config.agent } : {}),
}; };
} }
@@ -220,6 +225,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}), ...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
...(value["askUser"] !== undefined ? { askUser: parseAskUser(value["askUser"], path) } : {}),
...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}), ...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}),
}; };
} }
@@ -266,6 +272,24 @@ export function subsessionsEnabled(env: NodeJS.ProcessEnv = process.env, config:
return config.subsessions ?? false; return config.subsessions ?? false;
} }
function parseAskUser(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`PI WEB config askUser must be a boolean: ${path}`);
return value;
}
/**
* Whether LLMs may post a question set to the browser via the ask_user tool. On
* by default: the questions land in the session the user is already watching and
* nothing happens without them acting. Set the env var `PI_WEB_ASK_USER` or the
* `askUser` config key to `false` to remove the tool. The env var takes
* precedence over the config file.
*/
export function askUserEnabled(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): boolean {
const fromEnv = env["PI_WEB_ASK_USER"];
if (fromEnv !== undefined && fromEnv !== "") return fromEnv === "1" || fromEnv.toLowerCase() === "true";
return config.askUser ?? true;
}
const OFFLINE_ENV_KEYS = ["PI_WEB_OFFLINE", "PI_OFFLINE"] as const; const OFFLINE_ENV_KEYS = ["PI_WEB_OFFLINE", "PI_OFFLINE"] as const;
/** /**
@@ -128,6 +128,7 @@ function emptyConfigService(): PiWebConfigService {
allowedHosts: false, allowedHosts: false,
spawnSessions: false, spawnSessions: false,
subsessions: false, subsessions: false,
askUser: false,
agentCommand: false, agentCommand: false,
agentDir: false, agentDir: false,
agentSessionDir: false, agentSessionDir: false,
+1 -1
View File
@@ -208,7 +208,7 @@ export function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigRespo
exists: false, exists: false,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
+1 -1
View File
@@ -281,6 +281,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes
exists, exists,
config, config,
effectiveConfig: config, effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false }, envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
}; };
} }
+10
View File
@@ -15,6 +15,7 @@ export const SELECTED_MACHINE_CONFIG_KEYS = [
"maxUploadBytes", "maxUploadBytes",
"spawnSessions", "spawnSessions",
"subsessions", "subsessions",
"askUser",
"agent", "agent",
] as const satisfies readonly (keyof PiWebConfigValues)[]; ] as const satisfies readonly (keyof PiWebConfigValues)[];
@@ -131,6 +132,7 @@ function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "curr
const maxUploadBytes = value["maxUploadBytes"]; const maxUploadBytes = value["maxUploadBytes"];
const spawnSessions = value["spawnSessions"]; const spawnSessions = value["spawnSessions"];
const subsessions = value["subsessions"]; const subsessions = value["subsessions"];
const askUser = value["askUser"];
const agent = value["agent"]; const agent = value["agent"];
if (host !== undefined) { if (host !== undefined) {
if (typeof host !== "string") throw new Error("PI WEB config host must be a string"); if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
@@ -154,6 +156,10 @@ function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "curr
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean"); if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
config.subsessions = subsessions; config.subsessions = subsessions;
} }
if (askUser !== undefined) {
if (typeof askUser !== "boolean") throw new Error("PI WEB config askUser must be a boolean");
config.askUser = askUser;
}
if (agent !== undefined) config.agent = parseAgentRequest(agent, agentPathHost); if (agent !== undefined) config.agent = parseAgentRequest(agent, agentPathHost);
return config; return config;
} }
@@ -166,6 +172,7 @@ function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
...(config.askUser !== undefined ? { askUser: config.askUser } : {}),
...(config.agent !== undefined ? { agent: config.agent } : {}), ...(config.agent !== undefined ? { agent: config.agent } : {}),
}; };
} }
@@ -241,6 +248,8 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P
allowedHosts: requireResponseBoolean(record, "allowedHosts", source), allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
spawnSessions: requireResponseBoolean(record, "spawnSessions", source), spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
subsessions: requireResponseBoolean(record, "subsessions", source), subsessions: requireResponseBoolean(record, "subsessions", source),
// Older responses predate the ask_user tool; treat a missing flag as "not overridden".
askUser: optionalResponseBoolean(record, "askUser", source) ?? false,
agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false, agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false,
agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false, agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false,
...optionalAgentDirSource(record, source), ...optionalAgentDirSource(record, source),
@@ -288,6 +297,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]), allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]), spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]), subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
askUser: isEnvSet(env["PI_WEB_ASK_USER"]),
agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]), agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]),
agentDir: hasAgentDirEnvOverride(env, command), agentDir: hasAgentDirEnvOverride(env, command),
...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }), ...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }),
+62
View File
@@ -10,6 +10,7 @@ export const PI_WEB_CAPABILITIES = {
sessionsPersistedState: "sessions.persistedState", sessionsPersistedState: "sessions.persistedState",
sessionsNotifications: "sessions.notifications", sessionsNotifications: "sessions.notifications",
sessionsUnread: "sessions.unread", sessionsUnread: "sessions.unread",
sessionsAskUser: "sessions.askUser",
promptAttachments: "prompt.attachments", promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions", workspaceFileSuggestions: "workspace.fileSuggestions",
piPackagesManage: "piPackages.manage", piPackagesManage: "piPackages.manage",
@@ -97,6 +98,11 @@ export interface PiWebConfigValues {
* while the capability stabilizes. Requires spawnSessions to be enabled. * while the capability stabilizes. Requires spawnSessions to be enabled.
*/ */
subsessions?: boolean; subsessions?: boolean;
/**
* When true, LLMs can post a question set to the browser via the ask_user
* tool. On by default; set to `false` to remove the tool from the runtime.
*/
askUser?: boolean;
/** Desired Pi-compatible agent profile and companion CLI (Pi by default). */ /** Desired Pi-compatible agent profile and companion CLI (Pi by default). */
agent?: PiWebAgentConfig; agent?: PiWebAgentConfig;
} }
@@ -161,6 +167,7 @@ export interface PiWebConfigEnvOverrides {
allowedHosts: boolean; allowedHosts: boolean;
spawnSessions: boolean; spawnSessions: boolean;
subsessions: boolean; subsessions: boolean;
askUser: boolean;
agentCommand: boolean; agentCommand: boolean;
agentDir: boolean; agentDir: boolean;
/** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */ /** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */
@@ -433,6 +440,54 @@ export interface QueuedSessionMessage {
text: string; text: string;
} }
/** One selectable option of an {@link AskUserQuestion}. */
export interface AskUserQuestionOption {
/** Stable machine value reported back to the model. */
value: string;
/** Short human label rendered in the browser. */
label: string;
/** Optional clarifying line rendered under the label. */
detail?: string;
}
/**
* One question of an `ask_user` set. Questions are never required: the user may
* submit while leaving any of them untouched, and unanswered questions are
* reported to the model as such.
*/
export interface AskUserQuestion {
/** Unique within the ask; used as the answer key. */
id: string;
/** The question itself, as one plain-text line. */
question: string;
/** Optional supporting context rendered under the question. */
detail?: string;
/** Offered options; may be empty when only free text makes sense. */
options: AskUserQuestionOption[];
/** When true, a labelled free-text field is offered alongside the options. */
allowOther?: boolean;
/** When true, several options may be selected at once. */
multiple?: boolean;
}
/**
* The open, unanswered question set of a session. Daemon-owned and reported in
* {@link SessionStatus}, so a reconnecting or reloading browser rehydrates it
* without depending on having seen the `ask.opened` event.
*/
export interface PendingAskUser {
askId: string;
askedAt: string;
questions: AskUserQuestion[];
}
/**
* Why an ask stopped being the session's open ask. The answer/outcome types that
* describe *what* the user replied arrive with the pending-ask store that
* computes them.
*/
export type AskUserCloseReason = "submitted" | "superseded" | "cancelled";
/** /**
* Progress of the session startup window, where the daemon is still * Progress of the session startup window, where the daemon is still
* constructing the agent session and no `PiAgentSession` exists yet, so * constructing the agent session and no `PiAgentSession` exists yet, so
@@ -610,6 +665,11 @@ export interface SessionStatus {
* there are none. See {@link SessionWarning}. * there are none. See {@link SessionWarning}.
*/ */
warnings?: SessionWarning[]; warnings?: SessionWarning[];
/**
* The session's open `ask_user` question set, when one is waiting for the
* user. Daemon-owned, so it survives browser reload and web/API restarts.
*/
pendingAsk?: PendingAskUser;
} }
export interface WorkspaceActivity { export interface WorkspaceActivity {
@@ -990,6 +1050,8 @@ type SessionUiEventBody =
| { type: "command.output"; level: "info" | "success" | "error"; message: string; notificationId?: string } | { type: "command.output"; level: "info" | "success" | "error"; message: string; notificationId?: string }
| SessionNotificationInboxEvent | SessionNotificationInboxEvent
| { type: "session.error"; message: string } | { type: "session.error"; message: string }
| { type: "ask.opened"; ask: PendingAskUser }
| { type: "ask.closed"; askId: string; reason: AskUserCloseReason }
| { type: "session.name"; sessionId: string; name?: string } | { type: "session.name"; sessionId: string; name?: string }
| { type: "session.created"; session: SessionInfo } | { type: "session.created"; session: SessionInfo }
| { type: "pi.event"; eventType: string }; | { type: "pi.event"; eventType: string };
+20
View File
@@ -90,6 +90,26 @@ describe("PI WEB capabilities", () => {
})).toContain(unread); })).toContain(unread);
}); });
it("renders the question card only when both runtimes support daemon-owned asks", () => {
const askUser = PI_WEB_CAPABILITIES.sessionsAskUser;
expect(WEB_RUNTIME_CAPABILITIES).toContain(askUser);
expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(askUser);
expect(parseKnownPiWebCapabilities([askUser, "future.capability"])).toEqual([askUser]);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [askUser] },
sessiond: { available: true, capabilities: [] },
})).not.toContain(askUser);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [] },
sessiond: { available: true, capabilities: [askUser] },
})).not.toContain(askUser);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [askUser] },
sessiond: { available: true, capabilities: [askUser] },
})).toContain(askUser);
});
it("keeps only known string capabilities when parsing runtime data", () => { it("keeps only known string capabilities when parsing runtime data", () => {
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]); expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined(); expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
+3
View File
@@ -15,6 +15,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.sessionsNotifications, PI_WEB_CAPABILITIES.sessionsNotifications,
PI_WEB_CAPABILITIES.sessionsUnread, PI_WEB_CAPABILITIES.sessionsUnread,
PI_WEB_CAPABILITIES.sessionsAskUser,
PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.promptAttachments,
PI_WEB_CAPABILITIES.workspaceFileSuggestions, PI_WEB_CAPABILITIES.workspaceFileSuggestions,
PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.piPackagesManage,
@@ -31,6 +32,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.sessionsNotifications, PI_WEB_CAPABILITIES.sessionsNotifications,
PI_WEB_CAPABILITIES.sessionsUnread, PI_WEB_CAPABILITIES.sessionsUnread,
PI_WEB_CAPABILITIES.sessionsAskUser,
PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.promptAttachments,
] as const satisfies readonly PiWebCapability[]; ] as const satisfies readonly PiWebCapability[];
@@ -43,6 +45,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsNotifications]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsNotifications]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsUnread]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsUnread]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsAskUser]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"], [PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],