Archived
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:
@@ -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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user