From 6fa57b524bc1b59f4c592e1b8b22d20ab1e146de Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 26 Jul 2026 21:26:23 +0200 Subject: [PATCH] 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. --- src/client/src/api/clients.test.ts | 2 +- src/client/src/api/parsers.test.ts | 4 +- src/client/src/api/parsers.ts | 3 + .../components/SettingsDialog.testSupport.ts | 2 +- .../settings/SettingsGeneralPanel.test.ts | 2 +- .../settings/SettingsPluginsPanel.test.ts | 2 +- .../settings/SettingsSessiondPanel.test.ts | 2 +- .../settings/SettingsShortcutsPanel.test.ts | 2 +- .../settings/settingsConfigDraft.ts | 1 + .../settings/settingsDataLoading.test.ts | 2 +- .../settingsMachineAccessConfig.test.ts | 2 +- .../settings/settingsPluginConfig.test.ts | 2 +- .../settings/settingsSessiondConfig.test.ts | 2 + .../settings/settingsSessiondConfig.ts | 1 + src/config.test.ts | 41 +++++++++++- src/config.ts | 26 +++++++- src/server/app.activeAgentProfile.test.ts | 1 + src/server/app.testSupport.ts | 2 +- src/server/configRoutes.test.ts | 2 +- src/server/configRoutes.ts | 10 +++ src/shared/apiTypes.ts | 62 +++++++++++++++++++ src/shared/capabilities.test.ts | 20 ++++++ src/shared/capabilities.ts | 3 + 23 files changed, 181 insertions(+), 15 deletions(-) diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index eda5907..6a3ef02 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -578,7 +578,7 @@ function piWebConfigResponse(config: PiWebConfigValues) { exists: true, 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 }, }; } diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index d356dc2..1e0eebe 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -61,13 +61,13 @@ describe("API parsers", () => { 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" } }, 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({ path: "/tmp/config.json", 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" } }, 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 }, }); }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 9941aa0..758009a 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -958,6 +958,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("agent", optionalAgent(record["agent"])), ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), ...optionalField("subsessions", optionalBoolean(record, "subsessions")), + ...optionalField("askUser", optionalBoolean(record, "askUser")), }; } @@ -1038,6 +1039,8 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), 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, agentDir: optionalBoolean(record, "agentDir") ?? false, ...optionalAgentDirSource(record), diff --git a/src/client/src/components/SettingsDialog.testSupport.ts b/src/client/src/components/SettingsDialog.testSupport.ts index 6658996..e1a87c0 100644 --- a/src/client/src/components/SettingsDialog.testSupport.ts +++ b/src/client/src/components/SettingsDialog.testSupport.ts @@ -63,7 +63,7 @@ export function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, 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 }, }; } diff --git a/src/client/src/components/settings/SettingsGeneralPanel.test.ts b/src/client/src/components/settings/SettingsGeneralPanel.test.ts index 83190e5..d111349 100644 --- a/src/client/src/components/settings/SettingsGeneralPanel.test.ts +++ b/src/client/src/components/settings/SettingsGeneralPanel.test.ts @@ -230,6 +230,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, 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 }, }; } diff --git a/src/client/src/components/settings/SettingsPluginsPanel.test.ts b/src/client/src/components/settings/SettingsPluginsPanel.test.ts index 8225d33..e340a4b 100644 --- a/src/client/src/components/settings/SettingsPluginsPanel.test.ts +++ b/src/client/src/components/settings/SettingsPluginsPanel.test.ts @@ -162,7 +162,7 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, 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 }, }; } diff --git a/src/client/src/components/settings/SettingsSessiondPanel.test.ts b/src/client/src/components/settings/SettingsSessiondPanel.test.ts index 3696f5a..8bb9f37 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.test.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.test.ts @@ -153,6 +153,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, 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 }, }; } diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.test.ts b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts index aba2875..50abb9e 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.test.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts @@ -258,6 +258,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, 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 }, }; } diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 0afca5d..a7a3018 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -101,6 +101,7 @@ function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebCo ...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }), ...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }), ...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }), + ...(baseConfig.askUser === undefined ? {} : { askUser: baseConfig.askUser }), ...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }), }; } diff --git a/src/client/src/components/settings/settingsDataLoading.test.ts b/src/client/src/components/settings/settingsDataLoading.test.ts index bd32d48..0e4d6bb 100644 --- a/src/client/src/components/settings/settingsDataLoading.test.ts +++ b/src/client/src/components/settings/settingsDataLoading.test.ts @@ -8,7 +8,7 @@ const configResponse: PiWebConfigResponse = { exists: true, config: { 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: [] }; diff --git a/src/client/src/components/settings/settingsMachineAccessConfig.test.ts b/src/client/src/components/settings/settingsMachineAccessConfig.test.ts index 34a04ca..22bf8d3 100644 --- a/src/client/src/components/settings/settingsMachineAccessConfig.test.ts +++ b/src/client/src/components/settings/settingsMachineAccessConfig.test.ts @@ -81,6 +81,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, 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 }, }; } diff --git a/src/client/src/components/settings/settingsPluginConfig.test.ts b/src/client/src/components/settings/settingsPluginConfig.test.ts index e123378..af9ac51 100644 --- a/src/client/src/components/settings/settingsPluginConfig.test.ts +++ b/src/client/src/components/settings/settingsPluginConfig.test.ts @@ -64,6 +64,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse { exists: true, 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 }, }; } diff --git a/src/client/src/components/settings/settingsSessiondConfig.test.ts b/src/client/src/components/settings/settingsSessiondConfig.test.ts index 69d1752..97a6a36 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.test.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.test.ts @@ -86,6 +86,7 @@ describe("session daemon settings config helpers", () => { allowedHosts: false, spawnSessions: true, subsessions: false, + askUser: false, agentCommand: true, agentDir: false, agentDirSource: "pi-compatibility", @@ -121,6 +122,7 @@ function configResponse( allowedHosts: false, spawnSessions: false, subsessions: false, + askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false, diff --git a/src/client/src/components/settings/settingsSessiondConfig.ts b/src/client/src/components/settings/settingsSessiondConfig.ts index f330482..202f194 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.ts @@ -41,6 +41,7 @@ export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, se ...base.envOverrides, spawnSessions: selectedMachine.envOverrides.spawnSessions, subsessions: selectedMachine.envOverrides.subsessions, + askUser: selectedMachine.envOverrides.askUser, agentCommand: selectedMachine.envOverrides.agentCommand, agentDir: selectedMachine.envOverrides.agentDir, agentSessionDir: selectedMachine.envOverrides.agentSessionDir, diff --git a/src/config.test.ts b/src/config.test.ts index b2ae4cf..c212f20 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; 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 configPath: string; @@ -182,6 +182,26 @@ describe("PI WEB config persistence", () => { 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 () => { 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", () => { it("is off when no offline env var is set", () => { expect(offlineModeEnabled({})).toBe(false); diff --git a/src/config.ts b/src/config.ts index 5f766b5..c8a8e2a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,10 +15,11 @@ export interface LoadedPiWebConfig { config: PiWebConfig; } -export interface EffectivePiWebConfig extends Omit { +export interface EffectivePiWebConfig extends Omit { uploads: NonNullable; spawnSessions: boolean; subsessions: boolean; + askUser: boolean; agent: Required>; } @@ -156,6 +157,8 @@ export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: spawnSessions: spawnSessionsEnabled(env, loaded.config), // Beta capability, resolved off by default. 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 }, }, }; @@ -178,6 +181,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): delete existing["maxUploadBytes"]; delete existing["spawnSessions"]; delete existing["subsessions"]; + delete existing["askUser"]; delete existing["agent"]; const merged = { ...existing, ...piWebConfigRecord(normalized) }; mkdirSync(dirname(path), { recursive: true }); @@ -204,6 +208,7 @@ function piWebConfigRecord(config: PiWebConfig): Record { ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), + ...(config.askUser !== undefined ? { askUser: config.askUser } : {}), ...(config.agent !== undefined ? { agent: config.agent } : {}), }; } @@ -220,6 +225,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], 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) } : {}), }; } @@ -266,6 +272,24 @@ export function subsessionsEnabled(env: NodeJS.ProcessEnv = process.env, config: 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; /** diff --git a/src/server/app.activeAgentProfile.test.ts b/src/server/app.activeAgentProfile.test.ts index c80b3fd..a1d66ae 100644 --- a/src/server/app.activeAgentProfile.test.ts +++ b/src/server/app.activeAgentProfile.test.ts @@ -128,6 +128,7 @@ function emptyConfigService(): PiWebConfigService { allowedHosts: false, spawnSessions: false, subsessions: false, + askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false, diff --git a/src/server/app.testSupport.ts b/src/server/app.testSupport.ts index 3d689e1..db4491c 100644 --- a/src/server/app.testSupport.ts +++ b/src/server/app.testSupport.ts @@ -208,7 +208,7 @@ export function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigRespo exists: false, 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 }, }; } diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 3067008..8e01ecc 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -281,6 +281,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes exists, 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 }, }; } diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index ef3c6b4..35205a2 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -15,6 +15,7 @@ export const SELECTED_MACHINE_CONFIG_KEYS = [ "maxUploadBytes", "spawnSessions", "subsessions", + "askUser", "agent", ] as const satisfies readonly (keyof PiWebConfigValues)[]; @@ -131,6 +132,7 @@ function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "curr const maxUploadBytes = value["maxUploadBytes"]; const spawnSessions = value["spawnSessions"]; const subsessions = value["subsessions"]; + const askUser = value["askUser"]; const agent = value["agent"]; if (host !== undefined) { 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"); 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); return config; } @@ -166,6 +172,7 @@ function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig { ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), + ...(config.askUser !== undefined ? { askUser: config.askUser } : {}), ...(config.agent !== undefined ? { agent: config.agent } : {}), }; } @@ -241,6 +248,8 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P allowedHosts: requireResponseBoolean(record, "allowedHosts", source), spawnSessions: requireResponseBoolean(record, "spawnSessions", 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, agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false, ...optionalAgentDirSource(record, source), @@ -288,6 +297,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = { allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]), spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]), subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]), + askUser: isEnvSet(env["PI_WEB_ASK_USER"]), agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]), agentDir: hasAgentDirEnvOverride(env, command), ...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }), diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index e934fe4..58d00c7 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -10,6 +10,7 @@ export const PI_WEB_CAPABILITIES = { sessionsPersistedState: "sessions.persistedState", sessionsNotifications: "sessions.notifications", sessionsUnread: "sessions.unread", + sessionsAskUser: "sessions.askUser", promptAttachments: "prompt.attachments", workspaceFileSuggestions: "workspace.fileSuggestions", piPackagesManage: "piPackages.manage", @@ -97,6 +98,11 @@ export interface PiWebConfigValues { * while the capability stabilizes. Requires spawnSessions to be enabled. */ 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). */ agent?: PiWebAgentConfig; } @@ -161,6 +167,7 @@ export interface PiWebConfigEnvOverrides { allowedHosts: boolean; spawnSessions: boolean; subsessions: boolean; + askUser: boolean; agentCommand: boolean; agentDir: boolean; /** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */ @@ -433,6 +440,54 @@ export interface QueuedSessionMessage { 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 * constructing the agent session and no `PiAgentSession` exists yet, so @@ -610,6 +665,11 @@ export interface SessionStatus { * there are none. See {@link 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 { @@ -990,6 +1050,8 @@ type SessionUiEventBody = | { type: "command.output"; level: "info" | "success" | "error"; message: string; notificationId?: string } | SessionNotificationInboxEvent | { 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.created"; session: SessionInfo } | { type: "pi.event"; eventType: string }; diff --git a/src/shared/capabilities.test.ts b/src/shared/capabilities.test.ts index c5ab93f..534698f 100644 --- a/src/shared/capabilities.test.ts +++ b/src/shared/capabilities.test.ts @@ -90,6 +90,26 @@ describe("PI WEB capabilities", () => { })).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", () => { 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(); diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index e2ccbcc..a3432c8 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -15,6 +15,7 @@ export const WEB_RUNTIME_CAPABILITIES = [ PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.sessionsNotifications, PI_WEB_CAPABILITIES.sessionsUnread, + PI_WEB_CAPABILITIES.sessionsAskUser, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions, PI_WEB_CAPABILITIES.piPackagesManage, @@ -31,6 +32,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [ PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.sessionsNotifications, PI_WEB_CAPABILITIES.sessionsUnread, + PI_WEB_CAPABILITIES.sessionsAskUser, PI_WEB_CAPABILITIES.promptAttachments, ] as const satisfies readonly PiWebCapability[]; @@ -43,6 +45,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsNotifications]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsUnread]: ["web", "sessiond"], + [PI_WEB_CAPABILITIES.sessionsAskUser]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],